Skip to content

Commit 486545c

Browse files
brkalowclaude
andauthored
fix(backend): preserve Set-Cookie headers through proxy (#8162)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6fe4418 commit 486545c

File tree

3 files changed

+62
-4
lines changed

3 files changed

+62
-4
lines changed

.changeset/warm-clubs-watch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': patch
3+
---
4+
5+
Fix an issue where multiple `set-cookie` headers were being dropped by the frontend API proxy.

packages/backend/src/__tests__/proxy.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,22 @@ describe('proxy', () => {
148148
expect(body.errors[0].code).toBe('proxy_path_mismatch');
149149
});
150150

151+
it('does not follow protocol-relative paths', async () => {
152+
const mockResponse = new Response('{}', { status: 200 });
153+
mockFetch.mockResolvedValueOnce(mockResponse);
154+
155+
const request = new Request('https://example.com/__clerk//evil.com/steal');
156+
157+
await clerkFrontendApiProxy(request, {
158+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
159+
secretKey: 'sk_test_xxx',
160+
});
161+
162+
// String concatenation keeps the host as FAPI, not evil.com
163+
const fetchedUrl = new URL(mockFetch.mock.calls[0][0] as string);
164+
expect(fetchedUrl.host).toBe('frontend-api.clerk.dev');
165+
});
166+
151167
it('forwards GET request to FAPI with correct headers', async () => {
152168
const mockResponse = new Response(JSON.stringify({ client: {} }), {
153169
status: 200,
@@ -525,6 +541,33 @@ describe('proxy', () => {
525541
expect(response.headers.get('Content-Type')).toBe('application/javascript');
526542
});
527543

544+
it('preserves multiple Set-Cookie headers from FAPI response', async () => {
545+
const headers = new Headers();
546+
headers.append('Set-Cookie', '__client=abc123; Path=/; HttpOnly; Secure');
547+
headers.append('Set-Cookie', '__client_uat=1234567890; Path=/; Secure');
548+
headers.append('Set-Cookie', '__session=xyz789; Path=/; HttpOnly; Secure');
549+
headers.append('Content-Type', 'application/json');
550+
551+
const mockResponse = new Response(JSON.stringify({ client: {} }), {
552+
status: 200,
553+
headers,
554+
});
555+
mockFetch.mockResolvedValue(mockResponse);
556+
557+
const request = new Request('https://example.com/__clerk/v1/client');
558+
559+
const response = await clerkFrontendApiProxy(request, {
560+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
561+
secretKey: 'sk_test_xxx',
562+
});
563+
564+
const setCookies = response.headers.getSetCookie();
565+
expect(setCookies).toHaveLength(3);
566+
expect(setCookies).toContain('__client=abc123; Path=/; HttpOnly; Secure');
567+
expect(setCookies).toContain('__client_uat=1234567890; Path=/; Secure');
568+
expect(setCookies).toContain('__session=xyz789; Path=/; HttpOnly; Secure');
569+
});
570+
528571
it('preserves relative Location headers', async () => {
529572
const mockResponse = new Response(null, {
530573
status: 302,

packages/backend/src/proxy.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,12 +214,19 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
214214
);
215215
}
216216

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

226+
if (targetUrl.host !== fapiHost) {
227+
return createErrorResponse('proxy_request_failed', 'Resolved target does not match the expected host', 400);
228+
}
229+
223230
// Build headers for the proxied request
224231
const headers = new Headers();
225232

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

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

245251
// Request uncompressed responses to avoid a double compression pass.
@@ -288,7 +294,11 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
288294
response.headers.forEach((value, key) => {
289295
const lower = key.toLowerCase();
290296
if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) {
291-
responseHeaders.set(key, value);
297+
if (lower === 'set-cookie') {
298+
responseHeaders.append(key, value);
299+
} else {
300+
responseHeaders.set(key, value);
301+
}
292302
}
293303
});
294304

0 commit comments

Comments
 (0)