All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m34s
CI / OCR Service Tests (pull_request) Successful in 19s
CI / Backend Unit Tests (pull_request) Successful in 3m45s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
The previous `export const csrfFetch = makeCsrfFetch(fetch)` captured the
global fetch at module evaluation time. Tests that mock fetch via
`vi.stubGlobal('fetch', mockFetch)` set up their stub *after* module import,
so all calls through csrfFetch bypassed the mock — 21 browser tests saw 0
fetch calls.
Changing csrfFetch to a plain function means `fetch` is resolved from the
global scope at each call site, picking up whatever stub is in place at
call time. Production behaviour is identical; test isolation is restored.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
81 lines
3.1 KiB
TypeScript
81 lines
3.1 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);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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;
|
|
}
|