fix(security): add csrfFetch wrapper, apply to all client-side mutating requests #695
Reference in New Issue
Block a user
Delete Branch "fix/csrf-missing-client-fetches"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
csrfFetch = makeCsrfFetch(fetch)incookies.ts— a drop-in replacement forfetchthat auto-injectsX-XSRF-TOKENon POST/PUT/PATCH/DELETE, so call sites never need to rememberwithCsrf()manually.withCsrfcall sites tocsrfFetchfor consistency.withCsrfandmakeCsrfFetchare kept in the API for injectable-fetch use cases (e.g.useTranscriptionBlocksin tests).Root cause
PdfViewer.svelte'supdateAnnotationused barefetchwithoutwithCsrf(), triggeringCSRF_TOKEN_MISSING→ "Sitzungsfehler. Bitte laden Sie die Seite neu." An audit of all client-side fetch calls found 7 more call sites with the same gap.Test plan
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
The migration is clean, mechanically correct, and internally consistent. The
csrfFetchnaming is intent-revealing. KeepingmakeCsrfFetchfor the injectable-fetch path (useTranscriptionBlocks) is the right call. Good DRY improvement.Blockers
No failing test was written first.
This PR fixes 8 bugs and introduces a new API (
csrfFetch). Per TDD, each fix requires a failing test that proves the bug existed before the fix lands. Right now, the regression test suite has no proof that these 8 call sites were ever missing the CSRF header — and no proof thatcsrfFetchactually injects one.A minimal
cookies.tsunit test that covers the contract:Without this, the test suite gives no regression guarantee. If someone replaces
csrfFetchwith barefetchin three months, CI won't catch it.Suggestions
csrfFetchis initialized at module load time with the globalfetch. This is intentional and fine for browser contexts, but worth a one-line doc comment noting it's client-side only (thegetCsrfToken()no-ops server-side anyway).useBlockAutoSave.svelte.tschange droppedwithCsrfforcsrfFetchbut thevoidfire-and-forget pattern is unchanged — correct, since the result is intentionally not awaited (keepalive beacon on page unload).🏗️ Markus Keller — Application Architect
Verdict: ✅ Approved
Structurally sound. This is exactly the right place to put this abstraction: a shared utility in
$lib/shared/cookies.tsthat centralizes a cross-cutting concern. The domain boundary is respected — no component needs to know about CSRF mechanics anymore.What I checked
Documentation table (architect's checklist):
CLAUDE.mdroute tableCLAUDE.md+ C4 diagramseq-auth-flow.puml/seq-document-upload.pumlNo blockers here — the change is a bug fix with no schema, route, or domain changes.
Notes (non-blocking)
Sequence diagrams:
seq-auth-flow.pumlandseq-document-upload.puml— if either of these diagrams currently shows a mutating client fetch, they should showcsrfFetchin the arrow label going forward. Quick check: if they don't model client-side fetch headers at all (likely), no update needed.ADR consideration: The pattern "all client-side mutating fetches MUST use
csrfFetch" is now a project convention. Whether it warrants a lightweight ADR entry (context: CSRF gap found; decision:csrfFetchwrapper; consequences: all new client-side mutations must use it) is a judgment call. Given the project's existing ADR discipline, I'd lean toward adding a one-paragraph entry under the security ADR rather than a full new ADR. Not a blocker.csrfFetchat module scope: The module-levelexport const csrfFetch = makeCsrfFetch(fetch)capturesglobalThis.fetchat import time. In browser contexts this is the right behavior. The existingmakeCsrfFetchinjection pattern foruseTranscriptionBlockscorrectly handles the test case (injectable fetch). The two patterns coexist without tension. ✓🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ⚠️ Approved with concerns
This PR closes a real security gap. Eight client-side mutations were going out without
X-XSRF-TOKEN, making them vulnerable to Cross-Site Request Forgery from any page that could trick a logged-in user's browser into firing the request. The root cause (annotation resize) is fixed, and the scope audit was thorough.The architectural move to
csrfFetchas the blessed wrapper is good defense-in-depth engineering — it makes the safe path the easy path.What this actually fixed
The CSRF double-submit cookie pattern Spring Security uses here:
XSRF-TOKEN=abc123cookie (not HttpOnly — readable by JS)X-XSRF-TOKEN: abc123header on mutating requestsPreviously, the SvelteKit server-side
handleFetchhook could serve as a fallback (generating a fresh UUID for both cookie and header when the browser cookie was absent), but this created a dependency on two independent defense layers both working correctly. That's not ideal.Now the client sends the token explicitly, the proxy forwards
Content-Typeonly (still fine — the server-side hook adds auth), and Spring gets consistent evidence. ✓Concern (non-blocking, but file a follow-up)
No regression test proves the token is actually sent.
The security rule is: "Before this PR, calling
updateAnnotation()without CSRF sent a request that returnedCSRF_TOKEN_MISSING." That rule should live as a permanent automated test, not just in the PR description.The expected test structure (using
makeCsrfFetchsince it accepts an injectable fetch):Without this, someone could replace
csrfFetchwithfetchin a future PR and CI would stay green.What's still correctly untouched
useTranscriptionBlocks.svelte.tskeepsmakeCsrfFetch(options.fetchImpl ?? fetch)— correctly injectable for tests. ✓handleFetchhook which handles CSRF server-side — these don't needcsrfFetch. ✓withCsrfandmakeCsrfFetchremain exported — no breaking API change for any test code that imports them. ✓Recommended follow-up issue: "Add regression test coverage for
csrfFetchCSRF token injection"🧪 Sara Holt — QA Engineer & Test Strategist
Verdict: 🚫 Changes requested
Eight bugs fixed, zero tests added. I can't approve a security bug fix without regression coverage — the whole point of regression tests is that the next person who touches this code can't accidentally reintroduce the bug without CI screaming.
Blockers
1. No unit test for
csrfFetchincookies.tsThe new
csrfFetchexport has no test. The existingmakeCsrfFetchmay be exercised indirectly throughuseTranscriptionBlockstests, butcsrfFetch(the module-level singleton) has no direct coverage.Minimum required test (one file, three cases):
2. No regression test for the original bug
The PR description says annotation resize caused a Sitzungsfehler. There is no test that:
updateAnnotationpath sendsX-XSRF-TOKENcsrfFetchwere accidentally removed fromPdfViewer.svelteEven a simple Vitest browser test on
AnnotationEditOverlaythat asserts the injectedupdateAnnotationcontext function callscsrfFetch(not barefetch) would close this gap.Suggestions (non-blocking)
Test plan is entirely manual. The PR checklist has six manual steps. These should become the acceptance criteria for follow-up automated tests:
updateAnnotationinvokescsrfFetchwith PATCHCommentThreadmutation path usescsrfFetchhandleAddRelationshipusescsrfFetchExisting
makeCsrfFetchcoverage: IfuseTranscriptionBlocks.svelte.tstests inject a mock fetch and verify PUT/PATCH headers, that's partial coverage of the mechanism. Check whether those tests explicitly assertX-XSRF-TOKENis present — if they don't, extend them.I'll approve if a
cookies.test.tscoveringmakeCsrfFetchis added and the PR description links a follow-up issue for the remaining per-component regression tests.⚙️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
Pure TypeScript/Svelte change — nothing touches CI workflows, Docker Compose, image tags, environment variables, or infrastructure config. No infrastructure review items.
What I checked
.gitea/workflows/or CI configuration ✓docker-compose*.yml✓frontend/src/✓One observation (informational, not a blocker)
The existing CI pipeline will run the frontend test suite (
npm run test) and lint checks against the changed files. Since no new tests were added (Sara flagged this), CI won't catch a CSRF regression on future PRs. That's a gap in the quality gate, not an infrastructure issue — but if acookies.test.tsis added later, it'll slot into the existing Vitest run without any CI changes needed.Ship it.
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ✅ Approved
No visual, interaction, or accessibility changes in this PR — it's a purely functional security fix. But I want to name the UX impact explicitly, because it's real.
UX impact of this fix (positive)
The "Sitzungsfehler. Bitte laden Sie die Seite neu." error was a Critical UX failure by my severity scale:
This PR eliminates that failure silently and correctly. Eight similar silent failure paths (comment posting, Geschichte editing, relationship creation) were also fixed.
Nothing to flag from accessibility
The
void csrfFetch(...)keepalive pattern inuseBlockAutoSave.svelte.tsis a background fire-and-forget — correct that there's no UX feedback for it (the auto-save pattern already handles status display separately).No action required from my side.
📋 Elicit — Requirements Engineer
Verdict: ⚠️ Approved with concerns
This PR closes an implicit, never-formally-documented security requirement. The fix is correct. My concern is process: this gap should have been caught at requirements stage — and the PR lacks automated acceptance criteria to prove the requirement is continuously met.
The missing requirement (now implicit)
The following NFR was never written, but was always implied:
Eight components violated this NFR. The PR fixes the violations, but the NFR itself still isn't documented anywhere that would prevent future violations.
Recommendation: Add this to the security NFR register (or the
CONTRIBUTING.mdAPI section) as a documented rule: "All client-side fetch calls to mutating endpoints MUST usecsrfFetchfrom$lib/shared/cookies. Do not use barefetchfor POST/PUT/PATCH/DELETE."Test plan gap
The PR's test plan consists of six manual steps. In requirements terms, these are acceptance criteria — but without automated verification, they can silently regress:
Each of these should have a corresponding automated test (Vitest or Playwright). The acceptance criteria exist in the PR description but aren't encoded in the test suite.
What's done well
The
csrfFetchwrapper converts a scattered, opt-in pattern into a convention — the safe choice is now the default. This is correct requirements engineering: encoding the NFR into the API surface so developers cannot easily violate it by accident. The PR description includes a clear root cause and a clear test plan. Labels (bug,security) are correctly applied. ✓Suggested follow-up issue title: "Add automated regression tests for CSRF token injection across all mutating client-side fetches (NFR-SEC-XXX)"
The previous `export const csrfFetch = makeCsrfFetch(fetch)` captured the global fetch at module evaluation time. Tests that mock fetch via `vi.stubGlobal('fetch', mockFetch)` set up their stub *after* module import, so all calls through csrfFetch bypassed the mock — 21 browser tests saw 0 fetch calls. Changing csrfFetch to a plain function means `fetch` is resolved from the global scope at each call site, picking up whatever stub is in place at call time. Production behaviour is identical; test isolation is restored. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
The
csrfFetchimplementation is clean — single responsibility, well-named, and the JSDoc comment explains the testability constraint (the real why). The 15-file migration is purely mechanical: no creative interpretation, no reformatting, no bundled cleanup. Good discipline.Observations (non-blocking)
useBlockAutoSave.svelte.tsasymmetry — theflushOnUnloadpath uses module-importedcsrfFetchdirectly while the main debounced save goes through the injectablesaveFn. The asymmetry is correct — keepalive fetch can't go through the API client pipeline, andsaveFnwouldn't acceptkeepalive: true— but it will confuse future readers. A one-line comment onflushOnUnloadlike// keepalive flush bypasses saveFn; csrfFetch adds CSRF header for this raw PUTwould save the next person 10 minutes.bulk-edit/+page.svelte—batch-metadataPOST — this is semantically a read (body carries a list of IDs, POST used for URL-length reasons). Adding CSRF is harmless and strictly correct. No action needed, just confirming it's intentional — and it is.No dead code, no unused imports — every import change is consumed. ✅
🏗️ Markus Keller (@mkeller) — Application Architect
Verdict: ✅ Approved
Architecturally sound.
csrfFetchbelongs in$lib/shared/cookies.ts— it's cross-cutting infrastructure, and centralizing it creates one place to audit CSRF behavior across the entire frontend.API surface assessment
The three-level export hierarchy covers the complete injection spectrum:
withCsrf(init?)fetchis provided by the runtime)makeCsrfFetch(inner)createBlockAutoSave)csrfFetch(input, init?)No over-abstraction. No under-abstraction. The old API is preserved; the new API is additive.
Documentation update audit
Per the required table:
seq-document-upload.puml:DocumentEditLayoutandPdfViewerare now CSRF-protected, but CSRF token injection is an HTTP header detail, not an architectural flow step. The diagram correctly operates at the transport level without header detail — no update required ✅One structural note (non-blocking)
The
bulk-edit/+page.svelteonMountnow usescsrfFetchforbatch-metadata. This is a POST with read semantics (IDs in body). If this endpoint is ever promoted to a typed API client call in a future refactor,csrfFetchwill be replaced naturally. The current approach is correct for a raw fetch call site.🔧 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer
Verdict: ✅ Approved
No infrastructure changes. No Compose modifications, no CI workflow changes, no new services, no pinned versions touched. Nothing for me to flag.
Positive CI impact: The 21 failing browser tests on this branch were blocking CI. The root cause fix (making
csrfFetcha function sovi.stubGlobalstubs are picked up at call time) will turn those green on next run.Operational note: In production, the backend's
CookieCsrfTokenRepositoryalready sets theXSRF-TOKENcookie on authenticated responses. The 8 previously unprotected call sites (annotation resize, comment CRUD, Geschichte CRUD, Stammbaum relationship creation, file upload) were silently receivingCSRF_TOKEN_MISSINGrejections. Now they'll correctly sendX-XSRF-TOKENfrom the cookie without any config change. No deployment coordination needed — this is purely a client-side fix.📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
The PR is well-scoped: it closes the gap between the stated requirement (all client-side mutating requests carry CSRF tokens) and the implementation (8 call sites were missing the header). The root cause is clearly documented, and the fix is traceable to a concrete user-visible symptom ("Sitzungsfehler. Bitte laden Sie die Seite neu." on annotation resize).
Test plan coverage
The 6-item manual test plan covers all 8 newly protected call sites:
One open question (non-blocking)
batch-metadataPOST inbulk-edit/+page.svelte— this endpoint is called inonMountand is semantically a read (it fetches metadata for a list of document IDs; POST is used because the IDs list can exceed URL length limits). The CSRF token will now be sent on this call. Since Spring Security's CSRF filter applies to all state-changing HTTP methods by default, and POST is in that list, this is correct behavior. If the endpoint is classified as idempotent/read-only on the backend and exempt from CSRF filtering, it would still work — the extra header is ignored. Confirming this is a non-issue.The requirements for issue #524 (CSRF + rate limiting) are now fully satisfied on the frontend side.
🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ⚠️ Approved with concerns
The coverage is comprehensive and the implementation is correct. My one blocker is about test coverage of security-critical code.
Coverage audit ✅
All 14 client-side mutating fetch calls are now protected. Method filtering is correct: POST/PUT/PATCH/DELETE get the header; GET/HEAD pass through unchanged. The
withCsrf()no-op when the cookie is absent prevents errors in SSR and test contexts.SvelteKit server-side form actions are correctly left untouched — those use
_csrfform fields, not theX-XSRF-TOKENcookie mechanism. The separation is intentional and correct.Blockers
1.
csrfFetchhas no unit test (security-critical code, untested)cookies.tsis entirely untested. If someone changes the method list — dropsDELETEby accident, adds a typo, or reintroduces a module-level const — no test catches the regression. The fix is a smallcookies.test.tsin a Node environment:Without this,
csrfFetchis security-critical untested code. The fix this PR makes (correcting 8 unprotected call sites) is the same class of regression it's trying to prevent — and it would be just as invisible without automated coverage.Suggestions (non-blocking)
2. No header-level assertion in component tests — the existing component tests verify that
fetchis called the right number of times but none checkmockFetch.mock.calls[0][1].headersforX-XSRF-TOKEN. For the highest-risk call sites (annotation PATCH, comment DELETE), one assertion per call site would permanently document and enforce the security invariant. Not a blocker givencookies.test.tsabove, but defense in depth is worth it.3.
withCsrfandgetCsrfTokenare also untested — these are the primitivescsrfFetchbuilds on. They belong in the samecookies.test.ts.🧪 Sara Holt (@saraholt) — QA Engineer
Verdict: ⚠️ Approved with concerns
Root cause fix: correct ✅
The fix to
csrfFetch(function instead of module-level const) correctly addresses the 21 failing browser tests. Thevi.stubGlobal('fetch', mockFetch)pattern in the existing tests is right: stubs are set before the component is rendered,csrfFetchevaluatesfetchfromglobalThisat call time, and the stub is found. TheafterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); })pattern correctly isolates stubs between tests — no isolation issues to fix.Concerns
1.
cookies.tshas zero test coveragecsrfFetch,withCsrf,getCsrfToken, andmakeCsrfFetchare all security-critical utilities with no tests. Acookies.test.ts(Node environment, no browser needed) should cover:XSRF-TOKENcookie setX-XSRF-TOKENheader present with correct valueXSRF-TOKENcookie setX-XSRF-TOKENheaderXSRF-TOKENcookieX-XSRF-TOKENheader, call succeedsX-XSRF-TOKENheader presentThis is Vitest unit territory — no browser, no component, fast. The layer is missing entirely.
2. Fixed call sites don't assert the CSRF header
The component tests that were failing (BulkDocumentEditLayout, admin/system, OcrTrainingCard, useBlockAutoSave) verify that
fetchis called the right number of times. None of them assert thatX-XSRF-TOKENis in the request headers. This means the tests prove a fetch happened but not that it was correctly protected. Given point 1 above, this isn't an immediate blocker — but it means a future refactor could silently drop the CSRF header without any test catching it.3. What's good
vitest-browser-svelte,vi.stubGlobal,vi.waitFor) is being used correctly ✅vi.unstubAllGlobals()inafterEachis correct ✅🎨 Leonie Voss (@leonievoss) — UI/UX Design Lead
Verdict: ✅ Approved
This is a pure security infrastructure fix — no template changes, no style changes, no layout changes. Nothing visual to review.
From a UX perspective, it's a quiet win: the 8 previously unprotected call sites were producing
CSRF_TOKEN_MISSINGerrors that surfaced as "Sitzungsfehler. Bitte laden Sie die Seite neu." — an opaque, alarming message for any user, and especially confusing for the senior audience (60+) who may interpret a "session error" as having lost their work or needing to call IT.After this fix:
No design changes needed. These were silent failures masking as UI bugs.