diff --git a/.changeset/khaki-chairs-kiss.md b/.changeset/khaki-chairs-kiss.md new file mode 100644 index 00000000000..ff01e897fd2 --- /dev/null +++ b/.changeset/khaki-chairs-kiss.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Fix a cross-origin handshake bypass where `isKnownClerkReferrer()` trusted overly broad referrer hosts as Clerk-owned: any `accounts.*` host (e.g. `accounts.attacker.com`), plus dev account-portal domains (`*.accounts.dev` and legacy suffixes) on production instances. These let unrelated origins skip the handshake and its session-freshness check. The referrer is now trusted only for the accounts portal derived from the instance's frontend API, plus dev account-portal domains on non-production instances. diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index f2ab2b5a663..b086ae54aa2 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -2058,7 +2058,8 @@ describe('tokens.authenticateRequest(options)', () => { }); }); - test('does not trigger handshake when referer is from production accounts portal', async () => { + // accounts.example.com is not this instance's derived accounts portal, so it must not be trusted. + test('triggers handshake when referer is an unrelated accounts.* domain', async () => { const request = mockRequestWithCookies( { referer: 'https://accounts.example.com/sign-in', @@ -2080,14 +2081,14 @@ describe('tokens.authenticateRequest(options)', () => { signInUrl: 'https://primary.com/sign-in', }); - expect(requestState).toBeSignedIn({ + expect(requestState).toMatchHandshake({ + reason: AuthErrorReason.PrimaryDomainCrossOriginSync, domain: 'primary.com', - isSatellite: false, signInUrl: 'https://primary.com/sign-in', }); }); - test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => { + test('does not trigger cross-origin handshake when referer is from dev accounts portal on a dev instance (current format)', async () => { const request = mockRequestWithCookies( { referer: 'https://foo-bar-13.accounts.dev/sign-in', @@ -2097,13 +2098,14 @@ describe('tokens.authenticateRequest(options)', () => { { __session: mockJwt, __client_uat: '12345', + __clerk_db_jwt: mockJwt, }, 'https://primary.com/dashboard', ); const requestState = await authenticateRequest(request, { ...mockOptions(), - publishableKey: PK_LIVE, + publishableKey: PK_TEST, domain: 'primary.com', isSatellite: false, signInUrl: 'https://primary.com/sign-in', @@ -2116,7 +2118,7 @@ describe('tokens.authenticateRequest(options)', () => { }); }); - test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => { + test('does not trigger cross-origin handshake when referer is from dev accounts portal on a dev instance (legacy format)', async () => { const request = mockRequestWithCookies( { referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in', @@ -2126,13 +2128,14 @@ describe('tokens.authenticateRequest(options)', () => { { __session: mockJwt, __client_uat: '12345', + __clerk_db_jwt: mockJwt, }, 'https://primary.com/dashboard', ); const requestState = await authenticateRequest(request, { ...mockOptions(), - publishableKey: PK_LIVE, + publishableKey: PK_TEST, domain: 'primary.com', isSatellite: false, signInUrl: 'https://primary.com/sign-in', @@ -2145,6 +2148,65 @@ describe('tokens.authenticateRequest(options)', () => { }); }); + // A production instance must not trust dev-portal referrers, which are freely obtainable. + test('triggers handshake when referer is a dev accounts portal on a production instance (current format)', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://foo-bar-13.accounts.dev/sign-in', + 'sec-fetch-dest': 'document', + 'sec-fetch-site': 'cross-site', + }, + { + __session: mockJwt, + __client_uat: '12345', + }, + 'https://primary.com/dashboard', + ); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + publishableKey: PK_LIVE, + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + + expect(requestState).toMatchHandshake({ + reason: AuthErrorReason.PrimaryDomainCrossOriginSync, + domain: 'primary.com', + signInUrl: 'https://primary.com/sign-in', + }); + }); + + test('triggers handshake when referer is a dev accounts portal on a production instance (legacy format)', async () => { + const request = mockRequestWithCookies( + { + referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in', + 'sec-fetch-dest': 'document', + 'sec-fetch-site': 'cross-site', + }, + { + __session: mockJwt, + __client_uat: '12345', + }, + 'https://primary.com/dashboard', + ); + + const requestState = await authenticateRequest(request, { + ...mockOptions(), + publishableKey: PK_LIVE, + domain: 'primary.com', + isSatellite: false, + signInUrl: 'https://primary.com/sign-in', + }); + + expect(requestState).toMatchHandshake({ + reason: AuthErrorReason.PrimaryDomainCrossOriginSync, + domain: 'primary.com', + signInUrl: 'https://primary.com/sign-in', + }); + }); + test('does not trigger cross-origin handshake when referer is from expected accounts portal derived from frontend API', async () => { const request = mockRequestWithCookies( { diff --git a/packages/backend/src/tokens/authenticateContext.ts b/packages/backend/src/tokens/authenticateContext.ts index e8d1d682330..aaf1d8f7e04 100644 --- a/packages/backend/src/tokens/authenticateContext.ts +++ b/packages/backend/src/tokens/authenticateContext.ts @@ -216,11 +216,11 @@ class AuthenticateContext implements AuthenticateContext { } /** - * Determines if the referrer URL is from a Clerk domain (accounts portal or FAPI). - * This includes both development and production account portal domains, as well as FAPI domains - * used for redirect-based authentication flows. + * Determines if the referrer URL is from a Clerk domain: the instance's FAPI domain, the accounts + * portal derived from its frontend API, or — on non-production instances only — a development + * account-portal domain. * - * @returns {boolean} True if the referrer is from a Clerk accounts portal or FAPI domain, false otherwise + * @returns {boolean} True if the referrer is a trusted Clerk domain, false otherwise */ public isKnownClerkReferrer(): boolean { if (!this.referrer) { @@ -239,12 +239,16 @@ class AuthenticateContext implements AuthenticateContext { } } - // Check for development account portal patterns - if (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) { + // Dev account-portal domains are freely obtainable, so only trust them on non-production instances. + if ( + this.instanceType !== 'production' && + (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) + ) { return true; } - // Check for production account portal by comparing with expected accounts URL + // Only trust the accounts portal derived from this instance's frontend API — never a + // generic `accounts.*` prefix, which any attacker-controlled domain could match. const expectedAccountsUrl = buildAccountsBaseUrl(this.frontendApi); if (expectedAccountsUrl) { const expectedAccountsOrigin = new URL(expectedAccountsUrl).origin; @@ -253,11 +257,6 @@ class AuthenticateContext implements AuthenticateContext { } } - // Check for generic production accounts patterns (accounts.*) - if (referrerHost.startsWith('accounts.')) { - return true; - } - return false; } catch { // Invalid URL format