/** * HTTP Security Middleware * CORS allow-list, admin token gate, Firebase ID token verification and rate limiting. * All behavior is driven by environment variables so deployments opt in explicitly. */ import crypto from "crypto"; import type { NextFunction, Request, Response } from "express"; import type { CorsOptions } from "cors"; import rateLimit from "express-rate-limit"; import { createRemoteJWKSet, jwtVerify } from "jose"; const FIREBASE_JWKS_URL = new URL( "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com" ); export const CORS_ALLOWED_HEADERS = [ "Content-Type", "Authorization", "X-Admin-Token", "X-LLM-API-Key", "X-LLM-Provider", "X-Unsloth-Key", "X-HF-Token", "X-GNews-Key", "X-AGNews-Key", "X-NewsAPI-Key", ]; export function parseAllowedOrigins(raw: string | undefined): string[] { return (raw || "") .split(",") .map((o) => o.trim().replace(/\/$/, "")) .filter(Boolean); } /** * Cross-origin access is denied unless the origin is listed in ALLOWED_ORIGINS. * Same-origin requests (the bundled web UI) never need CORS headers. */ export function buildCorsOptions(allowedOrigins: string[]): CorsOptions { return { origin: (origin, callback) => { if (!origin) return callback(null, false); callback(null, allowedOrigins.includes("*") || allowedOrigins.includes(origin)); }, methods: ["GET", "POST", "OPTIONS"], allowedHeaders: CORS_ALLOWED_HEADERS, }; } export function safeEqual(a: string, b: string): boolean { const bufA = Buffer.from(a); const bufB = Buffer.from(b); if (bufA.length !== bufB.length) return false; return crypto.timingSafeEqual(bufA, bufB); } /** * Gates administrative endpoints behind ADMIN_TOKEN (sent as `X-Admin-Token`). * When ADMIN_TOKEN is unset the endpoints are disabled entirely. */ export function requireAdmin(adminToken: string | undefined) { const expected = (adminToken || "").trim(); return (req: Request, res: Response, next: NextFunction) => { if (!expected) { res.status(403).json({ error: "Admin endpoints are disabled. Set the ADMIN_TOKEN environment variable on the server to enable them.", }); return; } const provided = String(req.headers["x-admin-token"] || "").trim(); if (!provided || !safeEqual(provided, expected)) { res.status(401).json({ error: "Invalid or missing X-Admin-Token header." }); return; } next(); }; } export interface FirebaseAuthConfig { projectId?: string; requireAuth: boolean; } /** * Verifies Firebase ID tokens sent as `Authorization: Bearer `. * - A token that is present but invalid is always rejected. * - A missing token is rejected only when REQUIRE_AUTH=true. * - REQUIRE_AUTH=true without FIREBASE_PROJECT_ID fails closed. */ export function firebaseAuth(config: FirebaseAuthConfig) { const projectId = (config.projectId || "").trim(); const jwks = projectId ? createRemoteJWKSet(FIREBASE_JWKS_URL) : null; return async (req: Request, res: Response, next: NextFunction) => { const header = String(req.headers.authorization || ""); const token = header.startsWith("Bearer ") ? header.slice(7).trim() : ""; if (!token) { if (config.requireAuth) { res.status(401).json({ error: "Authentication required. Sign in to run this request." }); return; } next(); return; } if (!jwks) { if (config.requireAuth) { res.status(503).json({ error: "Server misconfigured: REQUIRE_AUTH is set but FIREBASE_PROJECT_ID is missing." }); return; } // Verification is not configured; treat the request as anonymous. next(); return; } try { const { payload } = await jwtVerify(token, jwks, { issuer: `https://securetoken.google.com/${projectId}`, audience: projectId, }); res.locals.userId = payload.sub; next(); } catch { res.status(401).json({ error: "Invalid or expired authentication token." }); } }; } function readPositiveInt(raw: string | undefined, fallback: number): number { const num = Number(raw); return Number.isInteger(num) && num > 0 ? num : fallback; } export function buildRateLimiters(env: NodeJS.ProcessEnv) { const general = rateLimit({ windowMs: 60 * 1000, limit: readPositiveInt(env.API_RATE_LIMIT_PER_MINUTE, 120), standardHeaders: "draft-7", legacyHeaders: false, message: { error: "Too many requests. Please slow down." }, }); const forecast = rateLimit({ windowMs: 15 * 60 * 1000, limit: readPositiveInt(env.FORECAST_RATE_LIMIT_PER_15_MIN, 15), standardHeaders: "draft-7", legacyHeaders: false, message: { error: "Forecast rate limit reached. Please try again in a few minutes." }, }); return { general, forecast }; }