/** * Shared HTML-escaping and URL-safety utilities. * * `escapeHtml` / `escapeAttr` are the single source of truth for escaping * user-supplied strings before they are interpolated into exported/rendered * HTML. `safeUrl` blocks dangerous URL schemes (javascript:, vbscript:, * data:text/html) including whitespace/case/entity-obfuscated variants. */ /** Escapes &, <, >, " for safe use in text content and double-quoted attributes. */ export function escapeHtml(s: string): string { if (typeof s !== 'string') { if (s === null || s === undefined) return ''; s = String(s); } // Ampersand MUST be escaped first, otherwise the entities produced by the // other replacements would themselves be re-escaped (double-escaping). return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /** Escapes everything escapeHtml does, plus ' and ` for use in any attribute context. */ export function escapeAttr(s: string): string { if (typeof s !== 'string') { if (s === null || s === undefined) return ''; s = String(s); } return escapeHtml(s) .replace(/'/g, ''') .replace(/`/g, '`'); } // Dangerous scheme prefixes, checked against a normalized copy with all // colons removed (see safeUrl) so that colon-splicing obfuscation like // "java:script:alert(1)" can't slip past a literal "javascript:" check. const DANGEROUS_SCHEME_PREFIXES = ['javascript', 'vbscript', 'datatext/html']; /** Decodes &#NN; and &#xNN; numeric HTML entities (used to obfuscate scheme names). */ function decodeNumericEntities(s: string): string { return s.replace(/&#x([0-9a-f]+);?/gi, (_m, hex) => String.fromCharCode(parseInt(hex, 16))) .replace(/�*([0-9]+);?/g, (_m, dec) => String.fromCharCode(parseInt(dec, 10))); } /** * Returns the trimmed input if it is a safe URL, otherwise returns ''. * Blocks javascript:, vbscript:, and data:text/html schemes, including * whitespace / control-char / case / HTML-entity obfuscated variants. * Allowed URLs are returned UNCHANGED (trimmed only) -- query strings etc. * are never mangled. */ export function safeUrl(s: string): string { if (typeof s !== 'string') return ''; const trimmed = s.trim(); if (trimmed === '') return ''; // Build a normalized copy for scheme detection only: decode numeric HTML // entities (catches e.g. j -> 'j'), strip whitespace/control chars, // and lowercase. const normalized = decodeNumericEntities(trimmed) .replace(/[\x00-\x20]+/g, '') .toLowerCase(); // Strip any colons before matching the scheme prefix. This defeats // obfuscation that splices extra colons into the scheme name itself // (e.g. decoding : mid-word produces "java:script:alert(1)", which // would otherwise dodge a literal "^javascript:" check) while still // reliably catching the real "javascript:"/"vbscript:"/"data:text/html" // prefixes once their own colon is removed. const collapsed = normalized.replace(/:/g, ''); if (DANGEROUS_SCHEME_PREFIXES.some((prefix) => collapsed.startsWith(prefix))) { return ''; } return trimmed; }