|
| 1 | +/** |
| 2 | + * Stricter, dependency-free HTML sanitizer for blocks that render CMS-provided |
| 3 | + * markup in a table-like context (see Table.tsx). It is intentionally separate |
| 4 | + * from the shared `sanitizeHtml` so hardening this path cannot regress the many |
| 5 | + * other blocks that rely on the lighter sanitizer. |
| 6 | + * |
| 7 | + * On top of the shared sanitizer's guarantees it also: |
| 8 | + * - Removes dangerous elements together with their content (script, style, |
| 9 | + * iframe, object, embed, applet, form, svg, math, template, noscript, base, |
| 10 | + * link, meta, frame, frameset), plus any leftover open/close/self-closing tags |
| 11 | + * - Strips inline event-handler attributes (on*), srcdoc and inline style |
| 12 | + * - Neutralizes javascript:, data: and vbscript: protocols in url-bearing |
| 13 | + * attributes (href, src, action, formaction, xlink:href), decoding entities |
| 14 | + * and stripping whitespace/control chars first so obfuscated schemes are |
| 15 | + * caught, while harmless values like "data-*" or "javascriptX" are preserved |
| 16 | + * |
| 17 | + * Note: this is a pragmatic denylist — not a full HTML parser. Keep the content |
| 18 | + * model simple (text + basic inline/formatting markup). |
| 19 | + */ |
| 20 | +const DANGEROUS_ELEMENTS = [ |
| 21 | + "script", |
| 22 | + "style", |
| 23 | + "iframe", |
| 24 | + "object", |
| 25 | + "embed", |
| 26 | + "applet", |
| 27 | + "form", |
| 28 | + "svg", |
| 29 | + "math", |
| 30 | + "template", |
| 31 | + "noscript", |
| 32 | + "base", |
| 33 | + "link", |
| 34 | + "meta", |
| 35 | + "frame", |
| 36 | + "frameset", |
| 37 | +]; |
| 38 | + |
| 39 | +const URL_ATTRS = "href|src|action|formaction|xlink:href"; |
| 40 | +const DANGEROUS_PROTOCOLS = "javascript|data|vbscript"; |
| 41 | + |
| 42 | +// Captures a url-bearing attribute and its value (double/single-quoted or bare). |
| 43 | +const URL_ATTR_RE = new RegExp( |
| 44 | + `\\b(${URL_ATTRS})\\s*=\\s*("[^"]*"|'[^']*'|[^\\s>]+)`, |
| 45 | + "gi", |
| 46 | +); |
| 47 | +// A dangerous scheme must be a real scheme: name immediately followed by ":". |
| 48 | +const DANGEROUS_SCHEME_RE = new RegExp(`^(?:${DANGEROUS_PROTOCOLS}):`, "i"); |
| 49 | + |
| 50 | +function toCodePoint(n: number): string { |
| 51 | + return Number.isFinite(n) && n >= 0 && n <= 0x10ffff |
| 52 | + ? String.fromCodePoint(n) |
| 53 | + : ""; |
| 54 | +} |
| 55 | + |
| 56 | +/** Decode the HTML entities most commonly used to smuggle a scheme past a filter. */ |
| 57 | +function decodeEntities(value: string): string { |
| 58 | + return value |
| 59 | + .replace(/&#x([0-9a-f]+);?/gi, (_, hex) => toCodePoint(parseInt(hex, 16))) |
| 60 | + .replace(/&#(\d+);?/g, (_, dec) => toCodePoint(parseInt(dec, 10))) |
| 61 | + .replace(/:/gi, ":") |
| 62 | + .replace(/&tab;/gi, "\t") |
| 63 | + .replace(/&newline;/gi, "\n"); |
| 64 | +} |
| 65 | + |
| 66 | +/** |
| 67 | + * True when an attribute value resolves to a dangerous URL scheme. The value is |
| 68 | + * decoded and stripped of whitespace/control chars first, since browsers ignore |
| 69 | + * those within a scheme (e.g. `java\tscript:` and `javascript:`). |
| 70 | + */ |
| 71 | +function hasDangerousScheme(value: string): boolean { |
| 72 | + const normalized = decodeEntities(value) |
| 73 | + // Control chars are intentional: browsers strip C0 controls/whitespace from |
| 74 | + // a URL scheme, so an attacker can hide one inside `javascript:`. |
| 75 | + // deno-lint-ignore no-control-regex |
| 76 | + .replace(/[\s\u0000-\u001f]+/g, "") |
| 77 | + .toLowerCase(); |
| 78 | + return DANGEROUS_SCHEME_RE.test(normalized); |
| 79 | +} |
| 80 | + |
| 81 | +export function hardSanitize(raw: string | null | undefined): string { |
| 82 | + if (!raw) return ""; |
| 83 | + |
| 84 | + let html = raw; |
| 85 | + |
| 86 | + for (const tag of DANGEROUS_ELEMENTS) { |
| 87 | + // Remove the element with its content, then any stray open/close/self-closing tag. |
| 88 | + html = html |
| 89 | + .replace(new RegExp(`<${tag}\\b[\\s\\S]*?<\\/${tag}\\s*>`, "gi"), "") |
| 90 | + .replace(new RegExp(`<\\/?${tag}\\b[^>]*>`, "gi"), ""); |
| 91 | + } |
| 92 | + |
| 93 | + return html |
| 94 | + // Inline event handlers (onclick, onerror, …) |
| 95 | + .replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "") |
| 96 | + // srcdoc (smuggles an inline document into iframes) and inline styles |
| 97 | + .replace(/\s+(srcdoc|style)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "") |
| 98 | + // Neutralize dangerous protocols in url-bearing attributes. One pass handles |
| 99 | + // quoted and unquoted values identically: decode/normalize, then require a |
| 100 | + // real dangerous scheme followed by ":" (so harmless "data-*"/"javascriptX" |
| 101 | + // values are left intact). |
| 102 | + .replace(URL_ATTR_RE, (match, attr, value) => { |
| 103 | + const quote = value[0] === '"' || value[0] === "'" ? value[0] : ""; |
| 104 | + const inner = quote ? value.slice(1, -1) : value; |
| 105 | + if (!hasDangerousScheme(inner)) return match; |
| 106 | + const q = quote || '"'; |
| 107 | + return `${attr}=${q}#${q}`; |
| 108 | + }); |
| 109 | +} |
0 commit comments