Some checks failed
CI / fail2ban Regex (push) Has been cancelled
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 / Semgrep Security Scan (push) Has been cancelled
CI / Compose Bucket Idempotency (push) Has been cancelled
- frontend/hooks.server.ts: replace request.url.includes('/api/') with
new URL(request.url).pathname.startsWith('/api/') so a page named
/my-api/something cannot accidentally match the API gate
- DomainException: add optional retryAfterSeconds field and a new
tooManyRequests() factory overload that carries the value
- LoginRateLimiter: pass windowMinutes * 60 as retryAfterSeconds when
throwing TOO_MANY_LOGIN_ATTEMPTS (RFC 6585 §4 SHOULD)
- GlobalExceptionHandler: emit Retry-After header when retryAfterSeconds
is set on a DomainException
- RateLimitInterceptor: emit Retry-After: 60 on 429 responses (1-min
window matches the existing MAX_REQUESTS_PER_MINUTE logic)
- LoginRateLimiterTest: assert retryAfterSeconds equals window duration
- RateLimitInterceptorTest: assert Retry-After header is set on 429
- JdbcSessionRevocationAdapterIntegrationTest: new @SpringBootTest +
Testcontainers test verifying revokeAll deletes all spring_session rows
and revokeOther leaves the current session intact
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
158 lines
5.2 KiB
TypeScript
158 lines
5.2 KiB
TypeScript
import * as Sentry from '@sentry/sveltekit';
|
|
import { isRedirect, redirect, type Handle, type HandleFetch } from '@sveltejs/kit';
|
|
import { paraglideMiddleware } from '$lib/paraglide/server';
|
|
import { sequence } from '@sveltejs/kit/hooks';
|
|
import { env } from 'process';
|
|
import { cookieName, cookieMaxAge } from '$lib/paraglide/runtime';
|
|
import { detectLocale } from '$lib/shared/server/locale';
|
|
|
|
// VITE_SENTRY_DSN is a write-only ingest key — it can POST events to GlitchTip
|
|
// but cannot read them. Safe to include in the client bundle per Sentry security model.
|
|
Sentry.init({
|
|
dsn: import.meta.env.VITE_SENTRY_DSN,
|
|
environment: import.meta.env.MODE,
|
|
tracesSampleRate: 0.1,
|
|
sendDefaultPii: false,
|
|
enabled: !!import.meta.env.VITE_SENTRY_DSN
|
|
});
|
|
|
|
const PUBLIC_PATHS = [
|
|
'/login',
|
|
'/logout',
|
|
'/forgot-password',
|
|
'/reset-password',
|
|
'/register',
|
|
'/hilfe/transkription' // prerendered help page — must be reachable without an auth cookie
|
|
];
|
|
|
|
const handleLocaleDetection: Handle = ({ event, resolve }) => {
|
|
if (!event.cookies.get(cookieName)) {
|
|
const locale = detectLocale(event.request.headers.get('accept-language') ?? '');
|
|
if (locale) {
|
|
event.cookies.set(cookieName, locale, {
|
|
path: '/',
|
|
sameSite: 'lax',
|
|
maxAge: cookieMaxAge,
|
|
httpOnly: false
|
|
});
|
|
}
|
|
}
|
|
return resolve(event);
|
|
};
|
|
|
|
const handleAuth: Handle = async ({ event, resolve }) => {
|
|
const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p));
|
|
if (!isPublic && !event.locals.user) {
|
|
throw redirect(302, '/login');
|
|
}
|
|
return resolve(event);
|
|
};
|
|
|
|
const handleParaglide: Handle = ({ event, resolve }) =>
|
|
paraglideMiddleware(event.request, ({ request, locale }) => {
|
|
event.request = request;
|
|
|
|
return resolve(event, {
|
|
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale)
|
|
});
|
|
});
|
|
|
|
const userGroup: Handle = async ({ event, resolve }) => {
|
|
// One-off cleanup of the legacy Basic-credentials cookie from before the Spring Session migration (#523).
|
|
if (event.cookies.get('auth_token')) {
|
|
event.cookies.delete('auth_token', { path: '/' });
|
|
}
|
|
|
|
const sessionId = event.cookies.get('fa_session');
|
|
if (!sessionId) {
|
|
return resolve(event);
|
|
}
|
|
|
|
try {
|
|
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
|
const response = await fetch(`${apiUrl}/api/users/me`, {
|
|
headers: { Cookie: `fa_session=${sessionId}` }
|
|
});
|
|
|
|
if (response.ok) {
|
|
event.locals.user = await response.json();
|
|
} else if (response.status === 401) {
|
|
// Backend rejected the session (expired or invalidated). Drop the stale
|
|
// cookie and surface the reason on the login page. PUBLIC_PATHS check
|
|
// avoids a redirect loop if the user is already on /login.
|
|
event.cookies.delete('fa_session', { path: '/' });
|
|
const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p));
|
|
if (!isPublic) {
|
|
throw redirect(302, '/login?reason=expired');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Re-throw SvelteKit redirects (e.g. the /login?reason=expired throw above)
|
|
// using the official guard rather than duck-typing on the error shape.
|
|
if (isRedirect(error)) throw error;
|
|
console.error('Error fetching user in hook:', error);
|
|
}
|
|
|
|
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) || new URL(request.url).pathname.startsWith('/api/');
|
|
|
|
if (!isApi) return fetch(request);
|
|
|
|
const isMutating = MUTATING_METHODS.has(request.method);
|
|
const isPublicAuthApi = PUBLIC_API_PATHS.some((p) => request.url.includes(p));
|
|
|
|
const sessionId = !isPublicAuthApi ? event.cookies.get('fa_session') : null;
|
|
if (!isPublicAuthApi && !sessionId) {
|
|
return new Response('Unauthorized', { status: 401 });
|
|
}
|
|
|
|
// 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) {
|
|
return fetch(request);
|
|
}
|
|
|
|
// Clone first so the body stream is preserved on the new Request.
|
|
const cloned = request.clone();
|
|
const extraHeaders: Record<string, string> = { 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);
|
|
|
|
export const handleError = Sentry.handleErrorWithSentry(() => {
|
|
const errorId = Sentry.lastEventId() ?? crypto.randomUUID();
|
|
return { message: 'An unexpected error occurred', errorId };
|
|
});
|