Skip to content

Commit db0893d

Browse files
committed
fix(backend): Fix cross-origin handshakre bypass
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.
1 parent 1d0e78c commit db0893d

3 files changed

Lines changed: 72 additions & 16 deletions

File tree

.changeset/khaki-chairs-kiss.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': minor
3+
---
4+
5+
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.

packages/backend/src/tokens/__tests__/request.test.ts

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2058,7 +2058,8 @@ describe('tokens.authenticateRequest(options)', () => {
20582058
});
20592059
});
20602060

2061-
test('does not trigger handshake when referer is from production accounts portal', async () => {
2061+
// accounts.example.com is not this instance's derived accounts portal, so it must not be trusted.
2062+
test('triggers handshake when referer is an unrelated accounts.* domain', async () => {
20622063
const request = mockRequestWithCookies(
20632064
{
20642065
referer: 'https://accounts.example.com/sign-in',
@@ -2080,14 +2081,65 @@ describe('tokens.authenticateRequest(options)', () => {
20802081
signInUrl: 'https://primary.com/sign-in',
20812082
});
20822083

2083-
expect(requestState).toBeSignedIn({
2084+
expect(requestState).toMatchHandshake({
2085+
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
2086+
domain: 'primary.com',
2087+
signInUrl: 'https://primary.com/sign-in',
2088+
});
2089+
});
2090+
2091+
test('does not trigger cross-origin handshake when referer is from dev accounts portal on a dev instance (current format)', async () => {
2092+
const request = mockRequestWithCookies(
2093+
{
2094+
referer: 'https://foo-bar-13.accounts.dev/sign-in',
2095+
'sec-fetch-dest': 'document',
2096+
'sec-fetch-site': 'cross-site',
2097+
},
2098+
{
2099+
__session: mockJwt,
2100+
__client_uat: '12345',
2101+
},
2102+
'https://primary.com/dashboard',
2103+
);
2104+
2105+
const requestState = await authenticateRequest(request, {
2106+
...mockOptions(),
2107+
publishableKey: PK_TEST,
2108+
domain: 'primary.com',
2109+
isSatellite: false,
2110+
signInUrl: 'https://primary.com/sign-in',
2111+
});
2112+
2113+
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
2114+
});
2115+
2116+
test('does not trigger cross-origin handshake when referer is from dev accounts portal on a dev instance (legacy format)', async () => {
2117+
const request = mockRequestWithCookies(
2118+
{
2119+
referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in',
2120+
'sec-fetch-dest': 'document',
2121+
'sec-fetch-site': 'cross-site',
2122+
},
2123+
{
2124+
__session: mockJwt,
2125+
__client_uat: '12345',
2126+
},
2127+
'https://primary.com/dashboard',
2128+
);
2129+
2130+
const requestState = await authenticateRequest(request, {
2131+
...mockOptions(),
2132+
publishableKey: PK_TEST,
20842133
domain: 'primary.com',
20852134
isSatellite: false,
20862135
signInUrl: 'https://primary.com/sign-in',
20872136
});
2137+
2138+
expect(requestState.reason).not.toBe(AuthErrorReason.PrimaryDomainCrossOriginSync);
20882139
});
20892140

2090-
test('does not trigger handshake when referer is from dev accounts portal (current format)', async () => {
2141+
// A production instance must not trust dev-portal referrers, which are freely obtainable.
2142+
test('triggers handshake when referer is a dev accounts portal on a production instance (current format)', async () => {
20912143
const request = mockRequestWithCookies(
20922144
{
20932145
referer: 'https://foo-bar-13.accounts.dev/sign-in',
@@ -2109,14 +2161,14 @@ describe('tokens.authenticateRequest(options)', () => {
21092161
signInUrl: 'https://primary.com/sign-in',
21102162
});
21112163

2112-
expect(requestState).toBeSignedIn({
2164+
expect(requestState).toMatchHandshake({
2165+
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
21132166
domain: 'primary.com',
2114-
isSatellite: false,
21152167
signInUrl: 'https://primary.com/sign-in',
21162168
});
21172169
});
21182170

2119-
test('does not trigger handshake when referer is from dev accounts portal (legacy format)', async () => {
2171+
test('triggers handshake when referer is a dev accounts portal on a production instance (legacy format)', async () => {
21202172
const request = mockRequestWithCookies(
21212173
{
21222174
referer: 'https://accounts.foo-bar-13.lcl.dev/sign-in',
@@ -2138,9 +2190,9 @@ describe('tokens.authenticateRequest(options)', () => {
21382190
signInUrl: 'https://primary.com/sign-in',
21392191
});
21402192

2141-
expect(requestState).toBeSignedIn({
2193+
expect(requestState).toMatchHandshake({
2194+
reason: AuthErrorReason.PrimaryDomainCrossOriginSync,
21422195
domain: 'primary.com',
2143-
isSatellite: false,
21442196
signInUrl: 'https://primary.com/sign-in',
21452197
});
21462198
});

packages/backend/src/tokens/authenticateContext.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -239,12 +239,16 @@ class AuthenticateContext implements AuthenticateContext {
239239
}
240240
}
241241

242-
// Check for development account portal patterns
243-
if (isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost)) {
242+
// Dev account-portal domains are freely obtainable, so only trust them on non-production instances.
243+
if (
244+
this.instanceType !== 'production' &&
245+
(isLegacyDevAccountPortalOrigin(referrerHost) || isCurrentDevAccountPortalOrigin(referrerHost))
246+
) {
244247
return true;
245248
}
246249

247-
// Check for production account portal by comparing with expected accounts URL
250+
// Only trust the accounts portal derived from this instance's frontend API — never a
251+
// generic `accounts.*` prefix, which any attacker-controlled domain could match.
248252
const expectedAccountsUrl = buildAccountsBaseUrl(this.frontendApi);
249253
if (expectedAccountsUrl) {
250254
const expectedAccountsOrigin = new URL(expectedAccountsUrl).origin;
@@ -253,11 +257,6 @@ class AuthenticateContext implements AuthenticateContext {
253257
}
254258
}
255259

256-
// Check for generic production accounts patterns (accounts.*)
257-
if (referrerHost.startsWith('accounts.')) {
258-
return true;
259-
}
260-
261260
return false;
262261
} catch {
263262
// Invalid URL format

0 commit comments

Comments
 (0)