diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index 85becbc2..c156f6c0 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -73,7 +73,7 @@ $effect(() => { $effect(() => { function onBeforeUnload() { - autoSave.flushViaBeacon(); + autoSave.flushOnUnload(); } window.addEventListener('beforeunload', onBeforeUnload); return () => { diff --git a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts index dd13a7a6..739fb432 100644 --- a/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts +++ b/frontend/src/lib/hooks/__tests__/useBlockAutoSave.svelte.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; const mockSaveFn = vi.fn<(blockId: string, text: string) => Promise>(); @@ -82,3 +82,82 @@ describe('createBlockAutoSave', () => { expect(mockSaveFn).not.toHaveBeenCalled(); }); }); + +describe('flushOnUnload', () => { + let mockFetch: Mock; + + beforeEach(() => { + vi.useFakeTimers(); + mockSaveFn.mockClear(); + mockSaveFn.mockResolvedValue(undefined); + mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('sends a PUT request with keepalive:true for each pending block', () => { + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + as.handleTextChange('block-1', 'hello'); + as.handleTextChange('block-2', 'world'); + as.flushOnUnload(); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledWith( + '/api/documents/doc-1/transcription-blocks/block-1', + expect.objectContaining({ + method: 'PUT', + keepalive: true, + body: JSON.stringify({ text: 'hello' }) + }) + ); + expect(mockFetch).toHaveBeenCalledWith( + '/api/documents/doc-1/transcription-blocks/block-2', + expect.objectContaining({ + method: 'PUT', + keepalive: true, + body: JSON.stringify({ text: 'world' }) + }) + ); + }); + + it('does not call navigator.sendBeacon', () => { + const sendBeaconSpy = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + as.handleTextChange('block-1', 'text'); + as.flushOnUnload(); + + expect(sendBeaconSpy).not.toHaveBeenCalled(); + }); + + it('does nothing when there are no pending edits', () => { + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + as.flushOnUnload(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('cancels the debounce timer so saveFn is not also called', async () => { + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + as.handleTextChange('block-1', 'text'); + as.flushOnUnload(); + + await vi.advanceTimersByTimeAsync(2000); + expect(mockSaveFn).not.toHaveBeenCalled(); + }); + + it('does not send fetch if debounce already fired and pendingTexts is empty', async () => { + const as = createBlockAutoSave({ saveFn: mockSaveFn, documentId: 'doc-1' }); + as.handleTextChange('block-1', 'text'); + await vi.advanceTimersByTimeAsync(1500); + // debounce has fired; pendingTexts should be empty now + mockFetch.mockClear(); + + as.flushOnUnload(); + + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts index a627f50e..07dc5692 100644 --- a/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts +++ b/frontend/src/lib/hooks/useBlockAutoSave.svelte.ts @@ -94,12 +94,15 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { saveStates.delete(blockId); } - function flushViaBeacon(): void { + function flushOnUnload(): void { for (const [blockId, text] of pendingTexts) { clearDebounce(blockId); - const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`; - const body = JSON.stringify({ text }); - navigator.sendBeacon(url, new Blob([body], { type: 'application/json' })); + void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + keepalive: true + }); pendingTexts.delete(blockId); } } @@ -121,7 +124,7 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { handleBlur, handleRetry, clearBlock, - flushViaBeacon, + flushOnUnload, destroy }; } diff --git a/frontend/src/routes/api/[...path]/+server.ts b/frontend/src/routes/api/[...path]/+server.ts new file mode 100644 index 00000000..fa08d455 --- /dev/null +++ b/frontend/src/routes/api/[...path]/+server.ts @@ -0,0 +1,87 @@ +import type { RequestHandler } from './$types'; +import { env } from 'process'; + +const NO_BODY_METHODS = new Set(['GET', 'HEAD', 'DELETE']); + +// 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', + 'proxy-connection' +]); + +async function proxy(event: Parameters[0]): Promise { + 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 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 + // server-side auth_token cookie. + const contentType = event.request.headers.get('Content-Type'); + const requestHeaders: Record = {}; + if (contentType) requestHeaders['Content-Type'] = contentType; + + const response = await event.fetch(backendUrl, { + method: event.request.method, + headers: requestHeaders, + body: bodyBuffer + }); + + // Forward all response headers except hop-by-hop so that Content-Disposition, + // ETag, Cache-Control, Location (redirects), etc. reach the browser. + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) { + responseHeaders[key] = value; + } + }); + + return new Response(response.body, { + status: response.status, + headers: responseHeaders + }); +} + +export const GET: RequestHandler = (event) => proxy(event); +export const POST: RequestHandler = (event) => proxy(event); +export const PUT: RequestHandler = (event) => proxy(event); +export const PATCH: RequestHandler = (event) => proxy(event); +export const DELETE: RequestHandler = (event) => proxy(event); diff --git a/frontend/src/routes/api/[...path]/proxy.spec.ts b/frontend/src/routes/api/[...path]/proxy.spec.ts new file mode 100644 index 00000000..2831794c --- /dev/null +++ b/frontend/src/routes/api/[...path]/proxy.spec.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi } from 'vitest'; + +process.env.API_INTERNAL_URL = 'http://backend:8080'; + +const { GET, PUT, POST, PATCH } = 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('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); + (event.request as Request) = new Request('http://localhost/api/documents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'hello' }) + }); + + const response = await POST(event as never); + + expect(response.status).toBe(200); + expect(mockFetch).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 proxy-connection hop-by-hop header', async () => { + const mockFetch = vi.fn().mockResolvedValue( + new Response('data', { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Proxy-Connection': 'keep-alive' + } + }) + ); + const event = makeEvent('documents', 'GET', mockFetch); + + const response = await GET(event as never); + + expect(response.headers.get('Proxy-Connection')).toBeNull(); + }); + + 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(); + }); + + it('forwards PATCH request to backend with correct URL', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + const event = makeEvent('documents/doc-1', 'PATCH', mockFetch); + (event.request as Request) = new Request('http://localhost/api/documents/doc-1', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'updated' }) + }); + + const response = await PATCH(event as never); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://backend:8080/api/documents/doc-1', + expect.objectContaining({ method: 'PATCH' }) + ); + expect(response.status).toBe(200); + }); +});