Design an HTML sanitiser
Design the component that lets users post rich text without posting script: parse with a real HTML parser, walk the tree against an allow-list of tags and attributes, check URL attributes with the same URL parser browsers use, drop dangerous elements with their content, serialise with escaping, and understand mutation XSS well enough to know why you should ship a vetted library and test it with payload corpora.
01Understand the problem
- Explain why regex sanitising fails and parse, filter, serialise works
- Design an allow-list of tags and attributes and check URL schemes properly
- Understand mutation XSS (mXSS): safe-looking output that the browser re-parses into script
- Justify shipping DOMPurify or similar, and how to test any sanitiser with payload corpora
What they probe: block list or allow-list, how you handle javascript: with tabs and entities in it, whether you drop or unwrap <script>, what mXSS is, and whether you would write your own in production. Where candidates slip: regex replacement of <script>, checking href.startsWith("javascript:"), keeping style attributes, forgetting SVG and MathML.
A sanitiser takes untrusted HTML (comments, rich-text editor output, imported content) and returns HTML that cannot run script in a reader's browser while keeping harmless formatting. It is a different job from output encoding, which turns every < into < and keeps no markup at all. Use encoding whenever you can; you need a sanitiser only when users must author markup.
sanitize(html)returns safe HTML: only allow-listed tags and attributes survive.- Unknown elements are unwrapped (children kept); dangerous elements (
script,style,iframe,svg,math,template...) are dropped with their content. hrefallowshttp,https,mailtoand relative URLs;srcallowshttp,httpsand relative.- Links get
rel="nofollow ugc noopener". - Output is stable: sanitising it again changes nothing.
| Regex idea | Payload that beats it |
|---|---|
Remove <script>...</script> | <scr<script>ipt>alert(1)</script> reassembles after one pass |
Strip attributes starting with on | <svg/onload=alert(1)> has no space before the attribute |
Block href starting with javascript: | jav	ascript:alert(1) decodes to a tab, which URL parsing removes |
Allow <img> with a quoted src only | <img src=x onerror=alert(1)> unquoted |
| Lower-case match only | <ScRiPt>, or a null byte inside the tag name |
02Interfaces first
/** Parser-agnostic tree. Build it from parse5 on the server or DOMParser / a <template> in the browser. */
export type HNode =
| { kind: "text"; text: string } // already entity-decoded
| { kind: "el"; tag: string; attrs: [string, string][]; children: HNode[] }
| { kind: "drop" }; // comments, doctypes, foreign-namespace elements
export interface SanitizePolicy {
tags: ReadonlySet<string>; // kept as elements
dropWithContent: ReadonlySet<string>; // removed with everything inside
attrs: Readonly<Record<string, ReadonlySet<string>>>; // per tag; "*" applies to all kept tags
urlAttrs: Readonly<Record<string, ReadonlySet<string>>>; // attribute -> allowed schemes
maxDepth: number;
}
export interface Sanitizer { sanitize(html: string): string }Browsers parse HTML with a large, forgiving state machine. A sanitiser that works on text has to predict that state machine perfectly; one that works on the parser's tree only has to decide which nodes to keep. Every attribute value in the tree is already entity-decoded, so jav	ascript: arrives as jav<TAB>ascript: and is checked in the form the browser will use.
03Allow-list walk, URL check and serialiser
export const DEFAULT_POLICY: SanitizePolicy = {
tags: new Set(["p", "br", "b", "strong", "i", "em", "u", "s", "code", "pre", "blockquote", "ul", "ol", "li",
"a", "h2", "h3", "h4", "table", "thead", "tbody", "tr", "th", "td", "img", "span"]),
dropWithContent: new Set(["script", "style", "template", "noscript", "iframe", "object", "embed", "svg", "math",
"textarea", "title", "xmp", "noembed", "noframes", "plaintext", "select", "option"]),
attrs: {
"*": new Set(["title", "lang", "dir"]),
a: new Set(["href"]),
img: new Set(["src", "alt", "width", "height"]),
td: new Set(["colspan", "rowspan"]),
th: new Set(["colspan", "rowspan"]),
},
urlAttrs: { href: new Set(["http:", "https:", "mailto:"]), src: new Set(["http:", "https:"]) },
maxDepth: 100,
};
const VOID = new Set(["br", "img"]);
const NUMERIC = new Set(["width", "height", "colspan", "rowspan"]);
/** Resolve against a dummy base with the WHATWG parser, exactly as a browser would, and read the scheme it sees. */
export function safeUrl(value: string, schemes: ReadonlySet<string>): string | null {
let u: URL;
try { u = new URL(value.trim(), "https://relative.invalid/"); } catch { return null; }
return schemes.has(u.protocol) ? value.trim() : null;
}
function cleanAttrs(tag: string, attrs: readonly [string, string][], policy: SanitizePolicy): [string, string][] {
const out: [string, string][] = [];
const seen = new Set<string>();
for (const [raw, value] of attrs) {
const name = raw.toLowerCase();
if (seen.has(name)) continue; // first occurrence wins, as in the HTML parser
seen.add(name);
if (!policy.attrs["*"]?.has(name) && !policy.attrs[tag]?.has(name)) continue;
const schemes = policy.urlAttrs[name];
if (schemes) {
const safe = safeUrl(value, schemes);
if (safe !== null) out.push([name, safe]);
} else if (!NUMERIC.has(name) || /^\d{1,4}$/.test(value)) {
out.push([name, value]);
}
}
if (tag === "a") out.push(["rel", "nofollow ugc noopener"]);
return out;
}
export function sanitizeTree(nodes: readonly HNode[], policy: SanitizePolicy, depth = 0): HNode[] {
if (depth > policy.maxDepth) return []; // pathological nesting: drop rather than recurse forever
const out: HNode[] = [];
for (const n of nodes) {
if (n.kind === "drop") continue;
if (n.kind === "text") { out.push(n); continue; }
const tag = n.tag.toLowerCase();
if (policy.dropWithContent.has(tag)) continue;
const children = sanitizeTree(n.children, policy, depth + 1);
if (!policy.tags.has(tag)) { out.push(...children); continue; } // unknown but harmless: unwrap, keep the text
out.push({ kind: "el", tag, attrs: cleanAttrs(tag, n.attrs, policy), children: VOID.has(tag) ? [] : children });
}
return out;
}
const escText = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " ");
const escAttr = (s: string) => escText(s).replace(/"/g, """); // < and > escaped in attributes too: blunts mXSS
export function serialize(nodes: readonly HNode[]): string {
let html = "";
for (const n of nodes) {
if (n.kind === "text") html += escText(n.text);
else if (n.kind === "el") {
html += "<" + n.tag + n.attrs.map(([k, v]) => " " + k + "=\"" + escAttr(v) + "\"").join("") + ">";
if (!VOID.has(n.tag)) html += serialize(n.children) + "</" + n.tag + ">";
}
}
return html;
}import { parseFragment } from "parse5";
const HTML_NS = "http://www.w3.org/1999/xhtml";
interface P5 { nodeName: string; tagName?: string; namespaceURI?: string; attrs?: { name: string; value: string }[]; value?: string; childNodes?: P5[] }
function fromParse5(n: P5): HNode {
if (n.nodeName === "#text") return { kind: "text", text: n.value ?? "" };
if (!n.tagName || n.namespaceURI !== HTML_NS) return { kind: "drop" }; // comments, SVG and MathML subtrees
return { kind: "el", tag: n.tagName, attrs: (n.attrs ?? []).map((a) => [a.name, a.value]), children: (n.childNodes ?? []).map(fromParse5) };
}
export function sanitize(html: string, policy: SanitizePolicy = DEFAULT_POLICY): string {
const root = parseFragment(html) as unknown as P5; // same algorithm as browsers (WHATWG spec)
return serialize(sanitizeTree((root.childNodes ?? []).map(fromParse5), policy));
}04Mutation XSS, why to prefer a library, and testing
Mutation XSS (mXSS) happens when the sanitiser's view of the tree differs from the tree the browser builds when it parses the sanitiser's output. The markup passes as harmless, then mutates. Classic sources: foreign content (SVG and MathML switch parsing rules, so text in one context is a tag in another), elements whose content is raw text in one mode and markup in another (noscript, style, xmp), and the parser moving nodes around (<table> foster-parenting, auto-closed <p>). The 2019 DOMPurify bypass <svg></p><style><a id="</style><img src=1 onerror=alert(1)>"> worked through exactly this namespace confusion.
| Defence | What it does |
|---|---|
| Drop svg, math and raw-text elements entirely | Removes the contexts where re-parsing disagrees |
| Escape < and > inside attribute values | A value cannot become a tag if the context flips |
| Stability check in tests | sanitize(sanitize(x)) must equal sanitize(x); a difference is a parser disagreement |
| Sanitise in the browser that renders | DOMPurify uses the browser's own parser, so there is no second parser to disagree with |
| Trusted Types and a strict CSP | If something slips through, inline script and eval do not run |
DOMPurify (browser, or server with jsdom) and sanitize-html have years of bypass reports, fuzzing and fixes behind them. A home-grown sanitiser starts at zero. Keep the design in this lesson as your mental model, configure the library with the same allow-list, pin and update it, and run your own payload corpus against it in CI.
import { describe, it, expect } from "vitest";
const corpus: [string, string][] = [
["<script>alert(1)</script>hi", "hi"],
["<img src=x onerror=alert(1)>", "<img src=\"x\">"],
["<a href=\"jav	ascript:alert(1)\">x</a>", "<a rel=\"nofollow ugc noopener\">x</a>"],
["<a href=\" JAVASCRIPT:alert(1)\">x</a>", "<a rel=\"nofollow ugc noopener\">x</a>"],
["<svg><script>alert(1)</script></svg>ok", "ok"],
["<div onclick=\"x()\"><b>bold</b></div>", "<b>bold</b>"],
["<p title=\"a"><script>\">t</p>", "<p title=\"a"><script>\">t</p>"],
["<img src=\"data:image/svg+xml;base64,PHN2Zz4=\">", "<img>"],
];
describe("sanitize", () => {
it.each(corpus)("%s", (input, expected) => {
expect(sanitize(input)).toBe(expected);
});
it("is stable on its own output", () => {
for (const [input] of corpus) {
const once = sanitize(input);
expect(sanitize(once)).toBe(once);
}
});
});Grow the corpus from public sources (the PortSwigger XSS cheat sheet, the DOMPurify test suite, past CVEs) and from every bug report you receive. Run it against whichever library you ship, on every upgrade.
05Extensions, follow-ups, how to say it
- Store raw, sanitise on render with a versioned policy, or sanitise on write and re-run when the policy changes; either way record the policy version.
- Markdown is not safer by itself: most renderers pass raw HTML through. Disable raw HTML or sanitise the rendered output.
- Links: consider a redirect interstitial for external links and strip
targetto avoid tab-nabbing. - Images: proxy them through your own domain so readers' IPs and cookies do not leak to third parties.
Q1Why drop `<script>` with its content rather than unwrap it?+
Unwrapping keeps the script text as visible text, which is ugly, and for raw-text elements like style and noscript the content may be markup in another parsing mode. Dropping the subtree removes the ambiguity.
Q2Why not allow the `style` attribute?+
CSS can exfiltrate data with attribute selectors and background URLs, overlay fake UI, and historically ran script through expression(). Map the few styles you need to classes instead.
Q3Why not allow `id` and `name`?+
DOM clobbering: an element with id="config" becomes window.config and can shadow globals the page's script trusts.
"I never sanitise with regex. I parse with an HTML5-compliant parser, walk the tree against an allow-list of tags and attributes, drop script, style, SVG, MathML and template with their contents, unwrap unknown harmless tags, and check href and src with the WHATWG URL parser after entity decoding so tab and case tricks are normalised first. The serialiser quotes everything and escapes angle brackets even in attributes, and tests assert both a payload corpus and idempotence, which catches mutation XSS. In production I ship DOMPurify with this allow-list, plus Trusted Types and a strict CSP as the backstop."
Why does the sanitiser check `href` with `new URL(value, base).protocol` rather than `value.toLowerCase().startsWith("javascript:")`?