feat(frontend): add CSRF injection, rate-limit i18n, and 429 login handling
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m7s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m19s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m0s

- 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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-18 12:59:56 +02:00
parent 14deae962a
commit fdb9ae31ae
8 changed files with 105 additions and 30 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, string> = {};
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);

View File

@@ -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':

View File

@@ -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') });
}

View File

@@ -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();
</script>
@@ -106,7 +106,31 @@ let {
</div>
{#if form?.error}
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
{#if form?.rateLimited}
<div
aria-invalid="true"
role="alert"
class="flex items-center gap-2 font-sans text-xs font-medium text-red-600"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="h-4 w-4 shrink-0 text-ink-3"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<span>{form.error}</span>
</div>
{:else}
<div class="text-center font-sans text-xs font-medium text-red-600">{form.error}</div>
{/if}
{/if}
<button

View File

@@ -100,6 +100,25 @@ describe('login action', () => {
expect(cookies.delete).toHaveBeenCalledWith('auth_token', { path: '/' });
});
it('returns 429 with rateLimited=true when the backend rate-limits the request', async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ code: 'TOO_MANY_LOGIN_ATTEMPTS' }), {
status: 429,
headers: { 'Content-Type': 'application/json' }
})
);
const result = await (actions as ActionsRecord).login({
request: makeRequest({ email: 'a@b.de', password: 'pw' }),
cookies: makeCookies(),
fetch: mockFetch,
url: new URL('http://localhost/login')
} as never);
expect((result as { status: number }).status).toBe(429);
expect((result as { data: { rateLimited: boolean } }).data.rateLimited).toBe(true);
});
it('returns 500 when backend response omits fa_session cookie', async () => {
const mockFetch = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
const cookies = makeCookies();