import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; import helmet from 'helmet'; import cookieParser from 'cookie-parser'; import { logDebug, logWarn, logError } from '../services/auditLog'; import { enforceGlobalMfaPolicy } from './mfaPolicy'; /** * The global request pipeline shared by the legacy Express app and the NestJS * instance. Both mount the *exact same* config so a request hitting a migrated * Nest route is protected identically to one hitting the legacy fallback * (helmet/CSP, CORS, HSTS, forced-HTTPS, the global MFA policy and request * logging). Keeping it in one place is what makes the strangler dispatch * behaviourally transparent — and is the prerequisite for retiring Express, * since the Nest instance must carry the whole shell on its own. * * `bodyParser` is opt-out: the Nest instance does its own body parsing, so it * passes `false` to avoid parsing the request twice. */ export function applyGlobalMiddleware( app: express.Application, opts: { bodyParser?: boolean } = {}, ): void { const { bodyParser = true } = opts; // Trust first proxy (nginx/Docker) for correct req.ip if (process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.TRUST_PROXY) { app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1); } const allowedOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean) : null; let corsOrigin: cors.CorsOptions['origin']; if (allowedOrigins) { corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { if (!origin || allowedOrigins.includes(origin)) callback(null, true); else callback(new Error('Not allowed by CORS')); }; } else if (process.env.NODE_ENV?.toLowerCase() === 'production') { corsOrigin = false; } else { corsOrigin = true; } const shouldForceHttps = process.env.FORCE_HTTPS?.toLowerCase() === 'true'; // HSTS is worth enabling any time we're serving production traffic, // not only when FORCE_HTTPS is set. Self-hosters behind Traefik / // Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the // proxy handles the redirect for them), and the previous "HSTS off by // default" meant those instances never advertised HSTS at all. // // `includeSubDomains` stays OFF by default on purpose: an instance // running on an apex domain would otherwise force HTTPS on every // sibling subdomain the same operator may still be running over plain // HTTP. Operators who want the stricter policy opt in with // `HSTS_INCLUDE_SUBDOMAINS=true`. const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production'; const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true'; // RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable. // /mcp needs open CORS so external MCP clients (ChatGPT, Claude.ai, Inspector) can call it // with Bearer tokens from any origin. /oauth/register and /oauth/authorize need it for // browser-based DCR/authorization preflights — the global cors({ origin: false }) would // answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs. // All /.well-known/* paths get open CORS so clients probing openid-configuration or the // RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead). app.use( (req: Request, _res: Response, next: NextFunction) => { if ( req.path.startsWith('/.well-known/') || req.path === '/oauth/register' || req.path === '/oauth/authorize' || req.path === '/oauth/userinfo' || req.path === '/mcp' ) { cors({ origin: '*', credentials: false })(req, _res, next); } else { next(); } }, ); app.use(cors({ origin: corsOrigin, credentials: true })); app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"], imgSrc: ["'self'", "data:", "blob:", "https:"], connectSrc: [ "'self'", "ws:", "wss:", "https://nominatim.openstreetmap.org", "https://overpass-api.de", "https://places.googleapis.com", "https://api.openweathermap.org", "https://en.wikipedia.org", "https://commons.wikimedia.org", "https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org", "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com", "https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/", "https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com" ], workerSrc: ["'self'", "blob:"], childSrc: ["'self'", "blob:"], fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"], objectSrc: ["'none'"], frameSrc: ["'none'"], frameAncestors: ["'self'"], // Restrict