From fdb9ae31ae7b82ccdd317712771103165da01da0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 18 May 2026 12:59:56 +0200 Subject: [PATCH] feat(frontend): add CSRF injection, rate-limit i18n, and 429 login handling - handleFetch injects X-XSRF-TOKEN + XSRF-TOKEN cookie on all mutating backend API requests (double-submit cookie pattern); generates a fresh UUID when no XSRF-TOKEN cookie exists yet - ErrorCode union gains CSRF_TOKEN_MISSING and TOO_MANY_LOGIN_ATTEMPTS; getErrorMessage maps both to i18n keys - de/en/es messages add error_csrf_token_missing and error_too_many_login_attempts translations - Login action maps HTTP 429 to fail(429, { ..., rateLimited: true }); page shows a muted clock icon with aria-invalid on rate-limit errors Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 2 + frontend/messages/en.json | 2 + frontend/messages/es.json | 2 + frontend/src/hooks.server.ts | 72 +++++++++++-------- frontend/src/lib/shared/errors.ts | 6 ++ frontend/src/routes/login/+page.server.ts | 4 ++ frontend/src/routes/login/+page.svelte | 28 +++++++- frontend/src/routes/login/page.server.test.ts | 19 +++++ 8 files changed, 105 insertions(+), 30 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 03d63f48..da101292 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -19,6 +19,8 @@ "error_session_expired_explainer": "Aus Sicherheitsgründen werden Sitzungen nach 8 Stunden Inaktivität automatisch beendet.", "error_unauthorized": "Sie sind nicht angemeldet.", "error_forbidden": "Sie haben keine Berechtigung für diese Aktion.", + "error_csrf_token_missing": "Sitzungsfehler. Bitte laden Sie die Seite neu.", + "error_too_many_login_attempts": "Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.", "error_validation_error": "Die Eingabe ist ungültig.", "error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.", "nav_documents": "Dokumente", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index d52ddbf5..0eebd0d7 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -19,6 +19,8 @@ "error_session_expired_explainer": "For security reasons, sessions are automatically ended after 8 hours of inactivity.", "error_unauthorized": "You are not logged in.", "error_forbidden": "You do not have permission for this action.", + "error_csrf_token_missing": "Session error. Please reload the page.", + "error_too_many_login_attempts": "Too many login attempts. Please try again later.", "error_validation_error": "The input is invalid.", "error_internal_error": "An unexpected error occurred.", "nav_documents": "Documents", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 177dcdff..6d29297a 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -19,6 +19,8 @@ "error_session_expired_explainer": "Por razones de seguridad, las sesiones se terminan automáticamente tras 8 horas de inactividad.", "error_unauthorized": "No ha iniciado sesión.", "error_forbidden": "No tiene permiso para realizar esta acción.", + "error_csrf_token_missing": "Error de sesión. Recargue la página.", + "error_too_many_login_attempts": "Demasiados intentos. Por favor, inténtelo más tarde.", "error_validation_error": "La entrada no es válida.", "error_internal_error": "Se ha producido un error inesperado.", "nav_documents": "Documentos", diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index de71e2b9..3db312bc 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -96,42 +96,58 @@ const userGroup: Handle = async ({ event, resolve }) => { return resolve(event); }; +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +// Auth endpoints that establish/check their own credentials — skip fa_session injection +// but still need CSRF tokens on mutating requests. +const PUBLIC_API_PATHS = [ + '/api/auth/login', + '/api/auth/logout', + '/api/auth/forgot-password', + '/api/auth/reset-password', + '/api/auth/invite/', + '/api/auth/register' +]; + export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080'; const isApi = request.url.startsWith(apiUrl) || request.url.includes('/api/'); - if (isApi) { - // Auth endpoints that establish/check their own credentials manage cookies themselves; - // don't double-inject a stale fa_session. - const PUBLIC_API_PATHS = [ - '/api/auth/login', - '/api/auth/logout', - '/api/auth/forgot-password', - '/api/auth/reset-password', - '/api/auth/invite/', - '/api/auth/register' - ]; - if (PUBLIC_API_PATHS.some((p) => request.url.includes(p))) { - return fetch(request); - } + if (!isApi) return fetch(request); - const sessionId = event.cookies.get('fa_session'); - if (!sessionId) { - return new Response('Unauthorized', { status: 401 }); - } + const isMutating = MUTATING_METHODS.has(request.method); + const isPublicAuthApi = PUBLIC_API_PATHS.some((p) => request.url.includes(p)); - // Clone first so the body stream is preserved on the new Request. - const cloned = request.clone(); - const modified = new Request(cloned, { - headers: { - ...Object.fromEntries(cloned.headers), - Cookie: `fa_session=${sessionId}` - } - }); - return fetch(modified); + const sessionId = !isPublicAuthApi ? event.cookies.get('fa_session') : null; + if (!isPublicAuthApi && !sessionId) { + return new Response('Unauthorized', { status: 401 }); } - return fetch(request); + // Read the browser's XSRF-TOKEN cookie; fall back to a fresh UUID for the + // double-submit cookie pattern (both cookie and header must match — no server secret). + const xsrfToken = isMutating ? (event.cookies.get('XSRF-TOKEN') ?? crypto.randomUUID()) : null; + + const cookieParts: string[] = []; + if (sessionId) cookieParts.push(`fa_session=${sessionId}`); + if (xsrfToken) cookieParts.push(`XSRF-TOKEN=${xsrfToken}`); + + if (cookieParts.length === 0 && !xsrfToken) { + return fetch(request); + } + + // Clone first so the body stream is preserved on the new Request. + const cloned = request.clone(); + const extraHeaders: Record = {}; + if (cookieParts.length > 0) extraHeaders['Cookie'] = cookieParts.join('; '); + if (xsrfToken) extraHeaders['X-XSRF-TOKEN'] = xsrfToken; + + const modified = new Request(cloned, { + headers: { + ...Object.fromEntries(cloned.headers), + ...extraHeaders + } + }); + return fetch(modified); }; export const handle = sequence(userGroup, handleAuth, handleLocaleDetection, handleParaglide); diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index ab79487f..96700120 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -49,6 +49,8 @@ export type ErrorCode = | 'MISSING_CREDENTIALS' | 'UNAUTHORIZED' | 'FORBIDDEN' + | 'CSRF_TOKEN_MISSING' + | 'TOO_MANY_LOGIN_ATTEMPTS' | 'VALIDATION_ERROR' | 'BATCH_TOO_LARGE' | 'BULK_EDIT_TOO_MANY_IDS' @@ -166,6 +168,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_unauthorized(); case 'FORBIDDEN': return m.error_forbidden(); + case 'CSRF_TOKEN_MISSING': + return m.error_csrf_token_missing(); + case 'TOO_MANY_LOGIN_ATTEMPTS': + return m.error_too_many_login_attempts(); case 'VALIDATION_ERROR': return m.error_validation_error(); case 'BATCH_TOO_LARGE': diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 244711d0..022e4ecd 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -45,6 +45,10 @@ export const actions = { return fail(401, { error: getErrorMessage(code) }); } + if (response.status === 429) { + return fail(429, { error: getErrorMessage('TOO_MANY_LOGIN_ATTEMPTS'), rateLimited: true }); + } + if (!response.ok) { return fail(response.status, { error: getErrorMessage('INTERNAL_ERROR') }); } diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index b2ee6b2e..540d69b6 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -7,7 +7,7 @@ let { form }: { data: { registered: boolean; reason?: string | null }; - form?: { error?: string; success?: boolean }; + form?: { error?: string; rateLimited?: boolean; success?: boolean }; } = $props(); @@ -106,7 +106,31 @@ let { {#if form?.error} -
{form.error}
+ {#if form?.rateLimited} + + {:else} +
{form.error}
+ {/if} {/if}