initial commit

This commit is contained in:
syntaxbullet
2026-05-13 17:26:13 +02:00
commit 99569d2cf3
43 changed files with 8725 additions and 0 deletions

327
security.ts Normal file
View File

@@ -0,0 +1,327 @@
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<string, { count: number; resetAt: number }>();
export interface SecurityOptions {
csrf?: boolean;
limit?: {
key: string;
max: number;
windowMs: number;
};
}
export type RouteHandler<T extends Request = Request> = (req: T) => Response | Promise<Response>;
export const securityHeaders: Record<string, string> = {
"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<Response | undefined> {
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<T extends Request>(handler: (req: T) => unknown | Promise<unknown>, options: SecurityOptions = {}): RouteHandler<T> {
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(`<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BMP Demo Login</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; background: #f4f7fb; color: #182230; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
form { width: min(420px, 100%); display: grid; gap: 16px; padding: 24px; border: 1px solid #d7e0ea; border-radius: 8px; background: white; box-shadow: 0 20px 60px rgba(15, 23, 42, 0.1); }
h1 { margin: 0; font-size: 24px; }
p { margin: 0; color: #627084; line-height: 1.5; }
label { display: grid; gap: 6px; font-size: 13px; color: #405166; font-weight: 700; }
input { min-height: 44px; border: 1px solid #b9c7d6; border-radius: 7px; padding: 10px 12px; font: inherit; }
button { min-height: 44px; border: 0; border-radius: 7px; background: #2563eb; color: white; font: inherit; font-weight: 760; cursor: pointer; }
</style>
</head>
<body>
<form method="post" action="/login">
<h1>BMP Demo</h1>
<p>Bitte melden Sie sich an, bevor Bewerbungen, Berichte oder LLM-Jobs geöffnet werden.</p>
<input type="hidden" name="next" value="${escapeHtml(next)}" />
<label>Demo-Passwort <input name="password" type="password" autocomplete="current-password" autofocus required /></label>
<button type="submit">Einloggen</button>
</form>
</body>
</html>`);
}
export async function login(req: Request): Promise<Response> {
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<boolean> {
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<string> {
const payload = String(expiresAt);
return `${payload}.${await sign(payload)}`;
}
async function verifyAuthCookie(value: string): Promise<boolean> {
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<string> {
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<string, string> {
const cookies = new Map<string, string>();
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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[char]!));
}