Keep It Clean

Chapter 7
Sanitizing HTML & Input Validation
DOMPurify, allowlists, and where validation fits (and doesn't)

Output encoding (Chapter 6) handles the common case — data shown as text. But sometimes the value is meant to be HTML: a rich-text comment, a CMS article, a formatted message. You can't encode it (that would show the tags as text) and you can't trust it (it may contain a payload). The answer is HTML sanitization: parse the HTML and keep only a known-safe allowlist. This chapter also pins down where input validation genuinely helps — and where it's mistaken for an XSS defence.

When You Actually Need Sanitization

Sanitization is the exception, not the default. You need it only when all three are true: the input is user-provided, it must render as HTML (not text), and you therefore can't simply encode it. If any is false, prefer encoding (Chapter 6) — or avoid raw HTML entirely by accepting Markdown and rendering it through a safe renderer.

First ask: do you really need to accept HTML at all?
The safest rich text isn't sanitized HTML — it's a restricted format you control. Markdown (rendered with a safe configuration that disables raw HTML), or a structured editor that emits a constrained JSON document, sidesteps the entire "parse attacker HTML correctly" problem. Reach for HTML sanitization only when you must accept and store actual HTML. Every HTML-accepting feature is ongoing risk surface; minimize it.

Allowlist, Never Blocklist

A sanitizer works by parsing the input into a DOM and keeping only explicitly permitted elements and attributes, dropping everything else. This is the positive model from Chapter 4 — define what's safe, discard the rest — the opposite of trying to strip "bad" tags:

ApproachRuleOutcome
Allowlist (correct)keep only b, i, p, a[href], ul, li…unknown/dangerous tags & attrs dropped by default
Blocklist (broken)remove <script>, onerror…endless bypasses (Chapter 4) — fails

The Tool: DOMPurify

Don't write an HTML sanitizer — sanitizing HTML correctly is brutally hard (browser parsing quirks, mutation, mXSS), and hand-rolled ones are reliably bypassed. The industry-standard library is DOMPurify:

// default: a sensible safe allowlist, strips scripts/handlers/dangerous URLs const clean = DOMPurify.sanitize(dirtyHtml); // restrict further to just the tags/attrs your feature needs const clean = DOMPurify.sanitize(dirtyHtml, { ALLOWED_TAGS: ["b", "i", "a", "p", "ul", "li"], ALLOWED_ATTR: ["href"], }); element.innerHTML = clean; // now safe to insert
Use a maintained, battle-tested sanitizer — and keep it updated
HTML sanitization is an arms race against mutation XSS (mXSS) — payloads that are inert when parsed but become dangerous after the browser re-parses the sanitized output (e.g. via namespace confusion in <svg>/<math>). DOMPurify is maintained specifically to track these and patch them. A hand-rolled regex "sanitizer" will be bypassed; an outdated library may miss new mXSS classes. Rules: use DOMPurify (or your platform's equivalent), keep it current, sanitize with the narrowest allowlist your feature needs, and sanitize as close to output as possible.

Server-Side vs Client-Side Sanitization

DOMPurify runs in the browser and on the server (via jsdom). Where you sanitize depends on where the HTML is inserted:

  • DOM-based / SPA — sanitize in the client right before innerHTML (the DOM-XSS fix from Chapter 2; encoding can't help there).
  • Server-rendered — sanitize on the server before storing or emitting the HTML.
  • Defence in depth — sanitizing on store and escaping/sanitizing on render is reasonable for high-risk HTML.

Where Input Validation Fits (and Where It Doesn't)

Input validation is good practice — but it is not the XSS defence, and treating it as one is a classic mistake.

Input validationOutput encoding / sanitization
Goalreject malformed/unexpected data (an email looks like an email)make data safe for its output context
XSS rolesecondary — reduces bad data, defence in depthprimary — actually prevents execution
Why not sufficientmany fields legitimately allow < > " ' (names, comments, code snippets)neutralizes the payload regardless of input
"We validate input" is not an XSS defence
Validation helps where a field has a strict, narrow format — a date, a UUID, a number, an enum, an email — where you can reject anything that doesn't match (allowlist validation). But you cannot validate your way out of XSS for free-text fields: a comment, a name (O'Brien <the boss>), a bug report containing code, or a message — these legitimately contain < > " ', so you can't reject those characters without breaking the feature. The payload survives validation and must be neutralized at output. Validate for data quality; encode/sanitize for safety. Never substitute the former for the latter.

The Decision Flow

  1. Does the value need to render as HTML? If noencode (Chapter 6). Done.
  2. If yes → can you use Markdown/a structured format instead of raw HTML? If yes, prefer that (rendered safely).
  3. If you must accept HTML → sanitize with DOMPurify, narrowest allowlist, kept updated.
  4. Independently → validate structured fields for data quality, and layer CSP (Chapter 8) underneath everything.

Hands-On Exercises

Exercise 1

Use DOMPurify to sanitize a rich-text comment that should allow <b>, <i>, and <a href> but nothing else. Show what happens to a payload like <b>hi</b><img src=x onerror=alert(1)>, and explain why allowlisting (not blocklisting) is what makes it safe.

📄 View solution
Exercise 2

Explain why input validation cannot be the primary XSS defence, using three free-text fields that legitimately contain HTML-special characters. Then give two fields where allowlist validation genuinely does help, and say why the difference is the field's format.

📄 View solution
Exercise 3

Walk the decision flow for three features: (a) a username shown on a profile; (b) a blog post body with rich formatting; (c) an account-balance number. State whether each needs encoding, sanitization, or validation (or a mix), and justify by whether the value is text, markup, or a strict format.

📄 View solution

Chapter 7 Quick Reference

  • Sanitization = parse HTML, keep an allowlist of safe tags/attrs, drop the rest — for when the value must render as HTML
  • It's the exception, not the default — encode (Ch. 6) unless you genuinely need to render user HTML
  • Prefer a restricted format you control (Markdown rendered safely) over accepting raw HTML when possible
  • Allowlist, never blocklist — define what's safe; stripping "bad" tags fails (Chapter 4)
  • Use DOMPurify (server via jsdom or client) — never hand-roll; keep it updated for mutation XSS (mXSS)
  • Sanitize where HTML is inserted: client before innerHTML (DOM XSS), or server before store/emit
  • Input validation is NOT the XSS defence — free-text fields legitimately contain < > " '; it's data-quality + defence in depth
  • Allowlist validation helps for strict formats (date, UUID, number, enum, email) — reject anything off-format
  • Next chapter: Content Security Policy (CSP) — a browser-enforced safety net layered under encoding/sanitization