From 19e2f65a2100ea96c0336b5fd638504951164368 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 20 May 2026 20:10:12 +0200 Subject: [PATCH] fix(csrf): send X-XSRF-TOKEN on all client-side mutating fetch calls hooks.server.ts already forwards the CSRF token for server-side fetch (form actions, load). Client-side XHR calls bypassed it, causing Spring Security to return 403 before PermissionAspect even ran. Adds getCsrfToken/withCsrf/makeCsrfFetch to cookies.ts. useTranscriptionBlocks wraps its injectable fetchImpl with makeCsrfFetch (covers all block mutations and saveBlockWithConflictRetry). useBlockAutoSave, TranscriptionEditView, BulkDocumentEditLayout, OcrTrainingCard, and SegmentationTrainingCard apply withCsrf inline. Co-Authored-By: Claude Sonnet 4.6 --- .../document/BulkDocumentEditLayout.svelte | 6 ++- .../TranscriptionEditView.svelte | 14 +++--- .../transcription/useBlockAutoSave.svelte.ts | 16 ++++--- .../useTranscriptionBlocks.svelte.ts | 3 +- frontend/src/lib/ocr/OcrTrainingCard.svelte | 3 +- .../lib/ocr/SegmentationTrainingCard.svelte | 3 +- frontend/src/lib/shared/cookies.ts | 43 +++++++++++++++++++ 7 files changed, 73 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/document/BulkDocumentEditLayout.svelte b/frontend/src/lib/document/BulkDocumentEditLayout.svelte index b2623140..c4eaf2df 100644 --- a/frontend/src/lib/document/BulkDocumentEditLayout.svelte +++ b/frontend/src/lib/document/BulkDocumentEditLayout.svelte @@ -17,6 +17,7 @@ import PdfViewer from '$lib/document/viewer/PdfViewer.svelte'; import { bulkTitleFromFilename } from '$lib/document/filename'; import type { Tag } from '$lib/tag/TagInput.svelte'; import type { components } from '$lib/generated/api'; +import { withCsrf } from '$lib/shared/cookies'; type Person = components['schemas']['Person']; @@ -183,7 +184,10 @@ async function saveUpload() { // FormData with per-chunk progress. Session cookie is sent automatically // by the browser for same-origin requests. try { - const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData }); + const res = await fetch( + '/api/documents/quick-upload', + withCsrf({ method: 'POST', body: formData }) + ); const body = await res.json().catch(() => ({ errors: [] })); const errorFilenames = new Set( (body.errors ?? []).map((err: { filename: string }) => err.filename) diff --git a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte index 87e9749a..225e1e4e 100644 --- a/frontend/src/lib/document/transcription/TranscriptionEditView.svelte +++ b/frontend/src/lib/document/transcription/TranscriptionEditView.svelte @@ -6,6 +6,7 @@ import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyStat import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types'; import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte'; import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte'; +import { withCsrf } from '$lib/shared/cookies'; type Props = { documentId: string; @@ -113,11 +114,14 @@ function handleDelete(blockId: string) { async function reorder(newOrder: string[]) { try { - const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ blockIds: newOrder }) - }); + const res = await fetch( + `/api/documents/${documentId}/transcription-blocks/reorder`, + withCsrf({ + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ blockIds: newOrder }) + }) + ); if (!res.ok) return; const updated = await res.json(); for (const b of updated) { diff --git a/frontend/src/lib/document/transcription/useBlockAutoSave.svelte.ts b/frontend/src/lib/document/transcription/useBlockAutoSave.svelte.ts index 4e538fbc..d2b0bb47 100644 --- a/frontend/src/lib/document/transcription/useBlockAutoSave.svelte.ts +++ b/frontend/src/lib/document/transcription/useBlockAutoSave.svelte.ts @@ -1,5 +1,6 @@ import { SvelteMap } from 'svelte/reactivity'; import type { PersonMention } from '$lib/shared/types'; +import { withCsrf } from '$lib/shared/cookies'; export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error'; @@ -116,12 +117,15 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) { for (const [blockId, text] of pendingTexts) { const mentions = pendingMentions.get(blockId) ?? []; clearDebounce(blockId); - void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text, mentionedPersons: mentions }), - keepalive: true - }); + void fetch( + `/api/documents/${documentId}/transcription-blocks/${blockId}`, + withCsrf({ + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, mentionedPersons: mentions }), + keepalive: true + }) + ); pendingTexts.delete(blockId); pendingMentions.delete(blockId); } diff --git a/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts b/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts index da864ad8..4adcc310 100644 --- a/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts +++ b/frontend/src/lib/document/transcription/useTranscriptionBlocks.svelte.ts @@ -2,6 +2,7 @@ lastEditedAt's $derived are scope-local to one computation; they're never stored on $state. */ import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types'; +import { makeCsrfFetch } from '$lib/shared/cookies'; import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry'; import { BlockConflictResolvedError } from './blockConflictMerge'; @@ -41,7 +42,7 @@ export function createTranscriptionBlocks( options: TranscriptionBlocksOptions ): TranscriptionBlocksController { const { documentId } = options; - const fetchImpl = options.fetchImpl ?? fetch; + const fetchImpl = makeCsrfFetch(options.fetchImpl ?? fetch); let blocks = $state([]); let annotationReloadKey = $state(0); diff --git a/frontend/src/lib/ocr/OcrTrainingCard.svelte b/frontend/src/lib/ocr/OcrTrainingCard.svelte index 3bb8eb6c..88ae1c1f 100644 --- a/frontend/src/lib/ocr/OcrTrainingCard.svelte +++ b/frontend/src/lib/ocr/OcrTrainingCard.svelte @@ -2,6 +2,7 @@ import TrainingHistory from './TrainingHistory.svelte'; import { m } from '$lib/paraglide/messages.js'; import type { TrainingRun } from '$lib/ocr/training.js'; +import { withCsrf } from '$lib/shared/cookies'; interface TrainingInfo { availableBlocks?: number; @@ -33,7 +34,7 @@ async function startTraining() { successMessage = null; errorMessage = null; try { - const res = await fetch('/api/ocr/train', { method: 'POST' }); + const res = await fetch('/api/ocr/train', withCsrf({ method: 'POST' })); if (res.ok) { successMessage = m.training_success(); setTimeout(() => { diff --git a/frontend/src/lib/ocr/SegmentationTrainingCard.svelte b/frontend/src/lib/ocr/SegmentationTrainingCard.svelte index cfd9c478..c1e2f50b 100644 --- a/frontend/src/lib/ocr/SegmentationTrainingCard.svelte +++ b/frontend/src/lib/ocr/SegmentationTrainingCard.svelte @@ -2,6 +2,7 @@ import TrainingHistory from './TrainingHistory.svelte'; import { m } from '$lib/paraglide/messages.js'; import type { TrainingRun } from '$lib/ocr/training.js'; +import { withCsrf } from '$lib/shared/cookies'; interface TrainingInfo { availableSegBlocks?: number; @@ -27,7 +28,7 @@ async function startTraining() { training = true; successMessage = null; try { - const res = await fetch('/api/ocr/segtrain', { method: 'POST' }); + const res = await fetch('/api/ocr/segtrain', withCsrf({ method: 'POST' })); if (res.ok) { successMessage = m.training_success(); setTimeout(() => { diff --git a/frontend/src/lib/shared/cookies.ts b/frontend/src/lib/shared/cookies.ts index ca71e08d..3b2a273a 100644 --- a/frontend/src/lib/shared/cookies.ts +++ b/frontend/src/lib/shared/cookies.ts @@ -1,3 +1,46 @@ +/** + * Reads the XSRF-TOKEN cookie set by Spring Security's CookieCsrfTokenRepository. + * Returns null outside the browser or when the cookie is absent. + */ +export function getCsrfToken(): string | null { + if (typeof document === 'undefined') return null; + const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/); + return match ? decodeURIComponent(match[1]) : null; +} + +/** + * Merges the X-XSRF-TOKEN header into a RequestInit so Spring Security's + * CSRF filter accepts the request. Safe to call server-side (no-op when the + * cookie is absent). + */ +export function withCsrf(init?: RequestInit): RequestInit { + const token = getCsrfToken(); + if (!token) return init ?? {}; + const headers = new Headers(init?.headers); + headers.set('X-XSRF-TOKEN', token); + return { ...init, headers }; +} + +/** + * Wraps a fetch implementation so that every state-mutating call (POST, PUT, + * PATCH, DELETE) automatically includes the X-XSRF-TOKEN header. GET/HEAD + * requests pass through unchanged. + * + * Used to CSRF-protect client-side hooks that accept an injectable fetchImpl. + * In unit tests the injected mock is wrapped but getCsrfToken() returns null + * (no browser cookie), so no header is added and existing test expectations + * are unaffected. + */ +export function makeCsrfFetch(inner: typeof fetch): typeof fetch { + return (input: RequestInfo | URL, init?: RequestInit): Promise => { + const method = (init?.method ?? 'GET').toUpperCase(); + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { + return inner(input, withCsrf(init)); + } + return inner(input, init); + }; +} + /** * Extracts the fa_session cookie value from a list of Set-Cookie response headers. *