fix(security): add csrfFetch wrapper, apply to all client-side mutating requests #695

Merged
marcel merged 3 commits from fix/csrf-missing-client-fetches into main 2026-05-30 14:39:14 +02:00
16 changed files with 296 additions and 44 deletions

View File

@@ -17,7 +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';
import { csrfFetch } from '$lib/shared/cookies';
type Person = components['schemas']['Person'];
@@ -184,10 +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',
withCsrf({ method: 'POST', body: formData })
);
const res = await csrfFetch('/api/documents/quick-upload', {
method: 'POST',
body: formData
});
const body = await res.json().catch(() => ({ errors: [] }));
const errorFilenames = new Set<string>(
(body.errors ?? []).map((err: { filename: string }) => err.filename)
@@ -241,7 +241,7 @@ async function saveBulkEdit() {
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
try {
const res = await fetch('/api/documents/bulk', {
const res = await csrfFetch('/api/documents/bulk', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...dto, documentIds: chunk })

View File

@@ -13,6 +13,7 @@ import WhoWhenSection from '$lib/document/WhoWhenSection.svelte';
import DescriptionSection from '$lib/document/DescriptionSection.svelte';
import type { Tag } from '$lib/tag/TagInput.svelte';
import type { components } from '$lib/generated/api';
import { csrfFetch } from '$lib/shared/cookies';
import type { DatePrecision } from '$lib/shared/utils/documentDate';
type Person = components['schemas']['Person'];
@@ -86,7 +87,7 @@ async function handleFile(file: File) {
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`/api/documents/${doc.id}/file`, {
const res = await csrfFetch(`/api/documents/${doc.id}/file`, {
method: 'POST',
body: formData,
signal: controller.signal

View File

@@ -6,7 +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';
import { csrfFetch } from '$lib/shared/cookies';
type Props = {
documentId: string;
@@ -114,14 +114,11 @@ function handleDelete(blockId: string) {
async function reorder(newOrder: string[]) {
try {
const res = await fetch(
`/api/documents/${documentId}/transcription-blocks/reorder`,
withCsrf({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ blockIds: newOrder })
})
);
const res = await csrfFetch(`/api/documents/${documentId}/transcription-blocks/reorder`, {
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) {

View File

@@ -1,6 +1,6 @@
import { SvelteMap } from 'svelte/reactivity';
import type { PersonMention } from '$lib/shared/types';
import { withCsrf } from '$lib/shared/cookies';
import { csrfFetch } from '$lib/shared/cookies';
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
@@ -117,15 +117,12 @@ 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}`,
withCsrf({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mentionedPersons: mentions }),
keepalive: true
})
);
void csrfFetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, mentionedPersons: mentions }),
keepalive: true
});
pendingTexts.delete(blockId);
pendingMentions.delete(blockId);
}

View File

@@ -6,6 +6,7 @@ import AnnotationLayer from '$lib/document/annotation/AnnotationLayer.svelte';
import type { Annotation } from '$lib/shared/types';
import { m } from '$lib/paraglide/messages.js';
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
import { csrfFetch } from '$lib/shared/cookies';
type DrawRect = { x: number; y: number; width: number; height: number; pageNumber: number };
@@ -132,7 +133,7 @@ async function updateAnnotation(
coords: { x: number; y: number; width: number; height: number }
) {
if (!documentId) return;
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
const res = await csrfFetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(coords)

View File

@@ -2,7 +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';
import { csrfFetch } from '$lib/shared/cookies';
interface TrainingInfo {
availableBlocks?: number;
@@ -34,7 +34,7 @@ async function startTraining() {
successMessage = null;
errorMessage = null;
try {
const res = await fetch('/api/ocr/train', withCsrf({ method: 'POST' }));
const res = await csrfFetch('/api/ocr/train', { method: 'POST' });
if (res.ok) {
successMessage = m.training_success();
setTimeout(() => {

View File

@@ -2,7 +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';
import { csrfFetch } from '$lib/shared/cookies';
interface TrainingInfo {
availableSegBlocks?: number;
@@ -28,7 +28,7 @@ async function startTraining() {
training = true;
successMessage = null;
try {
const res = await fetch('/api/ocr/segtrain', withCsrf({ method: 'POST' }));
const res = await csrfFetch('/api/ocr/segtrain', { method: 'POST' });
if (res.ok) {
successMessage = m.training_success();
setTimeout(() => {

View File

@@ -6,6 +6,7 @@ import { chipLabel, otherName, inferredRelationshipLabel } from '$lib/person/rel
import AddRelationshipForm from '$lib/person/relationship/AddRelationshipForm.svelte';
import type { RelFormData } from '$lib/person/relationship/AddRelationshipForm.svelte';
import type { components } from '$lib/generated/api';
import { csrfFetch } from '$lib/shared/cookies';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -59,7 +60,7 @@ async function handleAddRelationship(data: RelFormData) {
};
if (data.fromYear !== undefined) body.fromYear = data.fromYear;
if (data.toYear !== undefined) body.toYear = data.toYear;
const res = await fetch(`/api/persons/${node.id}/relationships`, {
const res = await csrfFetch(`/api/persons/${node.id}/relationships`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)

View File

@@ -0,0 +1,233 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { getCsrfToken, withCsrf, makeCsrfFetch, csrfFetch } from './cookies';
// Helper that builds a minimal document.cookie stub.
function stubDocument(cookieStr: string) {
vi.stubGlobal('document', { cookie: cookieStr });
}
afterEach(() => {
vi.unstubAllGlobals();
});
// ---------------------------------------------------------------------------
// getCsrfToken
// ---------------------------------------------------------------------------
describe('getCsrfToken', () => {
it('returns null when document is undefined (server-side / Node)', () => {
// No stub — document is undefined in Node vitest environment.
expect(getCsrfToken()).toBeNull();
});
it('returns null when XSRF-TOKEN cookie is absent', () => {
stubDocument('other=abc; another=xyz');
expect(getCsrfToken()).toBeNull();
});
it('returns the token when XSRF-TOKEN is the only cookie', () => {
stubDocument('XSRF-TOKEN=secret42');
expect(getCsrfToken()).toBe('secret42');
});
it('returns the token when XSRF-TOKEN appears among other cookies', () => {
stubDocument('fa_session=sess1; XSRF-TOKEN=csrf99; locale=de');
expect(getCsrfToken()).toBe('csrf99');
});
it('URL-decodes the token value', () => {
stubDocument('XSRF-TOKEN=hello%2Fworld');
expect(getCsrfToken()).toBe('hello/world');
});
});
// ---------------------------------------------------------------------------
// withCsrf
// ---------------------------------------------------------------------------
describe('withCsrf', () => {
it('returns an empty RequestInit when cookie is absent and no init given', () => {
// No document stub → getCsrfToken() returns null.
expect(withCsrf()).toEqual({});
});
it('passes init through unchanged when cookie is absent', () => {
const init: RequestInit = { method: 'POST', body: 'data' };
expect(withCsrf(init)).toEqual(init);
});
it('injects X-XSRF-TOKEN header when token is present', () => {
stubDocument('XSRF-TOKEN=tok123');
const result = withCsrf({ method: 'POST' });
expect((result.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok123');
});
it('preserves existing headers when injecting the CSRF token', () => {
stubDocument('XSRF-TOKEN=tok123');
const result = withCsrf({ headers: { 'Content-Type': 'application/json' } });
const headers = result.headers as Headers;
expect(headers.get('Content-Type')).toBe('application/json');
expect(headers.get('X-XSRF-TOKEN')).toBe('tok123');
});
it('preserves the rest of the RequestInit when injecting', () => {
stubDocument('XSRF-TOKEN=tok');
const result = withCsrf({ method: 'PUT', body: '{}' });
expect(result.method).toBe('PUT');
expect(result.body).toBe('{}');
});
it('does not mutate the original init object', () => {
stubDocument('XSRF-TOKEN=tok');
const init: RequestInit = { method: 'POST' };
withCsrf(init);
expect(init.headers).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// makeCsrfFetch
// ---------------------------------------------------------------------------
describe('makeCsrfFetch', () => {
const MUTATING = ['POST', 'PUT', 'PATCH', 'DELETE'] as const;
const SAFE = ['GET', 'HEAD'] as const;
it('wraps the injected fetch — not the global one', async () => {
const inner = vi.fn().mockResolvedValue(new Response());
const globalMock = vi.fn().mockResolvedValue(new Response());
vi.stubGlobal('fetch', globalMock);
const wrapped = makeCsrfFetch(inner);
await wrapped('/api/test', { method: 'GET' });
expect(inner).toHaveBeenCalledOnce();
expect(globalMock).not.toHaveBeenCalled();
});
it.each(MUTATING)('calls withCsrf for %s requests', async (method) => {
stubDocument('XSRF-TOKEN=tok');
const inner = vi.fn().mockResolvedValue(new Response());
const wrapped = makeCsrfFetch(inner);
await wrapped('/api/resource', { method });
const passedInit = inner.mock.calls[0][1] as RequestInit;
expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok');
});
it.each(SAFE)('passes %s requests through without CSRF header', async (method) => {
stubDocument('XSRF-TOKEN=tok');
const inner = vi.fn().mockResolvedValue(new Response());
const wrapped = makeCsrfFetch(inner);
await wrapped('/api/resource', { method });
// init is passed as-is (no headers added).
const passedInit = inner.mock.calls[0][1] as RequestInit;
expect(passedInit?.headers).toBeUndefined();
});
it('defaults to GET semantics when method is omitted', async () => {
stubDocument('XSRF-TOKEN=tok');
const inner = vi.fn().mockResolvedValue(new Response());
const wrapped = makeCsrfFetch(inner);
await wrapped('/api/resource');
expect(inner).toHaveBeenCalledOnce();
const passedInit = inner.mock.calls[0][1] as RequestInit | undefined;
expect(passedInit?.headers).toBeUndefined();
});
it('is case-insensitive for method names', async () => {
stubDocument('XSRF-TOKEN=tok');
const inner = vi.fn().mockResolvedValue(new Response());
const wrapped = makeCsrfFetch(inner);
await wrapped('/api/resource', { method: 'post' });
const passedInit = inner.mock.calls[0][1] as RequestInit;
expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok');
});
});
// ---------------------------------------------------------------------------
// csrfFetch — uses the global fetch (regression guard for the module-const bug)
// ---------------------------------------------------------------------------
describe('csrfFetch', () => {
const MUTATING = ['POST', 'PUT', 'PATCH', 'DELETE'] as const;
const SAFE = ['GET', 'HEAD'] as const;
it('picks up a vi.stubGlobal fetch stub — does NOT bypass the mock', async () => {
// This is the regression test for the original module-level-const bug.
// If csrfFetch were `export const csrfFetch = makeCsrfFetch(fetch)` the
// reference captured at module init time would skip any later stub.
const mock = vi.fn().mockResolvedValue(new Response());
vi.stubGlobal('fetch', mock);
await csrfFetch('/api/test', { method: 'GET' });
expect(mock).toHaveBeenCalledOnce();
});
it.each(MUTATING)('injects X-XSRF-TOKEN for %s when cookie is set', async (method) => {
stubDocument('XSRF-TOKEN=csrf-val');
const mock = vi.fn().mockResolvedValue(new Response());
vi.stubGlobal('fetch', mock);
await csrfFetch('/api/resource', { method });
const passedInit = mock.mock.calls[0][1] as RequestInit;
expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('csrf-val');
});
it.each(SAFE)('does NOT inject CSRF header for %s', async (method) => {
stubDocument('XSRF-TOKEN=csrf-val');
const mock = vi.fn().mockResolvedValue(new Response());
vi.stubGlobal('fetch', mock);
await csrfFetch('/api/resource', { method });
const passedInit = mock.mock.calls[0][1] as RequestInit;
expect(passedInit?.headers).toBeUndefined();
});
it('does not inject header when XSRF-TOKEN cookie is absent', async () => {
// No document stub → getCsrfToken() returns null → withCsrf() is a no-op.
const mock = vi.fn().mockResolvedValue(new Response());
vi.stubGlobal('fetch', mock);
await csrfFetch('/api/resource', { method: 'POST' });
const passedInit = mock.mock.calls[0][1] as RequestInit;
// withCsrf returns { ...init } when no token — headers key won't be set.
const headers = passedInit.headers;
if (headers instanceof Headers) {
expect(headers.has('X-XSRF-TOKEN')).toBe(false);
} else {
expect((headers as Record<string, string> | undefined)?.['X-XSRF-TOKEN']).toBeUndefined();
}
});
it('forwards the URL and init body to fetch', async () => {
const mock = vi.fn().mockResolvedValue(new Response());
vi.stubGlobal('fetch', mock);
await csrfFetch('/api/resource', { method: 'GET', body: null });
expect(mock.mock.calls[0][0]).toBe('/api/resource');
});
it('is case-insensitive for method names', async () => {
stubDocument('XSRF-TOKEN=tok');
const mock = vi.fn().mockResolvedValue(new Response());
vi.stubGlobal('fetch', mock);
await csrfFetch('/api/resource', { method: 'delete' });
const passedInit = mock.mock.calls[0][1] as RequestInit;
expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok');
});
});

View File

@@ -41,6 +41,23 @@ export function makeCsrfFetch(inner: typeof fetch): typeof fetch {
};
}
/**
* Drop-in replacement for fetch that automatically injects X-XSRF-TOKEN on
* all mutating requests (POST, PUT, PATCH, DELETE). Use this everywhere in
* client-side code instead of bare fetch + withCsrf().
*
* Implemented as a function (not a module-level const) so that test stubs
* applied via vi.stubGlobal('fetch', mock) are picked up at call time rather
* than being silently bypassed by a pre-captured reference.
*/
export function csrfFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const method = (init?.method ?? 'GET').toUpperCase();
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
return fetch(input, withCsrf(init));
}
return fetch(input, init);
}
/**
* Extracts the fa_session cookie value from a list of Set-Cookie response headers.
*

View File

@@ -5,6 +5,7 @@ import type { Comment, FlatMessage, MentionDTO } from '$lib/shared/types';
import MentionEditor from '$lib/shared/discussion/MentionEditor.svelte';
import CommentMessage from '$lib/shared/discussion/CommentMessage.svelte';
import { extractContent } from '$lib/shared/discussion/mention';
import { csrfFetch } from '$lib/shared/cookies';
type Props = {
documentId: string;
annotationId?: string | null;
@@ -79,7 +80,7 @@ async function postComment() {
posting = true;
try {
const { content, mentionedUserIds } = extractContent(text, newMentionCandidates);
const res = await fetch(commentsBase, {
const res = await csrfFetch(commentsBase, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mentionedUserIds })
@@ -104,7 +105,7 @@ async function saveEdit(commentId: string) {
if (!text || posting) return;
posting = true;
try {
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
const res = await csrfFetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: text })
@@ -138,7 +139,7 @@ async function deleteComment(commentId: string) {
if (posting) return;
posting = true;
try {
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
const res = await csrfFetch(`/api/documents/${documentId}/comments/${commentId}`, {
method: 'DELETE'
});
if (res.ok) {

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;

View File

@@ -7,6 +7,7 @@ import BulkDocumentEditLayout, {
} from '$lib/document/BulkDocumentEditLayout.svelte';
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
import { m } from '$lib/paraglide/messages.js';
import { csrfFetch } from '$lib/shared/cookies';
let entries = $state<BulkEditEntry[]>([]);
let loading = $state(true);
@@ -22,7 +23,7 @@ onMount(async () => {
return;
}
try {
const res = await fetch('/api/documents/batch-metadata', {
const res = await csrfFetch('/api/documents/batch-metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })

View File

@@ -5,6 +5,7 @@ import { safeHtml } from '$lib/shared/utils/sanitize';
import { formatDate } from '$lib/shared/utils/date';
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import { csrfFetch } from '$lib/shared/cookies';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@@ -35,7 +36,7 @@ async function handleDelete() {
destructive: true
});
if (!ok) return;
const res = await fetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
if (res.ok) {
goto('/geschichten');
}

View File

@@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import { getErrorMessage } from '$lib/shared/errors';
import { csrfFetch } from '$lib/shared/cookies';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@@ -21,7 +22,7 @@ async function handleSubmit(payload: {
submitting = true;
errorMessage = null;
try {
const res = await fetch(`/api/geschichten/${data.geschichte.id}`, {
const res = await csrfFetch(`/api/geschichten/${data.geschichte.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)

View File

@@ -4,6 +4,7 @@ import { m } from '$lib/paraglide/messages.js';
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import { getErrorMessage } from '$lib/shared/errors';
import { csrfFetch } from '$lib/shared/cookies';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@@ -21,7 +22,7 @@ async function handleSubmit(payload: {
submitting = true;
errorMessage = null;
try {
const res = await fetch('/api/geschichten', {
const res = await csrfFetch('/api/geschichten', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)