449 lines
16 KiB
TypeScript
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(", ");
|
|
}
|