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
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:
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user