initial commit
This commit is contained in:
327
security.ts
Normal file
327
security.ts
Normal 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) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}[char]!));
|
||||
}
|
||||
Reference in New Issue
Block a user