Files
WRNexusJS/packages/core/src/headers.ts
T
2026-08-01 10:04:42 +05:30

449 lines
16 KiB
TypeScript

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<string, CspDirectiveValue>;
/** 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<string, string | string[] | false | null | undefined>;
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<string, string>;
}
const DEFAULT_CSP: Record<string, string[]> = {
"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": [],
unload: [],
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<string, string[]>();
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<string, string[]>,
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(", ");
}