HTML Sanitization
html sanitization html sanitization applies owasp allowlist policies via maintained libraries like html sanitization allowlists safe tags and attributes when
Introduction
HTML sanitization allowlists safe tags and attributes when user or CMS content must render as HTML — WYSIWYG articles, comments with formatting, email previews. OWASP recommends established libraries (DOMPurify) over regex strip_tags; staff engineers define allowlist policy per content type and validate in CI.
Business problem
Rich text CMS fields are stored XSS reservoirs — authors paste from Word with embedded scripts; users upload SVG with onload. Regex sanitizers miss nested parser tricks (mXSS). One bypass compromises every reader.
- Policy drift: Marketing adds iframe allow for embeds — opens XSS if src unchecked.
- Server vs client: Sanitize server-side at save and at render — client-only bypassable.
Why this feature exists
Browsers parse HTML error-tolerantly — attacker strings exploit parser differential between sanitizer and browser. Allowlist sanitizers walk DOM and strip disallowed nodes/attrs — safer than denylist regex.
- DOMPurify: Industry standard browser + Node — maintained mXSS fixes.
- Trusted Types: Sanitizer returns TrustedHTML in supported browsers.
Browser internals
Parser differential attacks — sanitizer outputs HTML browser re-parses differently creating script. Modern DOMPurify hooks address mXSS; keep library updated. SVG and MathML namespaces high risk.
- ALLOWED_TAGS config: Explicit list — default deny.
- ALLOWED_ATTR: Block event handlers, formaction, xlink:href javascript.
Rendering workflow
Sanitize pipeline: Input → server DOMPurify with profile → store clean HTML → render with CSP → optional client re-sanitize defense in depth → monitor CSP violations for bypass attempts.
- Profiles: USE_PROFILES html vs svg vs math — separate content types.
- URL attrs: ALLOWED_URI_REGEXP http/https/mailto only.
Feature deep dive
DOMPurify allowlist example for comment HTML:
- Tags: p, br, strong, em, a, ul, ol, li, code — no script, style, iframe.
- Attrs on a: href, title — rel=noopener noreferrer added by hook.
- Forbidden: on*, style, srcdoc, data-* unless explicitly needed.
import DOMPurify from "isomorphic-dompurify";const clean = DOMPurify.sanitize(dirtyHtml, {ALLOWED_TAGS: ["p", "br", "strong", "em", "a", "ul", "ol", "li", "code"],ALLOWED_ATTR: ["href", "title"],ALLOW_DATA_ATTR: false,ADD_ATTR: ["target"], // then hook sets rel=noopener});DOMPurify.addHook("afterSanitizeAttributes", (node) => {if (node.tagName === "A") {node.setAttribute("rel", "noopener noreferrer");const href = node.getAttribute("href") || "";if (!/^https?:\/\//i.test(href)) node.removeAttribute("href");}});
Accessibility analysis
Sanitizer must preserve semantic tags allowed in policy — stripping headings hurts structure. Don't allow style attr — breaks contrast; use class allowlist tied to design system accessible styles.
- Alt on img: If allowing img in rich text, require alt attribute in CMS validation.
SEO impact
User-generated HTML with spam links hurts site reputation — sanitize href domains or add rel=ugc nofollow on UGC links per Google link spam policies.
- rel=ugc: Sanitizer hook adds to user comment links.
Security considerations
OWASP XSS Prevention — sanitization is last line when HTML required. Never use regex alone. Pen-test sanitizer config changes. CSP still required — sanitizer bypass history exists.
- PDF/SVG upload: Separate pipeline — not same allowlist as comments.
Performance impact
Sanitize at save time once — not every page view. Large documents — stream or chunk; cache sanitized output keyed by content hash.
Real production example
CMS publish hook — reject if sanitizer strips >30% content length (signals attack or paste bomb):
- Version: Pin DOMPurify version; dependabot alerts for security patches.
function publishArticle(rawHtml) {const clean = sanitizeArticle(rawHtml);if (clean.length < rawHtml.length * 0.7) {throw new ValidationError("Content blocked — disallowed HTML detected");}return db.save({ html: clean });}
Enterprise usage
Legal CMS — two profiles: strict (internal docs) vs moderate (customer KB with links/images). Security approves allowlist changes via PR to config repo.
Common production failures
Custom regex sanitizer bypassed with
Architecture review questions
- Sanitizer allowlist defined per content type — not global permissive?
- Server-side sanitize on save AND render?
- DOMPurify (or equivalent) version current with CVE monitoring?
- href/src validated against URL scheme allowlist?
- CSP still enforced on pages rendering sanitized HTML?
Hands-on project
Project: Configure DOMPurify for comment allowlist; write tests with OWASP XSS payload list; verify blocked payloads don't execute in browser fixture.
Interview questions
Why regex HTML sanitization fails?(Advanced)
HTML parser is error-tolerant — nested tags, attribute order mutations, SVG/MathML, encoding tricks bypass regex. mXSS: sanitizer output re-parsed differently by browser. Allowlist DOM walkers (DOMPurify) address parser-aware stripping.
Follow-up: Allow style tag ever?
Server vs client sanitization?(Advanced)
Server mandatory — client can be bypassed by direct API POST. Client re-sanitize optional defense in depth for DOM sinks. Always store sanitized server-side; never trust client-cleaned HTML in DB.
Follow-up: Markdown to HTML pipeline risks?
Design allowlist change process for CMS team requesting iframe embeds.(Advanced)
Security review: src domain allowlist, sandbox attribute required, separate content profile, pen-test embed breakout, CSP frame-src update, document in ADR, time-bound approval with audit of embedded URLs quarterly.
Follow-up: Trusted Types integration?
Try it yourself
Edit the HTML, CSS, or JS panels — the preview updates as you type.
Try it yourself
Summary
HTML sanitization applies OWASP allowlist policies via maintained libraries like DOMPurify — staff teams define per-content-type tag/attribute profiles, sanitize server-side, and pair with CSP for defense in depth on CMS and user-generated markup.