NewsAnalyze / src /server /security.ts
GeoStrat-CI
Deploy 5924376 to Hugging Face Space [skip ci]
2c0e275
Raw History Blame Contribute Delete
4.88 kB
/**
* 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 <token>`.
* - 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 };
}