release: WRNexusJS 0.7.0

This commit is contained in:
2026-08-01 10:04:42 +05:30
parent c54144f2e4
commit 87507edf59
207 changed files with 12607 additions and 679 deletions
+55
View File
@@ -0,0 +1,55 @@
import type { Context, CookieOptions } from "@wrnexus/core";
import { SecurityError } from "./errors.ts";
export interface SecureCookieOptions extends CookieOptions {
hostOnly?: boolean;
}
export function secureCookieOptions(
ctx: Pick<Context, "url">,
options: SecureCookieOptions = {},
): CookieOptions {
const secure = options.secure ?? ctx.url.protocol === "https:";
const sameSite = options.sameSite ?? "Lax";
if (sameSite.toString().toLowerCase() === "none" && !secure) {
throw new SecurityError(
"WRN-SEC-COOKIE-SAMESITE",
"SameSite=None cookies must also use Secure.",
);
}
if (options.hostOnly && options.domain) {
throw new SecurityError("WRN-SEC-COOKIE-HOST", "Host-only cookies cannot set Domain.");
}
return {
path: options.path ?? "/",
...options,
domain: options.hostOnly ? undefined : options.domain,
httpOnly: options.httpOnly ?? true,
secure,
sameSite,
};
}
export function setSecureCookie(
ctx: Pick<Context, "url" | "cookies">,
name: string,
value: string,
options: SecureCookieOptions = {},
): void {
if (name.startsWith("__Host-") && (options.domain || (options.path && options.path !== "/"))) {
throw new SecurityError(
"WRN-SEC-COOKIE-HOST-PREFIX",
"__Host- cookies require Path=/ and must not set Domain.",
);
}
ctx.cookies.set(
name,
value,
secureCookieOptions(ctx, {
...options,
hostOnly: name.startsWith("__Host-") || options.hostOnly,
path: name.startsWith("__Host-") ? "/" : options.path,
secure: name.startsWith("__Host-") ? true : options.secure,
}),
);
}
+11
View File
@@ -0,0 +1,11 @@
export class SecurityError extends Error {
readonly code: string;
readonly status: number;
constructor(code: string, message: string, status = 400, options?: ErrorOptions) {
super(message, options);
this.name = "SecurityError";
this.code = code;
this.status = status;
}
}
+160
View File
@@ -0,0 +1,160 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { SecurityError } from "./errors.ts";
import { validateUrl, type SafeUrlPolicy } from "./url.ts";
export interface SafeFetchOptions extends RequestInit, SafeUrlPolicy {
timeoutMs?: number;
maxRedirects?: number;
maxResponseBytes?: number;
blockPrivateNetworks?: boolean;
/** Forward Authorization, Cookie, and Proxy-Authorization across origin-changing redirects. Defaults to false. */
forwardSensitiveHeaders?: boolean;
resolver?: (hostname: string) => Promise<string[]>;
}
function isPrivateIpv4(address: string): boolean {
const parts = address.split(".").map(Number);
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
) {
return false;
}
const [a, b] = parts;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b! >= 16 && b! <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b! >= 64 && b! <= 127) ||
a! >= 224
);
}
function isPrivateIpv6(address: string): boolean {
const normalized = address.toLowerCase().split("%")[0]!;
const mappedIpv4 = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(normalized)?.[1];
if (mappedIpv4) return isPrivateIpv4(mappedIpv4);
return (
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe8") ||
normalized.startsWith("fe9") ||
normalized.startsWith("fea") ||
normalized.startsWith("feb") ||
normalized.startsWith("ff")
);
}
export function isPrivateAddress(address: string): boolean {
const version = isIP(address);
return version === 4 ? isPrivateIpv4(address) : version === 6 ? isPrivateIpv6(address) : false;
}
async function defaultResolver(hostname: string): Promise<string[]> {
if (isIP(hostname)) return [hostname];
return (await lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address);
}
async function assertPublicHost(url: URL, options: SafeFetchOptions): Promise<void> {
if (options.blockPrivateNetworks === false) return;
const addresses = await (options.resolver ?? defaultResolver)(url.hostname);
if (!addresses.length) {
throw new SecurityError("WRN-SEC-SSRF-DNS", `Host '${url.hostname}' did not resolve.`);
}
const blocked = addresses.find(isPrivateAddress);
if (blocked) {
throw new SecurityError(
"WRN-SEC-SSRF-PRIVATE",
`Host '${url.hostname}' resolves to blocked address '${blocked}'.`,
403,
);
}
}
export async function safeFetch(
input: string | URL,
options: SafeFetchOptions = {},
): Promise<Response> {
const timeoutMs = options.timeoutMs ?? 10_000;
const maxRedirects = options.maxRedirects ?? 3;
const maxResponseBytes = options.maxResponseBytes ?? 5 * 1024 * 1024;
const controller = new AbortController();
const externalSignal = options.signal;
const abort = () => controller.abort(externalSignal?.reason);
externalSignal?.addEventListener("abort", abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error("WRNexus safeFetch timeout")),
timeoutMs,
);
const init: RequestInit = { ...options, signal: controller.signal, redirect: "manual" };
delete (init as Record<string, unknown>).timeoutMs;
delete (init as Record<string, unknown>).maxRedirects;
delete (init as Record<string, unknown>).maxResponseBytes;
delete (init as Record<string, unknown>).blockPrivateNetworks;
delete (init as Record<string, unknown>).resolver;
delete (init as Record<string, unknown>).forwardSensitiveHeaders;
delete (init as Record<string, unknown>).base;
delete (init as Record<string, unknown>).allowRelative;
delete (init as Record<string, unknown>).allowedProtocols;
delete (init as Record<string, unknown>).allowedHosts;
delete (init as Record<string, unknown>).blockedHosts;
delete (init as Record<string, unknown>).allowCredentials;
delete (init as Record<string, unknown>).allowDataImages;
try {
let current = validateUrl(input, {
...options,
allowRelative: false,
allowedProtocols: options.allowedProtocols ?? ["https:"],
});
let previousOrigin = current.origin;
for (let redirect = 0; ; redirect++) {
await assertPublicHost(current, options);
const requestInit: RequestInit = { ...init };
if (!options.forwardSensitiveHeaders && current.origin !== previousOrigin) {
const headers = new Headers(init.headers);
headers.delete("authorization");
headers.delete("cookie");
headers.delete("proxy-authorization");
requestInit.headers = headers;
}
const response = await fetch(current, requestInit);
if (response.status >= 300 && response.status < 400 && response.headers.has("location")) {
if (redirect >= maxRedirects) {
throw new SecurityError("WRN-SEC-SSRF-REDIRECT", "Too many redirects.", 502);
}
previousOrigin = current.origin;
current = validateUrl(new URL(response.headers.get("location")!, current), {
...options,
allowRelative: false,
allowedProtocols: options.allowedProtocols ?? ["https:"],
});
continue;
}
const contentLength = Number(response.headers.get("content-length") ?? "0");
if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) {
throw new SecurityError("WRN-SEC-SSRF-SIZE", "Remote response is too large.", 502);
}
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > maxResponseBytes) {
throw new SecurityError("WRN-SEC-SSRF-SIZE", "Remote response is too large.", 502);
}
return new Response(bytes, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
} finally {
clearTimeout(timer);
externalSignal?.removeEventListener("abort", abort);
}
}
+18
View File
@@ -0,0 +1,18 @@
export { SecurityError } from "./errors.ts";
export { assertSafeObject, isDangerousObjectKey, safeMerge } from "./object.ts";
export type { SafeObjectOptions } from "./object.ts";
export { isSafeUrl, sanitizeUrl, validateUrl } from "./url.ts";
export type { SafeUrlPolicy } from "./url.ts";
export { secureJsonStringify, serializeForHtml } from "./serialization.ts";
export type { SecureSerializeOptions } from "./serialization.ts";
export { secureCookieOptions, setSecureCookie } from "./cookies.ts";
export type { SecureCookieOptions } from "./cookies.ts";
export { requestHardening } from "./request.ts";
export type { RequestHardeningOptions } from "./request.ts";
export { safeFetch, isPrivateAddress } from "./fetch.ts";
export type { SafeFetchOptions } from "./fetch.ts";
export { securityPreset } from "./presets.ts";
export type { SecurityPreset } from "./presets.ts";
export { createTrustedHtml, isTrustedHtml, unwrapTrustedHtml } from "./trusted-html.ts";
export type { TrustedHtmlPolicy, TrustedHtmlValue } from "./trusted-html.ts";
+94
View File
@@ -0,0 +1,94 @@
import { SecurityError } from "./errors.ts";
const DANGEROUS_KEYS = new Set(["__proto__", "prototype", "constructor"]);
export interface SafeObjectOptions {
maxDepth?: number;
maxKeys?: number;
allowInstances?: boolean;
}
export function isDangerousObjectKey(key: string): boolean {
return DANGEROUS_KEYS.has(key);
}
export function assertSafeObject(value: unknown, options: SafeObjectOptions = {}): void {
const maxDepth = options.maxDepth ?? 32;
const maxKeys = options.maxKeys ?? 10_000;
const seen = new WeakSet<object>();
let keyCount = 0;
const visit = (current: unknown, depth: number): void => {
if (current === null || typeof current !== "object") return;
if (depth > maxDepth) {
throw new SecurityError("WRN-SEC-OBJECT-DEPTH", `Object depth exceeds ${maxDepth}.`, 413);
}
if (seen.has(current)) {
throw new SecurityError("WRN-SEC-OBJECT-CYCLE", "Cyclic objects are not accepted.");
}
seen.add(current);
const proto = Object.getPrototypeOf(current);
if (
!options.allowInstances &&
!Array.isArray(current) &&
proto !== Object.prototype &&
proto !== null &&
!(current instanceof Date) &&
!(current instanceof URL)
) {
throw new SecurityError(
"WRN-SEC-OBJECT-PROTOTYPE",
"Only plain objects, arrays, dates, and URLs are accepted.",
);
}
for (const key of Object.keys(current)) {
keyCount++;
if (keyCount > maxKeys) {
throw new SecurityError(
"WRN-SEC-OBJECT-KEYS",
`Object contains more than ${maxKeys} keys.`,
413,
);
}
if (isDangerousObjectKey(key)) {
throw new SecurityError(
"WRN-SEC-PROTOTYPE-POLLUTION",
`Dangerous object key '${key}' is not allowed.`,
);
}
visit((current as Record<string, unknown>)[key], depth + 1);
}
seen.delete(current);
};
visit(value, 0);
}
export function safeMerge<T extends Record<string, unknown>>(
target: T,
...sources: Array<Record<string, unknown> | undefined | null>
): T {
for (const source of sources) {
if (!source) continue;
assertSafeObject(source);
for (const [key, value] of Object.entries(source)) {
if (isDangerousObjectKey(key)) continue;
const existing = target[key];
if (
value &&
existing &&
typeof value === "object" &&
typeof existing === "object" &&
!Array.isArray(value) &&
!Array.isArray(existing)
) {
safeMerge(existing as Record<string, unknown>, value as Record<string, unknown>);
} else {
target[key as keyof T] = value as T[keyof T];
}
}
}
return target;
}
+42
View File
@@ -0,0 +1,42 @@
import type { SecurityConfig } from "@wrnexus/core";
export type SecurityPreset = "balanced" | "strict" | "api";
export function securityPreset(preset: SecurityPreset = "balanced"): SecurityConfig {
if (preset === "api") {
return {
contentSecurityPolicy: false,
frameOptions: "DENY",
crossOriginOpenerPolicy: "same-origin",
referrerPolicy: "no-referrer",
permissionsPolicy: {},
extraHeaders: {
"Cross-Origin-Resource-Policy": "same-origin",
"Cache-Control": "no-store",
},
};
}
if (preset === "strict") {
return {
contentSecurityPolicy: {
useDefaults: true,
directives: {
"style-src": ["'self'"],
"script-src": ["'self'"],
"upgrade-insecure-requests": [],
},
},
trustedTypes: { enabled: true, policyNames: ["wrnexus"], requireForScript: true },
hsts: { enabled: true, maxAge: 63_072_000, includeSubDomains: true, preload: true },
extraHeaders: {
"Cross-Origin-Resource-Policy": "same-origin",
"Origin-Agent-Cluster": "?1",
},
};
}
return {
contentSecurityPolicy: { useDefaults: true },
trustedTypes: { enabled: true, requireForScript: true },
extraHeaders: { "Cross-Origin-Resource-Policy": "same-origin" },
};
}
+79
View File
@@ -0,0 +1,79 @@
import type { Context, Middleware, RequestLimitsConfig } from "@wrnexus/core";
export type RequestHardeningOptions = RequestLimitsConfig;
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
function headerBytes(headers: Headers): { count: number; bytes: number } {
let count = 0;
let bytes = 0;
headers.forEach((value, name) => {
count++;
bytes += new TextEncoder().encode(`${name}:${value}\r\n`).byteLength;
});
return { count, bytes };
}
function hostAllowed(host: string, rules: string[]): boolean {
const normalized = host.toLowerCase().split(":")[0]!;
return rules.some((rule) => {
const candidate = rule.toLowerCase();
return candidate.startsWith("*.")
? normalized.endsWith(candidate.slice(1))
: normalized === candidate;
});
}
function reject(status: number, message: string): Response {
return new Response(message, {
status,
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
});
}
export function requestHardening(options: RequestHardeningOptions = {}): Middleware {
const maxUrlLength = options.maxUrlLength ?? 8_192;
const maxHeaderCount = options.maxHeaderCount ?? 100;
const maxHeaderBytes = options.maxHeaderBytes ?? 32 * 1024;
const maxQueryParameters = options.maxQueryParameters ?? 200;
const maxBodyBytes = options.maxBodyBytes ?? 10 * 1024 * 1024;
const timeoutMs = options.timeoutMs ?? 30_000;
const maxConcurrent = options.maxConcurrent ?? 1_000;
let concurrent = 0;
return async (ctx: Context, next) => {
if (ctx.req.url.length > maxUrlLength) return reject(414, "URI Too Long");
const measured = headerBytes(ctx.req.headers);
if (measured.count > maxHeaderCount || measured.bytes > maxHeaderBytes) {
return reject(431, "Request Header Fields Too Large");
}
if ([...ctx.url.searchParams].length > maxQueryParameters) {
return reject(400, "Too many query parameters");
}
const contentLength = Number(ctx.req.headers.get("content-length") ?? "0");
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
return reject(413, "Payload Too Large");
}
if (options.trustedHosts?.length) {
const host = ctx.req.headers.get("host") ?? ctx.url.host;
if (!hostAllowed(host, options.trustedHosts)) return reject(421, "Misdirected Request");
}
if (options.fetchMetadata !== false && !SAFE_METHODS.has(ctx.req.method.toUpperCase())) {
const site = ctx.req.headers.get("sec-fetch-site");
if (site === "cross-site") return reject(403, "Cross-site request denied");
}
if (concurrent >= maxConcurrent) return reject(503, "Server Busy");
concurrent++;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const timeout = new Promise<Response>((resolve) => {
timer = setTimeout(() => resolve(reject(504, "Request Timeout")), timeoutMs);
});
return await Promise.race([Promise.resolve(next()), timeout]);
} finally {
if (timer) clearTimeout(timer);
concurrent--;
}
};
}
+66
View File
@@ -0,0 +1,66 @@
import { SecurityError } from "./errors.ts";
import { assertSafeObject, isDangerousObjectKey } from "./object.ts";
export interface SecureSerializeOptions {
maxDepth?: number;
maxKeys?: number;
maxBytes?: number;
redact?: RegExp | string[];
redactedValue?: string;
}
const DEFAULT_SECRET =
/(?:password|passwd|secret|token|api[-_]?key|private[-_]?key|otp|authorization|cookie)/i;
function shouldRedact(key: string, rule: SecureSerializeOptions["redact"]): boolean {
if (Array.isArray(rule)) return rule.includes(key);
return (rule ?? DEFAULT_SECRET).test(key);
}
export function secureJsonStringify(value: unknown, options: SecureSerializeOptions = {}): string {
assertSafeObject(value, { maxDepth: options.maxDepth, maxKeys: options.maxKeys });
const redactedValue = options.redactedValue ?? "[REDACTED]";
const json = JSON.stringify(value, function secureReplacer(key, current) {
if (isDangerousObjectKey(key)) {
throw new SecurityError(
"WRN-SEC-PROTOTYPE-POLLUTION",
`Dangerous key '${key}' is not allowed.`,
);
}
if (key && shouldRedact(key, options.redact)) return redactedValue;
if (typeof current === "bigint") return current.toString();
if (typeof current === "function" || typeof current === "symbol") {
throw new SecurityError(
"WRN-SEC-SERIALIZE-TYPE",
`Cannot serialize ${typeof current} values.`,
);
}
return current;
});
if (json === undefined) {
throw new SecurityError("WRN-SEC-SERIALIZE-EMPTY", "Value cannot be serialized.");
}
const safe = json
.replace(/&/g, "\\u0026")
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
const bytes = new TextEncoder().encode(safe).byteLength;
const maxBytes = options.maxBytes ?? 256 * 1024;
if (bytes > maxBytes) {
throw new SecurityError(
"WRN-SEC-SERIALIZE-SIZE",
`Serialized payload exceeds ${maxBytes} bytes.`,
413,
);
}
return safe;
}
export function serializeForHtml(value: unknown, options: SecureSerializeOptions = {}): string {
return secureJsonStringify(value, options);
}
+52
View File
@@ -0,0 +1,52 @@
import { SecurityError } from "./errors.ts";
const TRUSTED_HTML = Symbol("wrnexus.trusted-html");
export interface TrustedHtmlPolicy {
/** Stable policy name used in diagnostics and CSP/Trusted Types integration. */
name: string;
/** Application-supplied, reviewed sanitizer. It must return sanitized HTML. */
sanitize(input: string): string;
}
export interface TrustedHtmlValue {
readonly policy: string;
readonly value: string;
readonly [TRUSTED_HTML]: true;
}
export function createTrustedHtml(input: string, policy: TrustedHtmlPolicy): TrustedHtmlValue {
if (!policy?.name?.trim() || typeof policy.sanitize !== "function") {
throw new SecurityError(
"WRN-SEC-TRUSTED-HTML-POLICY",
"Trusted HTML requires a named sanitizer policy.",
);
}
const value = policy.sanitize(String(input));
if (typeof value !== "string") {
throw new SecurityError(
"WRN-SEC-TRUSTED-HTML-RESULT",
`Trusted HTML policy '${policy.name}' must return a string.`,
);
}
return Object.freeze({ policy: policy.name.trim(), value, [TRUSTED_HTML]: true as const });
}
export function isTrustedHtml(value: unknown): value is TrustedHtmlValue {
return Boolean(
value &&
typeof value === "object" &&
(value as Partial<TrustedHtmlValue>)[TRUSTED_HTML] === true &&
typeof (value as Partial<TrustedHtmlValue>).value === "string",
);
}
export function unwrapTrustedHtml(value: TrustedHtmlValue): string {
if (!isTrustedHtml(value)) {
throw new SecurityError(
"WRN-SEC-TRUSTED-HTML-REQUIRED",
"Raw HTML must be produced by createTrustedHtml().",
);
}
return value.value;
}
+101
View File
@@ -0,0 +1,101 @@
import { SecurityError } from "./errors.ts";
export interface SafeUrlPolicy {
base?: string | URL;
allowRelative?: boolean;
allowedProtocols?: string[];
allowedHosts?: string[];
blockedHosts?: string[];
allowCredentials?: boolean;
allowDataImages?: boolean;
}
const DEFAULT_PROTOCOLS = ["http:", "https:"];
function hasAsciiControlOrSpace(value: string): boolean {
for (const character of value) {
const code = character.charCodeAt(0);
if (code <= 0x20 || code === 0x7f) return true;
}
return false;
}
function hostnameMatches(hostname: string, rule: string): boolean {
const normalized = rule.toLowerCase().replace(/\.$/, "");
const host = hostname.toLowerCase().replace(/\.$/, "");
if (normalized.startsWith("*.")) {
const suffix = normalized.slice(1);
return host.endsWith(suffix) && host.length > suffix.length;
}
return host === normalized;
}
export function validateUrl(value: string | URL, policy: SafeUrlPolicy = {}): URL {
const raw = String(value);
if (!raw || hasAsciiControlOrSpace(raw)) {
throw new SecurityError(
"WRN-SEC-URL-CONTROL",
"URL contains whitespace or control characters.",
);
}
const isRelative = /^(?:\.{0,2}\/|\/|\?|#)/.test(raw);
if (isRelative && policy.allowRelative === false) {
throw new SecurityError("WRN-SEC-URL-RELATIVE", "Relative URLs are not allowed.");
}
let url: URL;
try {
url = new URL(raw, policy.base ?? "http://wrnexus.invalid");
} catch (error) {
throw new SecurityError("WRN-SEC-URL-INVALID", "Invalid URL.", 400, { cause: error });
}
if (url.protocol === "data:") {
if (policy.allowDataImages && /^data:image\/(?:png|gif|jpeg|webp|avif);/i.test(raw)) return url;
throw new SecurityError("WRN-SEC-URL-DATA", "Data URLs are not allowed by this policy.");
}
const protocols = policy.allowedProtocols ?? DEFAULT_PROTOCOLS;
if (!protocols.includes(url.protocol)) {
throw new SecurityError(
"WRN-SEC-URL-PROTOCOL",
`URL protocol '${url.protocol}' is not allowed.`,
);
}
if (!policy.allowCredentials && (url.username || url.password)) {
throw new SecurityError("WRN-SEC-URL-CREDENTIALS", "Credentials in URLs are not allowed.");
}
if (policy.blockedHosts?.some((rule) => hostnameMatches(url.hostname, rule))) {
throw new SecurityError("WRN-SEC-URL-BLOCKED-HOST", `Host '${url.hostname}' is blocked.`);
}
if (
policy.allowedHosts?.length &&
!policy.allowedHosts.some((rule) => hostnameMatches(url.hostname, rule))
) {
throw new SecurityError("WRN-SEC-URL-HOST", `Host '${url.hostname}' is not allowlisted.`);
}
return url;
}
export function isSafeUrl(value: string | URL, policy: SafeUrlPolicy = {}): boolean {
try {
validateUrl(value, policy);
return true;
} catch {
return false;
}
}
export function sanitizeUrl(value: unknown, policy: SafeUrlPolicy = {}): string {
try {
const raw = String(value ?? "");
const url = validateUrl(raw, policy);
if (/^(?:\.{0,2}\/|\/|\?|#)/.test(raw)) return raw;
return url.toString();
} catch {
return "about:blank";
}
}