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