Sanitize User-Submitted HTML with DOMPurify
Clean rich-text input on the server with a strict allow-list, keep hooks for URL schemes, and prove the result with XSS payload tests.
Task
Safely render user-submitted rich text by sanitizing server-side with an allow-list and verifying against real XSS payloads.
When to use this
The marketing team wants rich formatting in comments. The prototype used innerHTML with escaping, which broke formatting, so someone removed the escaping.
Prerequisites
- jsdom plus DOMPurify available in the server runtime
- A rich-text feature accepting HTML from users
- A corpus of XSS payloads for verification
Steps
1. Sanitize server-side with a strict configuration.
import { JSDOM } from "jsdom";
import createDOMPurify from "dompurify";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "li", "code", "pre"],
ALLOWED_ATTR: ["href", "title"],
});
Expected output: Everything outside the allow-list is stripped before storage, so clients render only known-safe markup.
2. Constrain URL schemes through a hook.
DOMPurify.addHook("uponSanitizeAttribute", (node, data) => {
if (data.attrName === "href" && !/^https?:/i.test(data.attrValue)) {
data.keepAttr = false;
}
});
Expected output: javascript:, data:, and vbscript: URLs are removed even though href itself is allowed.
3. Sanize at write time, not render time.
const body = sanitizeHtml(req.body.comment);
await comments.insert({ userId, body }); // stored clean
Expected output: Clean data enters storage once; every future reader and every export inherits the guarantee.
4. Verify with a payload corpus.
node -e 'const out = DOMPurify.sanitize("<img src=x onerror=alert(1)><a href=javascript:alert(1)>x</a>", opts); console.log(out)'Expected output: The output contains neither the onerror attribute nor the javascript URL; the text and allowed tags survive.
5. Add the payload corpus to CI as a regression gate.
const PAYLOADS = [
'<img src=x onerror="alert(1)">',
'<a href="javascript:alert(1)">click</a>',
'<svg onload=alert(1)>',
'<iframe srcdoc="<script>alert(1)</script>">',
];
PAYLOADS.forEach((p) => {
expect(DOMPurify.sanitize(p, opts)).not.toMatch(/onerror|javascript:|<script|<iframe/i);
});
Expected output: Any sanitizer regression, misconfiguration, or library downgrade fails the build with a named payload.
Troubleshooting
Problem
Formatting the editor produces is stripped
Solution
The allow-list is narrower than the editor output; widen it deliberately per tag rather than disabling sanitization, and render from the sanitized form only.
Problem
Sanitization is slow at high volume
Solution
Creating one JSDOM window per request is the cost; reuse a pooled window and benchmark, or sanitize on the write path where volume is lower.
Problem
MathML or SVG needed in content
Solution
SVG expands attack surface significantly; if required, allow a reviewed subset with SANITIZE_DOM and profiling, and test the mXSS corpus specifically.
Limitations
- Server-side sanitization protects storage and all clients; the inserting client should still render sanitized output to avoid double-trust.
- Sanitizers evolve with new bypasses; keep the library current and the payload corpus growing.
Allow-list the verbs, not the words
Escaping HTML destroys formatting; trusting it destroys the site. Sanitization is the third path: parse the markup, keep only tags and attributes from an explicit list, and drop everything else. DOMPurify with a tight allow-list on the server turns arbitrary user HTML into a small, predictable vocabulary, and the payload corpus in CI keeps the vocabulary honest as the library and the threats evolve.
const clean = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "li", "code", "pre"],
ALLOWED_ATTR: ["href", "title"],
});
// Hook: href must be http(s)
DOMPurify.addHook("uponSanitizeAttribute", (node, data) => {
if (data.attrName === "href" && !/^https?:/i.test(data.attrValue)) {
data.keepAttr = false;
}
});
Sanitize at the boundary
Clean the input once, where it enters your system, and store the clean form. Sanitizing at render time means every renderer must remember to do it, in every language it is written in, forever; sanitizing at write time means one enforcement point and every reader inherits the guarantee. The boundary is also the right place for schema hooks like the href rule.
The corpus is the contract
A sanitizer is a moving target: browsers add parsing quirks, libraries patch bypasses, and configuration drifts. The payload corpus in CI freezes the expectation: these specific attacks must produce these specific results. Any downgrade, misconfiguration, or clever allow-list widening that lets one payload through fails a build with a named case, before it fails a user.