release: WRNexusJS 0.7.0
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" },
|
||||
};
|
||||
}
|
||||
@@ -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--;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user