Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / fail2ban Regex (push) Has been cancelled
CI / Semgrep Security Scan (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 3m34s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
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>
64 lines
2.4 KiB
TypeScript
64 lines
2.4 KiB
TypeScript
/**
|
|
* 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.
|
|
*
|
|
* The backend may append attributes like `Path`, `HttpOnly`, `SameSite=Strict`,
|
|
* `Max-Age`, `Secure`; we only forward the opaque session id — the SvelteKit
|
|
* cookies API rewrites the attributes itself when re-emitting to the browser.
|
|
*
|
|
* Pass the result of `response.headers.getSetCookie()` (modern Node/Undici) or
|
|
* a single-element array containing `response.headers.get('set-cookie')` for
|
|
* older runtimes that lack `getSetCookie`.
|
|
*
|
|
* Returns `null` if no fa_session cookie is present.
|
|
*/
|
|
export function extractFaSessionId(setCookieHeaders: string[]): string | null {
|
|
for (const header of setCookieHeaders) {
|
|
const match = header.match(/^fa_session=([^;]+)/);
|
|
if (match) return match[1];
|
|
}
|
|
return null;
|
|
}
|