first commit
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
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 SecurityConfig {
|
||||
/** Set false to skip all framework security headers except explicitly enabled CORS. */
|
||||
headers?: boolean;
|
||||
/**
|
||||
* 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 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 proto = req.headers.get("x-forwarded-proto");
|
||||
if (proto) url.protocol = (proto.split(",")[0] ?? "").trim() + ":";
|
||||
const host = req.headers.get("x-forwarded-host");
|
||||
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 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(", ");
|
||||
}
|
||||
Reference in New Issue
Block a user