fix(security): add csrfFetch wrapper and apply to all client-side mutating requests
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m52s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s

Introduces `csrfFetch` (= `makeCsrfFetch(fetch)`) in cookies.ts as a
drop-in fetch replacement that auto-injects X-XSRF-TOKEN on POST/PUT/PATCH/DELETE.

Previously 8 call sites sent mutating requests without the CSRF header —
annotation resize, comment POST/PATCH/DELETE, Geschichte CRUD, Stammbaum
relationship creation, bulk-edit PATCH, and file upload — all would fail
with CSRF_TOKEN_MISSING if the backend's cookie-based protection triggered.

All 14 client-side mutating fetches now use csrfFetch; withCsrf/makeCsrfFetch
remain in the API for injectable-fetch use cases (e.g. useTranscriptionBlocks).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-30 10:50:56 +02:00
parent 8cc6031ef0
commit 58254b492b
15 changed files with 53 additions and 44 deletions

View File

@@ -3,7 +3,7 @@ import { onDestroy } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import ImportStatusCard from './ImportStatusCard.svelte';
import type { ImportStatus } from './types.js';
import { withCsrf } from '$lib/shared/cookies';
import { csrfFetch } from '$lib/shared/cookies';
let backfillResult: number | null = $state(null);
let backfillLoading = $state(false);
@@ -62,7 +62,7 @@ async function fetchImportStatus() {
}
async function triggerImport() {
const res = await fetch('/api/admin/trigger-import', withCsrf({ method: 'POST' }));
const res = await csrfFetch('/api/admin/trigger-import', { method: 'POST' });
if (res.ok) {
importStatus = await res.json();
if (importStatus!.state === 'RUNNING') {
@@ -84,7 +84,7 @@ async function fetchThumbnailStatus() {
}
async function triggerThumbnails() {
const res = await fetch('/api/admin/generate-thumbnails', withCsrf({ method: 'POST' }));
const res = await csrfFetch('/api/admin/generate-thumbnails', { method: 'POST' });
if (res.ok) {
thumbnailStatus = await res.json();
if (thumbnailStatus!.state === 'RUNNING') {
@@ -107,7 +107,7 @@ async function backfillVersions() {
backfillLoading = true;
backfillResult = null;
try {
const res = await fetch('/api/admin/backfill-versions', withCsrf({ method: 'POST' }));
const res = await csrfFetch('/api/admin/backfill-versions', { method: 'POST' });
if (res.ok) {
const data = await res.json();
backfillResult = data.count;
@@ -121,7 +121,7 @@ async function backfillFileHashes() {
backfillHashesLoading = true;
backfillHashesResult = null;
try {
const res = await fetch('/api/admin/backfill-file-hashes', withCsrf({ method: 'POST' }));
const res = await csrfFetch('/api/admin/backfill-file-hashes', { method: 'POST' });
if (res.ok) {
const data = await res.json();
backfillHashesResult = data.count;