Skip to content

Commit 0288931

Browse files
brkalowclaude
andauthored
fix(backend): prevent Content-Encoding mismatch in proxy responses (clerk#8159)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d27d42e commit 0288931

3 files changed

Lines changed: 74 additions & 3 deletions

File tree

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 `ERR_CONTENT_DECODING_FAILED` when loading proxied assets by requesting uncompressed responses from FAPI and stripping `Content-Encoding`/`Content-Length` headers that `fetch()` invalidates through auto-decompression.

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,50 @@ describe('proxy', () => {
481481
expect(response.headers.get('Location')).toBe('https://accounts.google.com/oauth/authorize');
482482
});
483483

484+
it('sets Accept-Encoding to identity to avoid double compression', async () => {
485+
const mockResponse = new Response(JSON.stringify({ client: {} }), {
486+
status: 200,
487+
headers: { 'Content-Type': 'application/json' },
488+
});
489+
mockFetch.mockResolvedValue(mockResponse);
490+
491+
const request = new Request('https://example.com/__clerk/v1/client', {
492+
headers: { 'Accept-Encoding': 'gzip, deflate, br' },
493+
});
494+
495+
await clerkFrontendApiProxy(request, {
496+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
497+
secretKey: 'sk_test_xxx',
498+
});
499+
500+
const [, options] = mockFetch.mock.calls[0];
501+
expect(options.headers.get('Accept-Encoding')).toBe('identity');
502+
});
503+
504+
it('strips Content-Encoding and Content-Length from response even if upstream ignores identity', async () => {
505+
// Upstream may ignore Accept-Encoding: identity and compress anyway
506+
const mockResponse = new Response('decoded body', {
507+
status: 200,
508+
headers: {
509+
'Content-Type': 'application/javascript',
510+
'Content-Encoding': 'gzip',
511+
'Content-Length': '500',
512+
},
513+
});
514+
mockFetch.mockResolvedValue(mockResponse);
515+
516+
const request = new Request('https://example.com/__clerk/v1/client');
517+
518+
const response = await clerkFrontendApiProxy(request, {
519+
publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k',
520+
secretKey: 'sk_test_xxx',
521+
});
522+
523+
expect(response.headers.has('Content-Encoding')).toBe(false);
524+
expect(response.headers.has('Content-Length')).toBe(false);
525+
expect(response.headers.get('Content-Type')).toBe('application/javascript');
526+
});
527+
484528
it('preserves relative Location headers', async () => {
485529
const mockResponse = new Response(null, {
486530
status: 302,

packages/backend/src/proxy.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ const HOP_BY_HOP_HEADERS = [
5454
'upgrade',
5555
];
5656

57+
// Headers to strip from proxied responses. fetch() auto-decompresses
58+
// response bodies, so Content-Encoding no longer describes the body
59+
// and Content-Length reflects the compressed size. We request identity
60+
// encoding upstream to avoid the double compression pass, but strip
61+
// these defensively since servers may ignore Accept-Encoding: identity.
62+
const RESPONSE_HEADERS_TO_STRIP = ['content-encoding', 'content-length'];
63+
5764
/**
5865
* Derives the Frontend API URL from a publishable key.
5966
* @param publishableKey - The Clerk publishable key
@@ -235,6 +242,11 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
235242
const fapiHost = new URL(fapiBaseUrl).host;
236243
headers.set('Host', fapiHost);
237244

245+
// Request uncompressed responses to avoid a double compression pass.
246+
// fetch() auto-decompresses, so without this FAPI compresses → fetch
247+
// decompresses → the serving layer re-compresses for the browser.
248+
headers.set('Accept-Encoding', 'identity');
249+
238250
// Set X-Forwarded-* headers for proxy awareness
239251
// Only set these if not already present (preserve values from upstream proxies)
240252
if (!headers.has('X-Forwarded-Host')) {
@@ -271,10 +283,11 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
271283

272284
const response = await fetch(targetUrl.toString(), fetchOptions);
273285

274-
// Build response headers, excluding hop-by-hop headers
286+
// Build response headers, excluding hop-by-hop and encoding headers
275287
const responseHeaders = new Headers();
276288
response.headers.forEach((value, key) => {
277-
if (!HOP_BY_HOP_HEADERS.includes(key.toLowerCase())) {
289+
const lower = key.toLowerCase();
290+
if (!HOP_BY_HOP_HEADERS.includes(lower) && !RESPONSE_HEADERS_TO_STRIP.includes(lower)) {
278291
responseHeaders.set(key, value);
279292
}
280293
});
@@ -295,11 +308,20 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend
295308
}
296309
}
297310

298-
return new Response(response.body, {
311+
const proxyResponse = new Response(response.body, {
299312
status: response.status,
300313
statusText: response.statusText,
301314
headers: responseHeaders,
302315
});
316+
317+
// Some runtimes may re-add Content-Length when constructing the Response.
318+
// Delete explicitly since fetch() decoded the body and the original values
319+
// no longer reflect the actual content.
320+
for (const header of RESPONSE_HEADERS_TO_STRIP) {
321+
proxyResponse.headers.delete(header);
322+
}
323+
324+
return proxyResponse;
303325
} catch (error) {
304326
const message = error instanceof Error ? error.message : 'Unknown error';
305327
return createErrorResponse('proxy_request_failed', `Failed to proxy request to Clerk FAPI: ${message}`, 502);

0 commit comments

Comments
 (0)