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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user