From aab4fe37ae2295d8f868917be8409c81fba04c5f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 21:09:18 +0200 Subject: [PATCH 1/2] fix(ocr): send CSRF token when starting an OCR run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OCR trigger POST went through bare `fetch`, so it carried no X-XSRF-TOKEN header. Spring Security rejected it and the UI showed "Sitzungsfehler. Bitte laden Sie die Seite neu." (CSRF_TOKEN_MISSING). Default the job controller's fetchImpl to csrfFetch — matching the autosave hook — so mutating requests are CSRF-protected while GET polling passes through unchanged. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/ocr/useOcrJob.svelte.test.ts | 32 +++++++++++++++++++ frontend/src/lib/ocr/useOcrJob.svelte.ts | 3 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/ocr/useOcrJob.svelte.test.ts b/frontend/src/lib/ocr/useOcrJob.svelte.test.ts index 8c83f6e1..50c9b84f 100644 --- a/frontend/src/lib/ocr/useOcrJob.svelte.test.ts +++ b/frontend/src/lib/ocr/useOcrJob.svelte.test.ts @@ -139,6 +139,38 @@ describe('createOcrJob.triggerOcr', () => { }); }); +describe('createOcrJob — CSRF on the default fetch path', () => { + afterEach(() => { + vi.unstubAllGlobals(); + document.cookie = 'XSRF-TOKEN=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; + }); + + it('injects X-XSRF-TOKEN on the trigger POST when no fetchImpl is provided', async () => { + document.cookie = 'XSRF-TOKEN=tok-123; path=/'; + const globalFetch = vi.fn( + async () => + new Response(JSON.stringify({ jobId: 'job-1' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + vi.stubGlobal('fetch', globalFetch); + + // No fetchImpl — exercises the production default path used by the document page. + const job = createOcrJob({ documentId: () => 'doc-1' }); + await job.triggerOcr('KURRENT', false); + job.destroy(); + + const postCall = globalFetch.mock.calls.find( + ([url, init]) => + url.toString().includes('/ocr') && (init as RequestInit | undefined)?.method === 'POST' + ); + expect(postCall).toBeDefined(); + const headers = new Headers((postCall![1] as RequestInit).headers); + expect(headers.get('X-XSRF-TOKEN')).toBe('tok-123'); + }); +}); + describe('createOcrJob.checkStatus', () => { it('starts polling when status is RUNNING with a jobId', async () => { const fetchImpl = makeFetch({ diff --git a/frontend/src/lib/ocr/useOcrJob.svelte.ts b/frontend/src/lib/ocr/useOcrJob.svelte.ts index e5a74af1..e3c4382c 100644 --- a/frontend/src/lib/ocr/useOcrJob.svelte.ts +++ b/frontend/src/lib/ocr/useOcrJob.svelte.ts @@ -1,6 +1,7 @@ import { m } from '$lib/paraglide/messages.js'; import { getErrorMessage } from '$lib/shared/errors'; import { translateOcrProgress } from '$lib/ocr/translateOcrProgress'; +import { csrfFetch } from '$lib/shared/cookies'; export interface OcrJobOptions { documentId: () => string; @@ -27,7 +28,7 @@ const DEFAULT_RESET_DELAY_MS = 1000; export function createOcrJob(options: OcrJobOptions): OcrJobController { const { documentId, onJobFinished } = options; - const fetchImpl = options.fetchImpl ?? fetch; + const fetchImpl = options.fetchImpl ?? csrfFetch; const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; const resetDelayMs = options.resetDelayMs ?? DEFAULT_RESET_DELAY_MS; -- 2.49.1 From 246568301a1f0898fe86af0e85d451ad93292a36 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 22:10:09 +0200 Subject: [PATCH 2/2] refactor(ocr): CSRF-wrap injected fetchImpl too, not just the default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the useTranscriptionBlocks pattern: makeCsrfFetch(options.fetchImpl ?? fetch) wraps both the default and any injected fetch, so CSRF protection holds regardless of how the hook is constructed — defense-in-depth against a future caller injecting a bare fetch. Simplifies the CSRF test to assert on the injected path instead of stubbing global fetch. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/ocr/useOcrJob.svelte.test.ts | 17 +++++++---------- frontend/src/lib/ocr/useOcrJob.svelte.ts | 4 ++-- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/ocr/useOcrJob.svelte.test.ts b/frontend/src/lib/ocr/useOcrJob.svelte.test.ts index 50c9b84f..ecbeb5fe 100644 --- a/frontend/src/lib/ocr/useOcrJob.svelte.test.ts +++ b/frontend/src/lib/ocr/useOcrJob.svelte.test.ts @@ -139,29 +139,26 @@ describe('createOcrJob.triggerOcr', () => { }); }); -describe('createOcrJob — CSRF on the default fetch path', () => { +describe('createOcrJob — CSRF on mutating requests', () => { afterEach(() => { - vi.unstubAllGlobals(); document.cookie = 'XSRF-TOKEN=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; }); - it('injects X-XSRF-TOKEN on the trigger POST when no fetchImpl is provided', async () => { + it('injects X-XSRF-TOKEN on the trigger POST', async () => { document.cookie = 'XSRF-TOKEN=tok-123; path=/'; - const globalFetch = vi.fn( - async () => + const fetchImpl = makeFetch({ + '/ocr': () => new Response(JSON.stringify({ jobId: 'job-1' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) - ); - vi.stubGlobal('fetch', globalFetch); + }); - // No fetchImpl — exercises the production default path used by the document page. - const job = createOcrJob({ documentId: () => 'doc-1' }); + const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl }); await job.triggerOcr('KURRENT', false); job.destroy(); - const postCall = globalFetch.mock.calls.find( + const postCall = fetchImpl.mock.calls.find( ([url, init]) => url.toString().includes('/ocr') && (init as RequestInit | undefined)?.method === 'POST' ); diff --git a/frontend/src/lib/ocr/useOcrJob.svelte.ts b/frontend/src/lib/ocr/useOcrJob.svelte.ts index e3c4382c..af6e5adf 100644 --- a/frontend/src/lib/ocr/useOcrJob.svelte.ts +++ b/frontend/src/lib/ocr/useOcrJob.svelte.ts @@ -1,7 +1,7 @@ import { m } from '$lib/paraglide/messages.js'; import { getErrorMessage } from '$lib/shared/errors'; import { translateOcrProgress } from '$lib/ocr/translateOcrProgress'; -import { csrfFetch } from '$lib/shared/cookies'; +import { makeCsrfFetch } from '$lib/shared/cookies'; export interface OcrJobOptions { documentId: () => string; @@ -28,7 +28,7 @@ const DEFAULT_RESET_DELAY_MS = 1000; export function createOcrJob(options: OcrJobOptions): OcrJobController { const { documentId, onJobFinished } = options; - const fetchImpl = options.fetchImpl ?? csrfFetch; + const fetchImpl = makeCsrfFetch(options.fetchImpl ?? fetch); const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; const resetDelayMs = options.resetDelayMs ?? DEFAULT_RESET_DELAY_MS; -- 2.49.1