Skip to content
Merged
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/warm-clubs-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Fix an issue where multiple `set-cookie` headers were being dropped by the frontend API proxy.
43 changes: 43 additions & 0 deletions packages/backend/src/__tests__/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,22 @@ describe('proxy', () => {
expect(body.errors[0].code).toBe('proxy_path_mismatch');
});

it('does not follow protocol-relative paths', async () => {
const mockResponse = new Response('{}', { status: 200 });
mockFetch.mockResolvedValueOnce(mockResponse);

const request = new Request('https://example.com/__clerk//evil.com/steal');

await clerkFrontendApiProxy(request, {
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
secretKey: 'sk_test_xxx',
});

// String concatenation keeps the host as FAPI, not evil.com
const fetchedUrl = new URL(mockFetch.mock.calls[0][0] as string);
expect(fetchedUrl.host).toBe('frontend-api.clerk.dev');
});

it('forwards GET request to FAPI with correct headers', async () => {
const mockResponse = new Response(JSON.stringify({ client: {} }), {
status: 200,
Expand Down Expand Up @@ -525,6 +541,33 @@ describe('proxy', () => {
expect(response.headers.get('Content-Type')).toBe('application/javascript');
});

it('preserves multiple Set-Cookie headers from FAPI response', async () => {
const headers = new Headers();
headers.append('Set-Cookie', '__client=abc123; Path=/; HttpOnly; Secure');
headers.append('Set-Cookie', '__client_uat=1234567890; Path=/; Secure');
headers.append('Set-Cookie', '__session=xyz789; Path=/; HttpOnly; Secure');
headers.append('Content-Type', 'application/json');

const mockResponse = new Response(JSON.stringify({ client: {} }), {
status: 200,
headers,
});
mockFetch.mockResolvedValue(mockResponse);

const request = new Request('https://example.com/__clerk/v1/client');

const response = await clerkFrontendApiProxy(request, {
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
secretKey: 'sk_test_xxx',
});

const setCookies = response.headers.getSetCookie();
expect(setCookies).toHaveLength(3);
expect(setCookies).toContain('__client=abc123; Path=/; HttpOnly; Secure');
expect(setCookies).toContain('__client_uat=1234567890; Path=/; Secure');
expect(setCookies).toContain('__session=xyz789; Path=/; HttpOnly; Secure');
});

it('preserves relative Location headers', async () => {
const mockResponse = new Response(null, {
status: 302,
Expand Down
18 changes: 14 additions & 4 deletions packages/backend/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,19 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
);
}

// Derive the FAPI URL and construct the target URL
// Derive the FAPI URL and construct the target URL.
// Use string concatenation instead of `new URL(path, base)` to avoid
// protocol-relative resolution (e.g., "//evil.com" resolving to a different host).
const fapiBaseUrl = fapiUrlFromPublishableKey(publishableKey);
const fapiHost = new URL(fapiBaseUrl).host;
const targetPath = requestUrl.pathname.slice(proxyPath.length) || '/';
const targetUrl = new URL(targetPath, fapiBaseUrl);
const targetUrl = new URL(`${fapiBaseUrl}${targetPath}`);
targetUrl.search = requestUrl.search;

if (targetUrl.host !== fapiHost) {
return createErrorResponse('proxy_request_failed', 'Resolved target does not match the expected host', 400);
}

// Build headers for the proxied request
const headers = new Headers();

Expand All @@ -239,7 +246,6 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
headers.set('Clerk-Secret-Key', secretKey);

// Set the host header to the FAPI host
const fapiHost = new URL(fapiBaseUrl).host;
headers.set('Host', fapiHost);

// Request uncompressed responses to avoid a double compression pass.
Expand Down Expand Up @@ -288,7 +294,11 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
response.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) {
responseHeaders.set(key, value);
if (lower === 'set-cookie') {
responseHeaders.append(key, value);
} else {
responseHeaders.set(key, value);
}
}
});

Expand Down
Loading