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);