From 2808e548bd9e9e4508c0644edcf575b13e36b3fd Mon Sep 17 00:00:00 2001 From: brkalow Date: Wed, 25 Mar 2026 11:46:27 -0500 Subject: [PATCH 1/6] Append set-cookie headers instead of set, supporting multiple headers of the same name --- .changeset/warm-clubs-watch.md | 5 ++++ packages/backend/src/__tests__/proxy.test.ts | 27 ++++++++++++++++++++ packages/backend/src/proxy.ts | 6 ++++- 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .changeset/warm-clubs-watch.md 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..9d1bbb1954b 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -525,6 +525,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..4b71e4acb6b 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -288,7 +288,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); + } } }); From f3906676adbbe6bffd82e5e010e370cbb9efd891 Mon Sep 17 00:00:00 2001 From: brkalow Date: Wed, 25 Mar 2026 11:54:25 -0500 Subject: [PATCH 2/6] fix(backend): preserve Set-Cookie headers and block SSRF in proxy Use `headers.append()` instead of `headers.set()` for `set-cookie` headers when copying FAPI response headers, preventing multiple Set-Cookie values from being silently dropped (which caused OAuth callback failures due to missing `__client` cookies). Also add a host validation guard to prevent SSRF via protocol-relative paths (e.g., `//evil.com/steal`) that would cause the URL constructor to resolve to an attacker-controlled host, leaking the secret key. Co-Authored-By: Claude Opus 4.6 --- packages/backend/src/__tests__/proxy.test.ts | 14 ++++++++++++++ packages/backend/src/proxy.ts | 9 ++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts index 9d1bbb1954b..8674ae4e256 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -148,6 +148,20 @@ describe('proxy', () => { expect(body.errors[0].code).toBe('proxy_path_mismatch'); }); + it('rejects requests with protocol-relative path that would SSRF to a different host', async () => { + const request = new Request('https://example.com/__clerk//evil.com/steal'); + + const response = await clerkFrontendApiProxy(request, { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + secretKey: 'sk_test_xxx', + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.errors[0].code).toBe('proxy_request_failed'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('forwards GET request to FAPI with correct headers', async () => { const mockResponse = new Response(JSON.stringify({ client: {} }), { status: 200, diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index 4b71e4acb6b..f32080a7412 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -216,10 +216,18 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend // Derive the FAPI URL and construct the target URL const fapiBaseUrl = fapiUrlFromPublishableKey(publishableKey); + const fapiHost = new URL(fapiBaseUrl).host; const targetPath = requestUrl.pathname.slice(proxyPath.length) || '/'; const targetUrl = new URL(targetPath, fapiBaseUrl); targetUrl.search = requestUrl.search; + // Guard against SSRF: a path like "//evil.com/steal" causes the URL constructor + // to treat it as protocol-relative, resolving to a completely different host. + // This would leak the Clerk-Secret-Key header to an attacker-controlled server. + if (targetUrl.host !== fapiHost) { + return createErrorResponse('proxy_request_failed', 'Resolved target does not match the expected FAPI host', 400); + } + // Build headers for the proxied request const headers = new Headers(); @@ -239,7 +247,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. From 148923a933640598b6119a020bdc7a136c98a63d Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Wed, 25 Mar 2026 12:41:12 -0500 Subject: [PATCH 3/6] Apply suggestion from @brkalow --- packages/backend/src/proxy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index f32080a7412..83652f7e5d6 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -225,7 +225,7 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend // to treat it as protocol-relative, resolving to a completely different host. // This would leak the Clerk-Secret-Key header to an attacker-controlled server. if (targetUrl.host !== fapiHost) { - return createErrorResponse('proxy_request_failed', 'Resolved target does not match the expected FAPI host', 400); + return createErrorResponse('proxy_request_failed', 'Resolved target does not match the expected host', 400); } // Build headers for the proxied request From 1883b8f44da2a20bedf62a0f0edbbdf992f954c3 Mon Sep 17 00:00:00 2001 From: brkalow Date: Wed, 25 Mar 2026 12:53:54 -0500 Subject: [PATCH 4/6] fix(backend): use string concatenation for FAPI URL to prevent SSRF by construction Replace `new URL(path, base)` with string concatenation so that protocol-relative paths like `//evil.com` can never change the host. The defense-in-depth host check is kept as a belt-and-suspenders guard. Co-Authored-By: Claude Opus 4.6 --- packages/backend/src/__tests__/proxy.test.ts | 14 ++++++++------ packages/backend/src/proxy.ts | 10 +++++----- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts index 8674ae4e256..215f82266c9 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -148,18 +148,20 @@ describe('proxy', () => { expect(body.errors[0].code).toBe('proxy_path_mismatch'); }); - it('rejects requests with protocol-relative path that would SSRF to a different host', async () => { + it('does not SSRF on protocol-relative paths — request stays on FAPI host', async () => { + const mockResponse = new Response('{}', { status: 200 }); + mockFetch.mockResolvedValueOnce(mockResponse); + const request = new Request('https://example.com/__clerk//evil.com/steal'); - const response = await clerkFrontendApiProxy(request, { + await clerkFrontendApiProxy(request, { publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', secretKey: 'sk_test_xxx', }); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.errors[0].code).toBe('proxy_request_failed'); - expect(mockFetch).not.toHaveBeenCalled(); + // 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 () => { diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index 83652f7e5d6..77de28dde21 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -214,16 +214,16 @@ 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; - // Guard against SSRF: a path like "//evil.com/steal" causes the URL constructor - // to treat it as protocol-relative, resolving to a completely different host. - // This would leak the Clerk-Secret-Key header to an attacker-controlled server. + // Defense-in-depth: reject if the resolved host doesn't match FAPI. if (targetUrl.host !== fapiHost) { return createErrorResponse('proxy_request_failed', 'Resolved target does not match the expected host', 400); } From 253ef9899ed9f1fc47be3976b7857758e066222c Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Wed, 25 Mar 2026 13:24:03 -0500 Subject: [PATCH 5/6] Apply suggestion from @brkalow --- packages/backend/src/proxy.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index 77de28dde21..61e1ded3ea2 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -223,7 +223,6 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend const targetUrl = new URL(`${fapiBaseUrl}${targetPath}`); targetUrl.search = requestUrl.search; - // Defense-in-depth: reject if the resolved host doesn't match FAPI. if (targetUrl.host !== fapiHost) { return createErrorResponse('proxy_request_failed', 'Resolved target does not match the expected host', 400); } From 21a5805d6e6bea35fff735c0222dc18bb4f14d43 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Wed, 25 Mar 2026 13:25:51 -0500 Subject: [PATCH 6/6] Apply suggestion from @brkalow --- packages/backend/src/__tests__/proxy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts index 215f82266c9..93e01d5a11a 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -148,7 +148,7 @@ describe('proxy', () => { expect(body.errors[0].code).toBe('proxy_path_mismatch'); }); - it('does not SSRF on protocol-relative paths — request stays on FAPI host', async () => { + it('does not follow protocol-relative paths', async () => { const mockResponse = new Response('{}', { status: 200 }); mockFetch.mockResolvedValueOnce(mockResponse);