From 464b8d35d39b28c8c5568b8ba4386c0900f5f48b Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 18:17:00 +0200 Subject: [PATCH] fix(proxy): enforce body size limit on actual byteLength, not just Content-Length header Chunked requests omit Content-Length entirely. The previous guard only checked the header and was bypassed. Now the body is buffered first and its byteLength is checked, catching both cases. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/api/[...path]/+server.ts | 12 +++++++++++- frontend/src/routes/api/[...path]/proxy.spec.ts | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts index 9a66699d..887ef530 100644 --- a/frontend/src/routes/api/[...path]/+server.ts +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -35,11 +35,21 @@ async function proxy(event: Parameters[0]): Promise { const hasBody = !NO_BODY_METHODS.has(event.request.method); + // Early exit: reject well-behaved clients that advertise an oversized body + // before we buffer anything. const contentLength = event.request.headers.get('Content-Length'); if (contentLength && parseInt(contentLength, 10) > 1_048_576) { return new Response('Payload Too Large', { status: 413 }); } + // Buffer the body so we can check its actual size. This also catches chunked + // requests that omit Content-Length entirely (parseInt(null) → NaN → passes + // the header check above). + const bodyBuffer = hasBody ? await event.request.arrayBuffer() : undefined; + if (bodyBuffer && bodyBuffer.byteLength > 1_048_576) { + return new Response('Payload Too Large', { status: 413 }); + } + // Only forward Content-Type from the browser request — other headers (Cookie, // Authorization, Accept-Encoding, etc.) must not be forwarded directly. // Authentication is injected by handleFetch in hooks.server.ts from the @@ -51,7 +61,7 @@ async function proxy(event: Parameters[0]): Promise { const response = await event.fetch(backendUrl, { method: event.request.method, headers: requestHeaders, - body: hasBody ? await event.request.arrayBuffer() : undefined + body: bodyBuffer }); // Forward all response headers except hop-by-hop so that Content-Disposition, diff --git a/frontend/src/routes/api/[...path]/proxy.spec.ts b/frontend/src/routes/api/[...path]/proxy.spec.ts index f681799b..745134a2 100644 --- a/frontend/src/routes/api/[...path]/proxy.spec.ts +++ b/frontend/src/routes/api/[...path]/proxy.spec.ts @@ -69,6 +69,21 @@ describe('catch-all API proxy — body size limit', () => { expect(mockFetch).not.toHaveBeenCalled(); }); + it('returns 413 when actual body exceeds 1 MB even if Content-Length header is absent or lying', async () => { + const mockFetch = vi.fn(); + const event = makeEvent('documents', 'POST', mockFetch); + (event.request as Request) = new Request('http://localhost/api/documents', { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': '0' }, + body: new Uint8Array(1_048_577) // 1 MB + 1 byte, but Content-Length says 0 + }); + + const response = await POST(event as never); + + expect(response.status).toBe(413); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('forwards request when Content-Length header is absent', async () => { const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const event = makeEvent('documents', 'POST', mockFetch);