From d56d16124e3b6678bcf8e160ab7dfb3969033ad7 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 24 Jun 2026 14:39:05 -0500 Subject: [PATCH] fix: scope getOrganization lookup to the authenticated session getOrganizationAction accepted a client-supplied organizationId and resolved it with the app's WorkOS API key, without verifying the caller's session was authenticated within that organization. Because the API key can read any organization in the environment, a caller could resolve an arbitrary organization's id and name by ID. Scope the lookup to the session: return null unless the caller is authenticated and the requested organizationId matches the session's org_id. The impersonation banner -- the only consumer -- already passes the session's own organization id, so legitimate behavior is unchanged. Adds regression tests for the cross-organization and unauthenticated cases. --- src/server/action-bodies.ts | 9 +++++++++ src/server/actions.spec.ts | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/server/action-bodies.ts b/src/server/action-bodies.ts index cb1a16b..8fe488d 100644 --- a/src/server/action-bodies.ts +++ b/src/server/action-bodies.ts @@ -69,6 +69,15 @@ export async function switchToOrganizationBody(data: { export async function getOrganizationBody(organizationId: string): Promise { try { + // Authorization: only resolve the organization the caller is currently + // authenticated within. The WorkOS client uses the app's API key, which can + // read any organization in the environment, so without this check any caller + // could fetch arbitrary organizations by ID (authorization bypass / IDOR). + const auth = getRawAuthFromContext(); + if (!auth.user || auth.claims?.org_id !== organizationId) { + return null; + } + const { getWorkOS } = await import('@workos/authkit-session'); const workos = getWorkOS(); const org = await workos.organizations.getOrganization(organizationId); diff --git a/src/server/actions.spec.ts b/src/server/actions.spec.ts index a0e999f..22a80fb 100644 --- a/src/server/actions.spec.ts +++ b/src/server/actions.spec.ts @@ -294,7 +294,11 @@ describe('Actions', () => { }); describe('getOrganizationAction', () => { - it('returns organization info on success', async () => { + it('returns organization info for the current session organization', async () => { + mockAuthContext = { + auth: () => ({ user: { id: 'user_123' }, claims: { org_id: 'org_123' } }), + request: new Request('http://test.local'), + }; mockGetOrganization.mockResolvedValue({ id: 'org_123', name: 'Test Org' }); const result = await getOrganizationAction({ data: 'org_123' }); @@ -303,7 +307,35 @@ describe('Actions', () => { expect(result).toEqual({ id: 'org_123', name: 'Test Org' }); }); + it('denies fetching an organization the caller is not authenticated within', async () => { + // Session is scoped to org_123; the caller requests a different org. + mockAuthContext = { + auth: () => ({ user: { id: 'user_123' }, claims: { org_id: 'org_123' } }), + request: new Request('http://test.local'), + }; + mockGetOrganization.mockResolvedValue({ id: 'org_456', name: 'Victim Org' }); + + const result = await getOrganizationAction({ data: 'org_456' }); + + expect(result).toBeNull(); + expect(mockGetOrganization).not.toHaveBeenCalled(); + }); + + it('returns null when there is no authenticated session', async () => { + mockAuthContext = null; + mockGetOrganization.mockResolvedValue({ id: 'org_123', name: 'Test Org' }); + + const result = await getOrganizationAction({ data: 'org_123' }); + + expect(result).toBeNull(); + expect(mockGetOrganization).not.toHaveBeenCalled(); + }); + it('returns null when organization is not found', async () => { + mockAuthContext = { + auth: () => ({ user: { id: 'user_123' }, claims: { org_id: 'bad_org' } }), + request: new Request('http://test.local'), + }; mockGetOrganization.mockRejectedValue(new Error('Not found')); const result = await getOrganizationAction({ data: 'bad_org' });