Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/khaki-chairs-kiss.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 69 additions & 7 deletions packages/backend/src/tokens/__tests__/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2058,7 +2058,8 @@ describe('tokens.authenticateRequest(options)', () => {
});
});

test('does not trigger handshake when referer is from production accounts portal', async () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We revert the logic of this test because it tested that example.com's account portal wouldn't trigger a handshake on another (primary.com's) instance.

// 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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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(
{
Expand Down
23 changes: 11 additions & 12 deletions packages/backend/src/tokens/authenticateContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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
Expand Down
Loading