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.

● HardFREEDesign problem·16 min read·Intermediate → Advanced
HTML sanitizerXSSmutation XSSmXSSDOMPurifyallow-list

01Understand the problem

✓What you will learn
  • 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
◎Interview radar

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 &lt; and keeps no markup at all. Use encoding whenever you can; you need a sanitiser only when users must author markup.

  1. sanitize(html) returns safe HTML: only allow-listed tags and attributes survive.
  2. Unknown elements are unwrapped (children kept); dangerous elements (script, style, iframe, svg, math, template ...) are dropped with their content.
  3. href allows http, https, mailto and relative URLs; src allows http, https and relative.
  4. Links get rel="nofollow ugc noopener".
  5. Output is stable: sanitising it again changes nothing.
Regex ideaPayload 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&#x09;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

The contractts
/** 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 }
iWhy work on a tree, not a 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&#x09;ascript: arrives as jav<TAB>ascript: and is checked in the form the browser will use.

03Allow-list walk, URL check and serialiser

Sanitiser corets
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/ /g, "&nbsp;");
const escAttr = (s: string) => escText(s).replace(/"/g, "&quot;");     // < 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;
}
Adapter from parse5ts
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));
}
▭On the whiteboard · Design choices4 points
01
allow-list
tags, attributes and URL schemes are listed; anything else is gone. No style, no class, no id (DOM clobbering)
02
drop vs unwrap
script, style, svg, math, template and friends drop with content; an unknown div or font unwraps
03
URLs
checked with the WHATWG URL parser after entity decoding, so tabs, newlines and case tricks are normalised first
04
serialise
escape &, <, > in text and attributes, quote every attribute, no raw text elements survive

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.

DefenceWhat it does
Drop svg, math and raw-text elements entirelyRemoves the contexts where re-parsing disagrees
Escape < and > inside attribute valuesA value cannot become a tag if the context flips
Stability check in testssanitize(sanitize(x)) must equal sanitize(x); a difference is a parser disagreement
Sanitise in the browser that rendersDOMPurify uses the browser's own parser, so there is no second parser to disagree with
Trusted Types and a strict CSPIf something slips through, inline script and eval do not run
!Write this to understand it, ship a vetted library

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.

vitest with a payload corpusts
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&#x09;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&quot;><script>\">t</p>", "<p title=\"a&quot;&gt;&lt;script&gt;\">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 target to 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.

“How to say it in the room

"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."

Test yourself

Why does the sanitiser check `href` with `new URL(value, base).protocol` rather than `value.toLowerCase().startsWith("javascript:")`?

Finished this one?
Tick it and it shows as done on the roadmap and the course.