feat(proxy): add 1MB body guard and full proxy test suite

Blocks requests with Content-Length > 1 048 576 bytes with 413.
Tests cover security guards, body limit, and response forwarding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-22 16:34:28 +02:00
committed by marcel
parent e1304b6512
commit c9dd3f8e78
2 changed files with 206 additions and 7 deletions

View File

@@ -0,0 +1,156 @@
import { describe, it, expect, vi } from 'vitest';
process.env.API_INTERNAL_URL = 'http://backend:8080';
const { GET, PUT, POST } = await import('./+server');
function makeEvent(
path: string,
method = 'GET',
mockFetch = vi
.fn()
.mockResolvedValue(
new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })
)
) {
return {
params: { path },
request: new Request(`http://localhost/api/${path}`, { method }),
url: new URL(`http://localhost/api/${path}`),
fetch: mockFetch
};
}
describe('catch-all API proxy — security guards', () => {
it('returns 400 for path traversal with double-dot', async () => {
const event = makeEvent('documents/../../../etc/passwd');
const response = await GET(event as never);
expect(response.status).toBe(400);
});
it('returns 400 for URL-encoded dot traversal (%2e)', async () => {
const event = makeEvent('documents/%2e%2e/sensitive');
const response = await GET(event as never);
expect(response.status).toBe(400);
});
it('returns 400 for mixed-case encoded traversal (%2E)', async () => {
const event = makeEvent('documents/%2E%2E/sensitive');
const response = await GET(event as never);
expect(response.status).toBe(400);
});
it('returns 404 for actuator paths', async () => {
const event = makeEvent('actuator/heapdump');
const response = await GET(event as never);
expect(response.status).toBe(404);
});
it('returns 404 for actuator/env', async () => {
const event = makeEvent('actuator/env');
const response = await GET(event as never);
expect(response.status).toBe(404);
});
});
describe('catch-all API proxy — body size limit', () => {
it('returns 413 when Content-Length exceeds 1 MB', 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/json', 'Content-Length': '1048577' },
body: JSON.stringify({})
});
const response = await POST(event as never);
expect(response.status).toBe(413);
expect(mockFetch).not.toHaveBeenCalled();
});
it('does not reject request with Content-Length exactly at 1 MB', async () => {
const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
const event = makeEvent('documents', 'POST', mockFetch);
(event.request as Request) = new Request('http://localhost/api/documents', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': '1048576' },
body: JSON.stringify({})
});
const response = await POST(event as never);
expect(response.status).toBe(200);
});
});
describe('catch-all API proxy — forwarding', () => {
it('forwards PUT request to backend with correct URL', async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response('{"id":"block-1"}', {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
);
const event = makeEvent('documents/doc-1/transcription-blocks/block-1', 'PUT', mockFetch);
(event.request as Request) = new Request(
'http://localhost/api/documents/doc-1/transcription-blocks/block-1',
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'hello' })
}
);
const response = await PUT(event as never);
expect(mockFetch).toHaveBeenCalledWith(
'http://backend:8080/api/documents/doc-1/transcription-blocks/block-1',
expect.objectContaining({ method: 'PUT' })
);
expect(response.status).toBe(200);
});
it('passes through the backend response status code', async () => {
const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 404 }));
const event = makeEvent('documents/nonexistent', 'GET', mockFetch);
const response = await GET(event as never);
expect(response.status).toBe(404);
});
it('forwards Content-Disposition response header', async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response('data', {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="document.pdf"'
}
})
);
const event = makeEvent('documents/doc-1/export', 'GET', mockFetch);
const response = await GET(event as never);
expect(response.headers.get('Content-Disposition')).toBe('attachment; filename="document.pdf"');
});
it('does not forward hop-by-hop headers like transfer-encoding', async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response('data', {
status: 200,
headers: {
'Content-Type': 'application/json',
'Transfer-Encoding': 'chunked'
}
})
);
const event = makeEvent('documents', 'GET', mockFetch);
const response = await GET(event as never);
expect(response.headers.get('Transfer-Encoding')).toBeNull();
});
});

View File

@@ -3,22 +3,65 @@ import { env } from 'process';
const NO_BODY_METHODS = new Set(['GET', 'HEAD', 'DELETE']);
async function proxy(event: Parameters<RequestHandler>[0]): Promise<Response> {
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const backendUrl = `${apiUrl}/api/${event.params.path}${event.url.search}`;
// Hop-by-hop headers must not be forwarded to the client — they are connection-scoped.
const HOP_BY_HOP_HEADERS = new Set([
'transfer-encoding',
'connection',
'keep-alive',
'upgrade',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer'
]);
async function proxy(event: Parameters<RequestHandler>[0]): Promise<Response> {
const rawPath = event.params.path;
// Block path traversal attempts — SvelteKit decodes %2F before routing,
// so encoded dots could produce ../../../ sequences after normalization.
if (rawPath.includes('..') || /%(2e)/i.test(rawPath)) {
return new Response('Bad Request', { status: 400 });
}
// Block Actuator endpoints — heapdump, env, configprops expose credentials
// and heap memory and must never be reachable through the SvelteKit proxy.
if (rawPath.startsWith('actuator')) {
return new Response('Not Found', { status: 404 });
}
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
const backendUrl = `${apiUrl}/api/${rawPath}${event.url.search}`;
const contentType = event.request.headers.get('Content-Type');
const hasBody = !NO_BODY_METHODS.has(event.request.method);
const contentLength = event.request.headers.get('Content-Length');
if (contentLength && parseInt(contentLength, 10) > 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
// server-side auth_token cookie.
const contentType = event.request.headers.get('Content-Type');
const requestHeaders: Record<string, string> = {};
if (contentType) requestHeaders['Content-Type'] = contentType;
const response = await event.fetch(backendUrl, {
method: event.request.method,
headers: contentType ? { 'Content-Type': contentType } : {},
headers: requestHeaders,
body: hasBody ? await event.request.arrayBuffer() : undefined
});
// Forward all response headers except hop-by-hop so that Content-Disposition,
// ETag, Cache-Control, Location (redirects), etc. reach the browser.
const responseHeaders: Record<string, string> = {};
const responseContentType = response.headers.get('Content-Type');
if (responseContentType) responseHeaders['Content-Type'] = responseContentType;
response.headers.forEach((value, key) => {
if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
responseHeaders[key] = value;
}
});
return new Response(response.body, {
status: response.status,