const AUTH_COOKIE = "bmp_demo_auth"; const CSRF_COOKIE = "bmp_demo_csrf"; const AUTH_TTL_SECONDS = 8 * 60 * 60; const DEFAULT_LLM_MODEL = "openai/gpt-5.4-nano"; const configuredPassword = process.env.BMP_DEMO_PASSWORD ?? process.env.APP_ACCESS_TOKEN ?? process.env.DEMO_ACCESS_TOKEN; export const demoPassword = configuredPassword || crypto.randomUUID(); if (!configuredPassword) { console.warn("BMP_DEMO_PASSWORD is not set. Using this one-time demo password:"); console.warn(` ${demoPassword}`); } const rateLimitBuckets = new Map(); export interface SecurityOptions { csrf?: boolean; limit?: { key: string; max: number; windowMs: number; }; } export type RouteHandler = (req: T) => Response | Promise; export const securityHeaders: Record = { "X-Content-Type-Options": "nosniff", "Referrer-Policy": "same-origin", "X-Frame-Options": "DENY", "Permissions-Policy": "camera=(), microphone=(), geolocation=(), payment=()", }; export function isSafeStem(value: string): boolean { return /^[a-z0-9][a-z0-9_-]{0,80}$/i.test(value); } export function normalizeStem(value: string): string { const ascii = value .normalize("NFKD") .replace(/\p{Diacritic}/gu, "") .replace(/ß/g, "ss") .replace(/ẞ/g, "SS"); return ascii .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/_+/g, "_") .replace(/^_|_$/g, "") .slice(0, 80); } export function allowedModelFromEnv(): string { return process.env.OPENROUTER_MODEL || DEFAULT_LLM_MODEL; } export function validateRequestedModel(value: unknown): string | undefined { const allowed = allowedModelFromEnv(); if (value == null || value === "") return allowed; return value === allowed ? allowed : undefined; } export function getCsrfCookieName(): string { return CSRF_COOKIE; } export function csrfClientScript(): string { return ` function csrfHeaders(extra = {}) { const csrf = document.cookie .split("; ") .find((part) => part.startsWith("${CSRF_COOKIE}=")) ?.slice("${CSRF_COOKIE}=".length); return csrf ? { ...extra, "X-BMP-CSRF": decodeURIComponent(csrf) } : extra; } `; } export function addSecurityHeaders(response: Response): Response { for (const [key, value] of Object.entries(securityHeaders)) { response.headers.set(key, value); } return response; } export async function requireAuth(req: Request, options: SecurityOptions = {}): Promise { if (!rateLimit(req, "auth-check", 600, 60_000)) { return jsonError("Too many requests", 429); } if (options.limit && !rateLimit(req, options.limit.key, options.limit.max, options.limit.windowMs)) { return jsonError("Too many requests", 429); } if (!(await isAuthenticated(req))) { return unauthorized(req); } if (options.csrf && !hasValidCsrf(req)) { return jsonError("Invalid CSRF token", 403); } return undefined; } export function withAuth(handler: (req: T) => unknown | Promise, options: SecurityOptions = {}): RouteHandler { return async (req: T) => { const blocked = await requireAuth(req, options); if (blocked) return blocked; const response = await handler(req); return (response instanceof Response ? addSecurityHeaders(response) : response) as Response; }; } export function loginPage(req: Request): Response { const next = safeNextUrl(new URL(req.url).searchParams.get("next") || "/"); return html(` BMP Demo Login

BMP Demo

Bitte melden Sie sich an, bevor Bewerbungen, Berichte oder LLM-Jobs geöffnet werden.

`); } export async function login(req: Request): Promise { if (!rateLimit(req, "login", 8, 5 * 60_000)) { return html("Zu viele Login-Versuche. Bitte kurz warten.", 429); } let form: FormData; try { form = await req.formData(); } catch { return html("Ungültige Login-Anfrage.", 400); } const password = String(form.get("password") ?? ""); const next = safeNextUrl(String(form.get("next") ?? "/")); if (!timingSafeEqual(password, demoPassword)) { return html("Falsches Demo-Passwort.", 401); } const expiresAt = Math.floor(Date.now() / 1000) + AUTH_TTL_SECONDS; const authCookie = await createAuthCookie(expiresAt); const csrf = crypto.randomUUID(); const headers = new Headers({ ...securityHeaders, Location: next, }); headers.append("Set-Cookie", `${AUTH_COOKIE}=${authCookie}; Max-Age=${AUTH_TTL_SECONDS}; Path=/; HttpOnly; SameSite=Strict`); headers.append("Set-Cookie", `${CSRF_COOKIE}=${csrf}; Max-Age=${AUTH_TTL_SECONDS}; Path=/; SameSite=Strict`); return new Response(null, { status: 303, headers, }); } export function logout(): Response { const headers = new Headers({ ...securityHeaders, Location: "/login", }); headers.append("Set-Cookie", `${AUTH_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict`); headers.append("Set-Cookie", `${CSRF_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`); return new Response(null, { status: 303, headers, }); } export function jsonError(error: string, status: number): Response { return addSecurityHeaders(Response.json({ error }, { status })); } function unauthorized(req: Request): Response { const url = new URL(req.url); const acceptsHtml = req.headers.get("accept")?.includes("text/html"); if (req.method === "GET" && acceptsHtml) { return new Response(null, { status: 303, headers: { ...securityHeaders, Location: `/login?next=${encodeURIComponent(url.pathname + url.search)}`, }, }); } return jsonError("Authentication required", 401); } function html(body: string, status = 200): Response { return new Response(body, { status, headers: { ...securityHeaders, "Content-Type": "text/html; charset=utf-8", "Content-Security-Policy": "default-src 'self'; style-src 'unsafe-inline' 'self'; script-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", }, }); } function rateLimit(req: Request, key: string, max: number, windowMs: number): boolean { const bucketKey = `${key}:${clientIp(req)}`; const now = Date.now(); const bucket = rateLimitBuckets.get(bucketKey); if (!bucket || bucket.resetAt <= now) { rateLimitBuckets.set(bucketKey, { count: 1, resetAt: now + windowMs }); return true; } bucket.count += 1; return bucket.count <= max; } function clientIp(req: Request): string { return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "local"; } function hasValidCsrf(req: Request): boolean { const header = req.headers.get("x-bmp-csrf"); const cookie = parseCookies(req.headers.get("cookie")).get(CSRF_COOKIE); return Boolean(header && cookie && timingSafeEqual(header, cookie)); } async function isAuthenticated(req: Request): Promise { const auth = parseCookies(req.headers.get("cookie")).get(AUTH_COOKIE); if (auth && await verifyAuthCookie(auth)) return true; const authorization = req.headers.get("authorization") ?? ""; if (authorization.startsWith("Bearer ")) { return timingSafeEqual(authorization.slice("Bearer ".length), demoPassword); } if (authorization.startsWith("Basic ")) { try { const decoded = atob(authorization.slice("Basic ".length)); const password = decoded.includes(":") ? decoded.slice(decoded.indexOf(":") + 1) : decoded; return timingSafeEqual(password, demoPassword); } catch { return false; } } return false; } async function createAuthCookie(expiresAt: number): Promise { const payload = String(expiresAt); return `${payload}.${await sign(payload)}`; } async function verifyAuthCookie(value: string): Promise { const [payload, signature] = value.split("."); const expiresAt = Number(payload); if (!payload || !signature || !Number.isFinite(expiresAt) || expiresAt < Math.floor(Date.now() / 1000)) { return false; } return timingSafeEqual(signature, await sign(payload)); } async function sign(payload: string): Promise { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(demoPassword), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0")).join(""); } function parseCookies(header: string | null): Map { const cookies = new Map(); for (const part of (header ?? "").split(";")) { const index = part.indexOf("="); if (index < 0) continue; cookies.set(part.slice(0, index).trim(), decodeURIComponent(part.slice(index + 1).trim())); } return cookies; } function timingSafeEqual(a: string, b: string): boolean { const left = new TextEncoder().encode(a); const right = new TextEncoder().encode(b); const length = Math.max(left.length, right.length); let diff = left.length ^ right.length; for (let index = 0; index < length; index += 1) { diff |= (left[index] ?? 0) ^ (right[index] ?? 0); } return diff === 0; } function safeNextUrl(value: string): string { return value.startsWith("/") && !value.startsWith("//") ? value : "/"; } function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }[char]!)); }