diff --git a/src/api/auth-middleware.ts b/src/api/auth-middleware.ts index fad9f6a..cac5c23 100644 --- a/src/api/auth-middleware.ts +++ b/src/api/auth-middleware.ts @@ -168,18 +168,12 @@ export async function resolveWorkspace( throw new WorkspaceResolutionError("Invalid workspace ID format.", 400); } - // Validate membership + // Validate membership. Unauthorized access and non-existence are reported identically + // — same message, same status — so callers cannot probe for workspace existence. const workspace = await workspaceStore.get(workspaceId); - if (!workspace) { - throw new WorkspaceResolutionError(`Workspace "${workspaceId}" not found.`, 400); - } - - const isMember = workspace.members.some((m) => m.userId === identity.id); - if (!isMember) { - throw new WorkspaceResolutionError( - `Access denied: not a member of workspace "${workspaceId}".`, - 403, - ); + const isMember = workspace?.members.some((m) => m.userId === identity.id) ?? false; + if (!workspace || !isMember) { + throw new WorkspaceResolutionError("Access denied to workspace.", 403); } return workspaceId; diff --git a/test/unit/api/workspace-context.test.ts b/test/unit/api/workspace-context.test.ts index d684acb..95d75ab 100644 --- a/test/unit/api/workspace-context.test.ts +++ b/test/unit/api/workspace-context.test.ts @@ -121,7 +121,7 @@ describe("resolveWorkspace", () => { expect(ws!.members.some((m) => m.userId === "usr_nows")).toBe(true); }); - it("returns 403 when user is not a member of the specified workspace", async () => { + it("returns generic 403 when user is not a member of the specified workspace", async () => { const ws = await workspaceStore.create("Forbidden WS"); // Don't add the identity as a member const identity = makeIdentity({ id: "usr_nonmember" }); @@ -135,11 +135,13 @@ describe("resolveWorkspace", () => { expect(err).toBeInstanceOf(WorkspaceResolutionError); const wsErr = err as WorkspaceResolutionError; expect(wsErr.statusCode).toBe(403); - expect(wsErr.message).toContain("Access denied"); + expect(wsErr.message).toBe("Access denied to workspace."); + // Must not echo the workspace ID (information disclosure). + expect(wsErr.message).not.toContain(ws.id); } }); - it("returns 400 when X-Workspace-Id references a non-existent workspace", async () => { + it("returns generic 403 when X-Workspace-Id references a non-existent workspace", async () => { const identity = makeIdentity({ id: "usr_badref" }); const req = makeRequest({ "x-workspace-id": "ws_doesnotexist" }); @@ -149,8 +151,10 @@ describe("resolveWorkspace", () => { } catch (err) { expect(err).toBeInstanceOf(WorkspaceResolutionError); const wsErr = err as WorkspaceResolutionError; - expect(wsErr.statusCode).toBe(400); - expect(wsErr.message).toContain("not found"); + // Same response as "not a member" so callers cannot probe for existence. + expect(wsErr.statusCode).toBe(403); + expect(wsErr.message).toBe("Access denied to workspace."); + expect(wsErr.message).not.toContain("ws_doesnotexist"); } });