import type { Mode } from "./errors.ts"; export type CorsOrigin = "*" | string | string[]; export interface CorsConfig { /** Enable CORS headers and preflight handling. Defaults to false. */ enabled?: boolean; /** Allowed origins. Use "*" for public APIs. Defaults to "*". */ origin?: CorsOrigin; /** Allowed methods for preflight responses. */ methods?: string[]; /** Allowed request headers. Defaults to the browser's requested headers. */ allowedHeaders?: string[]; /** Response headers exposed to browser JavaScript. */ exposedHeaders?: string[]; /** Whether to send Access-Control-Allow-Credentials. */ credentials?: boolean; /** Access-Control-Max-Age, in seconds. */ maxAge?: number; } export type CspDirectiveValue = string | string[] | false | null | undefined; export interface ContentSecurityPolicyConfig { /** Defaults to true. */ enabled?: boolean; /** Use Content-Security-Policy-Report-Only instead of enforcing. */ reportOnly?: boolean; /** Merge or remove directives. Set a directive to false/null to remove it. */ directives?: Record; /** Set false to start from an empty policy instead of WrNexus defaults. */ useDefaults?: boolean; } export interface HstsConfig { /** Defaults to true in production, false in development. */ enabled?: boolean; /** Defaults to 31536000 seconds (1 year). */ maxAge?: number; /** Defaults to true. */ includeSubDomains?: boolean; /** Defaults to true. */ preload?: boolean; } export interface TrustedTypesConfig { /** Defaults to true in production, false in development. */ enabled?: boolean; /** * Defaults to ["*"] in production so browser extensions and dev tooling can * create their own policies without noisy console errors. Set this to a * concrete list, e.g. ["wrnexus", "default"], for stricter deployments. */ policyNames?: string[]; /** Defaults to true. */ requireForScript?: boolean; /** Adds "allow-duplicates" to the trusted-types directive. */ allowDuplicates?: boolean; } export type PermissionsPolicyConfig = Record; export interface RequestLimitsConfig { maxUrlLength?: number; maxHeaderCount?: number; maxHeaderBytes?: number; maxQueryParameters?: number; maxBodyBytes?: number; timeoutMs?: number; maxConcurrent?: number; trustedHosts?: string[]; fetchMetadata?: boolean; } export interface SecurityConfig { /** Set false to skip all framework security headers except explicitly enabled CORS. */ headers?: boolean; /** Built-in request size, timeout, concurrency, host, and Fetch Metadata limits. */ requestLimits?: RequestLimitsConfig; /** * Trust `X-Forwarded-Proto` / `X-Forwarded-Host` when building `ctx.url` — set * this when the app runs behind a TLS-terminating reverse proxy (nginx, the * WrNexus gateway, a load balancer). Without it, a proxied app sees the internal * `http://` request and marks cookies (e.g. CSRF/session) non-`Secure`. Default * false; enable ONLY when a trusted proxy actually sets these headers. */ trustProxy?: boolean; cors?: boolean | CorsConfig; contentSecurityPolicy?: false | ContentSecurityPolicyConfig; hsts?: false | HstsConfig; trustedTypes?: false | TrustedTypesConfig; /** Defaults to "same-origin". */ crossOriginOpenerPolicy?: false | "same-origin" | "same-origin-allow-popups" | "unsafe-none"; /** Defaults to "DENY". */ frameOptions?: false | "DENY" | "SAMEORIGIN"; /** Defaults to "strict-origin-when-cross-origin". */ referrerPolicy?: false | string; /** Defaults to "same-origin". */ crossOriginResourcePolicy?: false | "same-origin" | "same-site" | "cross-origin"; /** Isolate the origin in its own agent cluster. Defaults to true. */ originAgentCluster?: boolean; /** Disable speculative DNS prefetching. Defaults to true. */ disableDnsPrefetch?: boolean; /** Defaults to a restrictive browser capability policy. */ permissionsPolicy?: false | PermissionsPolicyConfig; /** Extra static headers applied last. */ extraHeaders?: Record; } const DEFAULT_CSP: Record = { "default-src": ["'self'"], "script-src": ["'self'"], "style-src": ["'self'", "'unsafe-inline'"], "img-src": ["'self'", "data:", "blob:"], "font-src": ["'self'", "data:"], "connect-src": ["'self'", "ws:", "wss:"], "object-src": ["'none'"], "base-uri": ["'self'"], "frame-ancestors": ["'none'"], "form-action": ["'self'"], }; const DEFAULT_PERMISSIONS_POLICY: PermissionsPolicyConfig = { accelerometer: [], autoplay: [], camera: [], "display-capture": [], "encrypted-media": [], fullscreen: ["self"], geolocation: [], gyroscope: [], magnetometer: [], microphone: [], midi: [], payment: [], "picture-in-picture": [], "sync-xhr": [], usb: [], "xr-spatial-tracking": [], }; const DEFAULT_CORS_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; /** * Guard a WebSocket upgrade against Cross-Site WebSocket Hijacking: browsers * always send an `Origin` header on a WS handshake, and — unlike fetch — WS is * NOT subject to CORS, so cookies would otherwise flow cross-site. We allow * same-origin (Origin host === Host header), configured CORS origins, and * non-browser clients (no Origin, which also carry no ambient cookies). */ export function isWebSocketOriginAllowed(req: Request, security?: SecurityConfig): boolean { const origin = req.headers.get("origin"); if (!origin) return true; // native/non-browser client — not the CSWSH threat let originHost: string; try { originHost = new URL(origin).host; } catch { return false; } if (originHost === req.headers.get("host")) return true; // same-origin const cors = normalizeCors(security?.cors); if (cors.enabled) { const configured = cors.origin ?? "*"; if (configured === "*") return true; const list = Array.isArray(configured) ? configured : [configured]; return list.includes(origin); } return false; } export function createCorsPreflightResponse( req: Request, security?: SecurityConfig, ): Response | null { if (req.method.toUpperCase() !== "OPTIONS") return null; if (!req.headers.has("origin") || !req.headers.has("access-control-request-method")) return null; const cors = normalizeCors(security?.cors); if (!cors.enabled) return null; const headers = new Headers(); const allowed = applyCorsHeaders(req, headers, cors); if (!allowed) return new Response("CORS origin denied", { status: 403 }); return new Response(null, { status: 204, headers }); } /** * Build the request URL, honoring `X-Forwarded-Proto` / `X-Forwarded-Host` when * `trustProxy` is set (app behind a TLS-terminating reverse proxy). This makes * `ctx.url.protocol` reflect the EXTERNAL scheme, so protocol-dependent logic — * `Secure` cookies, canonical URLs — is correct behind nginx / the gateway. * Security checks that compare the raw `Host`/`Origin` headers don't use this URL, * so they are unaffected. An invalid forwarded value is ignored by the URL setter. */ export function resolveRequestUrl(req: Request, trustProxy?: boolean): URL { const url = new URL(req.url); if (!trustProxy) return url; const forwarded = req.headers.get("forwarded")?.split(",")[0] ?? ""; const forwardedProto = /(?:^|;)\s*proto=(?:"([^"]+)"|([^;\s]+))/i.exec(forwarded); const forwardedHost = /(?:^|;)\s*host=(?:"([^"]+)"|([^;\s]+))/i.exec(forwarded); const proto = req.headers.get("x-forwarded-proto") ?? forwardedProto?.[1] ?? forwardedProto?.[2]; if (proto) url.protocol = (proto.split(",")[0] ?? "").trim() + ":"; const host = req.headers.get("x-forwarded-host") ?? forwardedHost?.[1] ?? forwardedHost?.[2]; if (host) { const h = (host.split(",")[0] ?? "").trim(); url.host = h; if (!h.includes(":")) url.port = ""; // drop the internal proxy port when none forwarded } return url; } export function withSecurityHeaders( req: Request, res: Response, mode: Mode, security?: SecurityConfig, nonce?: string, ): Response { const headers = new Headers(res.headers); const cors = normalizeCors(security?.cors); if (cors.enabled) { applyCorsHeaders(req, headers, cors); } if (security?.headers !== false) { applyBaseSecurityHeaders(headers, mode, security, nonce); } if (security?.extraHeaders) { Object.entries(security.extraHeaders).forEach(([name, value]) => headers.set(name, value)); } return new Response(res.body, { status: res.status, statusText: res.statusText, headers, }); } function applyBaseSecurityHeaders( headers: Headers, mode: Mode, security?: SecurityConfig, nonce?: string, ): void { headers.set("X-Content-Type-Options", "nosniff"); const frameOptions = security?.frameOptions ?? "DENY"; if (frameOptions !== false) headers.set("X-Frame-Options", frameOptions); const coop = security?.crossOriginOpenerPolicy ?? "same-origin"; if (coop !== false) headers.set("Cross-Origin-Opener-Policy", coop); const referrerPolicy = security?.referrerPolicy ?? "strict-origin-when-cross-origin"; if (referrerPolicy !== false) headers.set("Referrer-Policy", referrerPolicy); const corp = security?.crossOriginResourcePolicy ?? "same-origin"; if (corp !== false) headers.set("Cross-Origin-Resource-Policy", corp); if (security?.originAgentCluster !== false) headers.set("Origin-Agent-Cluster", "?1"); if (security?.disableDnsPrefetch !== false) headers.set("X-DNS-Prefetch-Control", "off"); const configuredPermissions = security?.permissionsPolicy; const permissionsPolicy = configuredPermissions === false ? false : { ...DEFAULT_PERMISSIONS_POLICY, ...(configuredPermissions ?? {}) }; if (permissionsPolicy !== false) { const value = serializePermissionsPolicy(permissionsPolicy); if (value) headers.set("Permissions-Policy", value); } const csp = serializeCsp(mode, security, nonce); if (csp) { const cspConfig = security?.contentSecurityPolicy; const reportOnly = typeof cspConfig === "object" && cspConfig.reportOnly === true; headers.set( reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy", csp, ); } const hsts = security?.hsts; const hstsEnabled = hsts !== false && (mode === "production" || (typeof hsts === "object" && hsts.enabled === true)); if (hstsEnabled) { headers.set("Strict-Transport-Security", serializeHsts(typeof hsts === "object" ? hsts : {})); } } let warnedCredentialsWildcard = false; function normalizeCors(cors: SecurityConfig["cors"]): CorsConfig & { enabled: boolean } { if (cors === true) return { enabled: true, origin: "*" }; if (!cors) return { enabled: false }; const normalized = { ...cors, enabled: cors.enabled !== false }; // `*` + credentials would reflect ANY origin back with credentials allowed — // effectively disabling the same-origin policy. Refuse the combination and // drop credentials so it degrades to a safe public (non-credentialed) API. if (normalized.credentials && (normalized.origin ?? "*") === "*") { if (!warnedCredentialsWildcard) { warnedCredentialsWildcard = true; console.warn( '[wrnexus] CORS `credentials: true` cannot be combined with `origin: "*"`; ' + "credentials disabled. Set an explicit origin allowlist to use credentials.", ); } normalized.credentials = false; } return normalized; } function applyCorsHeaders(req: Request, headers: Headers, cors: CorsConfig): boolean { const origin = req.headers.get("origin"); if (!origin) return true; const allowOrigin = resolveAllowedOrigin(origin, cors); if (!allowOrigin) return false; headers.set("Access-Control-Allow-Origin", allowOrigin); appendVary(headers, "Origin"); if (cors.credentials) headers.set("Access-Control-Allow-Credentials", "true"); if (cors.exposedHeaders?.length) { headers.set("Access-Control-Expose-Headers", cors.exposedHeaders.join(", ")); } if (req.method.toUpperCase() === "OPTIONS") { headers.set("Access-Control-Allow-Methods", (cors.methods ?? DEFAULT_CORS_METHODS).join(", ")); const requestedHeaders = req.headers.get("access-control-request-headers"); const allowedHeaders = cors.allowedHeaders?.join(", ") ?? requestedHeaders; if (allowedHeaders) headers.set("Access-Control-Allow-Headers", allowedHeaders); if (typeof cors.maxAge === "number") { headers.set("Access-Control-Max-Age", String(Math.max(0, Math.floor(cors.maxAge)))); } } return true; } function resolveAllowedOrigin(origin: string, cors: CorsConfig): string | null { const configured = cors.origin ?? "*"; if (configured === "*") return cors.credentials ? origin : "*"; if (typeof configured === "string") return configured === origin ? origin : null; return configured.includes(origin) ? origin : null; } function appendVary(headers: Headers, value: string): void { const existing = headers.get("Vary"); if (!existing) { headers.set("Vary", value); return; } const values = existing.split(",").map((item) => item.trim().toLowerCase()); if (!values.includes(value.toLowerCase())) headers.set("Vary", `${existing}, ${value}`); } function serializeCsp(mode: Mode, security?: SecurityConfig, nonce?: string): string { const config = security?.contentSecurityPolicy; if (config === false || config?.enabled === false) return ""; const directives = new Map(); if (config?.useDefaults !== false) { for (const [name, value] of Object.entries(DEFAULT_CSP)) { directives.set(name, [...value]); } if (mode === "development") { directives.set("script-src", ["'self'", "'unsafe-inline'"]); } else { directives.set("upgrade-insecure-requests", []); } } for (const [name, value] of Object.entries(config?.directives ?? {})) { if (value === false || value === null) { directives.delete(name); continue; } if (value === undefined) continue; directives.set(name, Array.isArray(value) ? value : value.split(/\s+/).filter(Boolean)); } // A per-request nonce lets inline framework scripts run under a strict policy: // add 'nonce-…' to script-src and drop 'unsafe-inline' (browsers ignore // 'unsafe-inline' when a nonce is present anyway). if (nonce) { const scriptSrc = directives.get("script-src") ?? ["'self'"]; directives.set("script-src", [ ...scriptSrc.filter((v) => v !== "'unsafe-inline'"), `'nonce-${nonce}'`, ]); } applyTrustedTypesDirectives(directives, mode, security?.trustedTypes); return [...directives.entries()] .map(([name, values]) => (values.length ? `${name} ${values.join(" ")}` : name)) .join("; "); } function applyTrustedTypesDirectives( directives: Map, mode: Mode, trustedTypes: SecurityConfig["trustedTypes"], ): void { if (trustedTypes === false) return; const enabled = typeof trustedTypes === "object" ? trustedTypes.enabled !== false : mode === "production"; if (!enabled) return; const policyNames = typeof trustedTypes === "object" && trustedTypes.policyNames?.length ? trustedTypes.policyNames : ["*"]; const trustedTypesValues = [...policyNames]; if (typeof trustedTypes === "object" && trustedTypes.allowDuplicates) { trustedTypesValues.push("'allow-duplicates'"); } directives.set("trusted-types", trustedTypesValues); const requireForScript = typeof trustedTypes === "object" ? trustedTypes.requireForScript !== false : true; if (requireForScript) directives.set("require-trusted-types-for", ["'script'"]); } function serializeHsts(config: HstsConfig): string { const parts = [`max-age=${config.maxAge ?? 31536000}`]; if (config.includeSubDomains !== false) parts.push("includeSubDomains"); if (config.preload !== false) parts.push("preload"); return parts.join("; "); } function serializePermissionsPolicy(policy: PermissionsPolicyConfig): string { return Object.entries(policy) .flatMap(([feature, value]) => { if (value === false || value === null || value === undefined) return []; if (typeof value === "string") return [`${feature}=${value}`]; return [`${feature}=(${value.join(" ")})`]; }) .join(", "); }