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:"]; const RELATIVE_PREFIX = /^(?:\.{0,2}\/|\/|\?|#)/; /** * `//evil.com` (and the `/\evil.com` spelling browsers normalise to it) reads * like a same-site path but navigates cross-origin. It must never be handed * back verbatim, or the host checks above are bypassed entirely. */ function isProtocolRelative(raw: string): boolean { return /^[/\\]{2}/.test(raw); } 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 = RELATIVE_PREFIX.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); // Genuinely relative input round-trips unchanged; protocol-relative input // is resolved so the returned string carries the host the policy approved. if (RELATIVE_PREFIX.test(raw) && !isProtocolRelative(raw)) return raw; return url.toString(); } catch { return "about:blank"; } }