fix(csrf): send X-XSRF-TOKEN on all client-side mutating fetch calls
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m35s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m34s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 59s
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m35s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m34s
CI / fail2ban Regex (pull_request) Successful in 42s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 59s
hooks.server.ts already forwards the CSRF token for server-side fetch (form actions, load). Client-side XHR calls bypassed it, causing Spring Security to return 403 before PermissionAspect even ran. Adds getCsrfToken/withCsrf/makeCsrfFetch to cookies.ts. useTranscriptionBlocks wraps its injectable fetchImpl with makeCsrfFetch (covers all block mutations and saveBlockWithConflictRetry). useBlockAutoSave, TranscriptionEditView, BulkDocumentEditLayout, OcrTrainingCard, and SegmentationTrainingCard apply withCsrf inline. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import PdfViewer from '$lib/document/viewer/PdfViewer.svelte';
|
|||||||
import { bulkTitleFromFilename } from '$lib/document/filename';
|
import { bulkTitleFromFilename } from '$lib/document/filename';
|
||||||
import type { Tag } from '$lib/tag/TagInput.svelte';
|
import type { Tag } from '$lib/tag/TagInput.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { withCsrf } from '$lib/shared/cookies';
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
@@ -183,7 +184,10 @@ async function saveUpload() {
|
|||||||
// FormData with per-chunk progress. Session cookie is sent automatically
|
// FormData with per-chunk progress. Session cookie is sent automatically
|
||||||
// by the browser for same-origin requests.
|
// by the browser for same-origin requests.
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
|
const res = await fetch(
|
||||||
|
'/api/documents/quick-upload',
|
||||||
|
withCsrf({ method: 'POST', body: formData })
|
||||||
|
);
|
||||||
const body = await res.json().catch(() => ({ errors: [] }));
|
const body = await res.json().catch(() => ({ errors: [] }));
|
||||||
const errorFilenames = new Set<string>(
|
const errorFilenames = new Set<string>(
|
||||||
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyStat
|
|||||||
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
|
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
|
||||||
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
|
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
|
||||||
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
|
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
|
||||||
|
import { withCsrf } from '$lib/shared/cookies';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
@@ -109,11 +110,14 @@ function handleDelete(blockId: string) {
|
|||||||
|
|
||||||
async function reorder(newOrder: string[]) {
|
async function reorder(newOrder: string[]) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, {
|
const res = await fetch(
|
||||||
method: 'PUT',
|
`/api/documents/${documentId}/transcription-blocks/reorder`,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
withCsrf({
|
||||||
body: JSON.stringify({ blockIds: newOrder })
|
method: 'PUT',
|
||||||
});
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ blockIds: newOrder })
|
||||||
|
})
|
||||||
|
);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const updated = await res.json();
|
const updated = await res.json();
|
||||||
for (const b of updated) {
|
for (const b of updated) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import type { PersonMention } from '$lib/shared/types';
|
import type { PersonMention } from '$lib/shared/types';
|
||||||
|
import { withCsrf } from '$lib/shared/cookies';
|
||||||
|
|
||||||
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
export type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
|
||||||
|
|
||||||
@@ -116,12 +117,15 @@ export function createBlockAutoSave({ saveFn, documentId }: Options) {
|
|||||||
for (const [blockId, text] of pendingTexts) {
|
for (const [blockId, text] of pendingTexts) {
|
||||||
const mentions = pendingMentions.get(blockId) ?? [];
|
const mentions = pendingMentions.get(blockId) ?? [];
|
||||||
clearDebounce(blockId);
|
clearDebounce(blockId);
|
||||||
void fetch(`/api/documents/${documentId}/transcription-blocks/${blockId}`, {
|
void fetch(
|
||||||
method: 'PUT',
|
`/api/documents/${documentId}/transcription-blocks/${blockId}`,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
withCsrf({
|
||||||
body: JSON.stringify({ text, mentionedPersons: mentions }),
|
method: 'PUT',
|
||||||
keepalive: true
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
body: JSON.stringify({ text, mentionedPersons: mentions }),
|
||||||
|
keepalive: true
|
||||||
|
})
|
||||||
|
);
|
||||||
pendingTexts.delete(blockId);
|
pendingTexts.delete(blockId);
|
||||||
pendingMentions.delete(blockId);
|
pendingMentions.delete(blockId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
lastEditedAt's $derived are scope-local to one computation; they're never
|
lastEditedAt's $derived are scope-local to one computation; they're never
|
||||||
stored on $state. */
|
stored on $state. */
|
||||||
import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types';
|
import type { TranscriptionBlockData, PersonMention } from '$lib/shared/types';
|
||||||
|
import { makeCsrfFetch } from '$lib/shared/cookies';
|
||||||
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
|
import { saveBlockWithConflictRetry } from './saveBlockWithConflictRetry';
|
||||||
import { BlockConflictResolvedError } from './blockConflictMerge';
|
import { BlockConflictResolvedError } from './blockConflictMerge';
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export function createTranscriptionBlocks(
|
|||||||
options: TranscriptionBlocksOptions
|
options: TranscriptionBlocksOptions
|
||||||
): TranscriptionBlocksController {
|
): TranscriptionBlocksController {
|
||||||
const { documentId } = options;
|
const { documentId } = options;
|
||||||
const fetchImpl = options.fetchImpl ?? fetch;
|
const fetchImpl = makeCsrfFetch(options.fetchImpl ?? fetch);
|
||||||
|
|
||||||
let blocks = $state<TranscriptionBlockData[]>([]);
|
let blocks = $state<TranscriptionBlockData[]>([]);
|
||||||
let annotationReloadKey = $state(0);
|
let annotationReloadKey = $state(0);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import TrainingHistory from './TrainingHistory.svelte';
|
import TrainingHistory from './TrainingHistory.svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { TrainingRun } from '$lib/ocr/training.js';
|
import type { TrainingRun } from '$lib/ocr/training.js';
|
||||||
|
import { withCsrf } from '$lib/shared/cookies';
|
||||||
|
|
||||||
interface TrainingInfo {
|
interface TrainingInfo {
|
||||||
availableBlocks?: number;
|
availableBlocks?: number;
|
||||||
@@ -33,7 +34,7 @@ async function startTraining() {
|
|||||||
successMessage = null;
|
successMessage = null;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ocr/train', { method: 'POST' });
|
const res = await fetch('/api/ocr/train', withCsrf({ method: 'POST' }));
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
successMessage = m.training_success();
|
successMessage = m.training_success();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import TrainingHistory from './TrainingHistory.svelte';
|
import TrainingHistory from './TrainingHistory.svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { TrainingRun } from '$lib/ocr/training.js';
|
import type { TrainingRun } from '$lib/ocr/training.js';
|
||||||
|
import { withCsrf } from '$lib/shared/cookies';
|
||||||
|
|
||||||
interface TrainingInfo {
|
interface TrainingInfo {
|
||||||
availableSegBlocks?: number;
|
availableSegBlocks?: number;
|
||||||
@@ -27,7 +28,7 @@ async function startTraining() {
|
|||||||
training = true;
|
training = true;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ocr/segtrain', { method: 'POST' });
|
const res = await fetch('/api/ocr/segtrain', withCsrf({ method: 'POST' }));
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
successMessage = m.training_success();
|
successMessage = m.training_success();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -1,3 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Reads the XSRF-TOKEN cookie set by Spring Security's CookieCsrfTokenRepository.
|
||||||
|
* Returns null outside the browser or when the cookie is absent.
|
||||||
|
*/
|
||||||
|
export function getCsrfToken(): string | null {
|
||||||
|
if (typeof document === 'undefined') return null;
|
||||||
|
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges the X-XSRF-TOKEN header into a RequestInit so Spring Security's
|
||||||
|
* CSRF filter accepts the request. Safe to call server-side (no-op when the
|
||||||
|
* cookie is absent).
|
||||||
|
*/
|
||||||
|
export function withCsrf(init?: RequestInit): RequestInit {
|
||||||
|
const token = getCsrfToken();
|
||||||
|
if (!token) return init ?? {};
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
headers.set('X-XSRF-TOKEN', token);
|
||||||
|
return { ...init, headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a fetch implementation so that every state-mutating call (POST, PUT,
|
||||||
|
* PATCH, DELETE) automatically includes the X-XSRF-TOKEN header. GET/HEAD
|
||||||
|
* requests pass through unchanged.
|
||||||
|
*
|
||||||
|
* Used to CSRF-protect client-side hooks that accept an injectable fetchImpl.
|
||||||
|
* In unit tests the injected mock is wrapped but getCsrfToken() returns null
|
||||||
|
* (no browser cookie), so no header is added and existing test expectations
|
||||||
|
* are unaffected.
|
||||||
|
*/
|
||||||
|
export function makeCsrfFetch(inner: typeof fetch): typeof fetch {
|
||||||
|
return (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const method = (init?.method ?? 'GET').toUpperCase();
|
||||||
|
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
||||||
|
return inner(input, withCsrf(init));
|
||||||
|
}
|
||||||
|
return inner(input, init);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the fa_session cookie value from a list of Set-Cookie response headers.
|
* Extracts the fa_session cookie value from a list of Set-Cookie response headers.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user