diff --git a/.changeset/warm-clubs-watch.md b/.changeset/warm-clubs-watch.md new file mode 100644 index 00000000000..471212c8e86 --- /dev/null +++ b/.changeset/warm-clubs-watch.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Fix an issue where multiple `set-cookie` headers were being dropped by the frontend API proxy. diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts index 672aaaf32b9..93e01d5a11a 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -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, @@ -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, diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index 7a47d2c3bf7..61e1ded3ea2 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -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(); @@ -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. @@ -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); + } } });