Mutation XSS
Mutation XSS (mXSS) happens when the browser’s HTML parser reinterprets a sanitised string into something dangerous — usually because the surrounding context (a comment, an SVG, a template) changes parsing rules. The fix is to never round-trip user content through innerHTML and to use a sanitiser that understands the parser.
Defensive sanitisation + DOMPurify
EXAMPLE
// SCENARIO — a CMS-style editor that accepts limited HTML.
// We focus on defenses; no weaponised payloads.
// ─── THE TRAP — sanitise then read innerHTML ───────────────────
// Suppose your sanitiser allows <p>, <a href> and strips everything else.
// You sanitise the user input, then store the SERIALISED form back.
function naiveSanitize(html) {
const div = document.createElement('div');
div.innerHTML = html;
div.querySelectorAll('script, style, iframe').forEach((el) => el.remove());
// remove on* attributes
div.querySelectorAll('*').forEach((el) => {
for (const a of [...el.attributes]) {
if (a.name.startsWith('on')) el.removeAttribute(a.name);
}
});
return div.innerHTML; // ❌ trap — reading innerHTML can re-serialise differently
}
// The bug: when the browser parses, then serialises, then RE-PARSES the result,
// minor differences in how nodes are emitted (entity decoding, attribute quoting,
// SVG mode, template tags) can produce HTML that means something different second
// time around — i.e. the sanitiser saw safe DOM but the FINAL render is dangerous.
//
// You can avoid the whole class by using a sanitiser designed for this:
// ─── FIX 1 — DOMPurify (recommended) ───────────────────────────
// npm install dompurify
import DOMPurify from 'dompurify';
function safeRender(target, dirty) {
target.innerHTML = DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'title', 'rel', 'target'],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
FORBID_TAGS: ['style', 'svg', 'math', 'iframe', 'object', 'embed'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'srcdoc'],
SAFE_FOR_TEMPLATES: true, // refuse mustache-style template syntax in attrs
RETURN_TRUSTED_TYPE: true, // works with Trusted Types policy
});
}
// DOMPurify runs the input through the browser parser in a sandboxed DOM, walks it
// with strict allowlists, and is hardened against mXSS — re-parsing the output produces
// the same tree it sanitised.
// ─── FIX 2 — Trusted Types ─────────────────────────────────────
// CSP policy
// Content-Security-Policy: require-trusted-types-for 'script';
// trusted-types app-policy;
// One central policy mints the only TrustedHTML allowed for innerHTML.
const sanitizer = trustedTypes.createPolicy('app-policy', {
createHTML: (input) => DOMPurify.sanitize(input, { RETURN_TRUSTED_TYPE: true }).toString(),
});
function setContent(el, html) {
el.innerHTML = sanitizer.createHTML(html);
// Any innerHTML assignment of a plain string is rejected by the browser when require-trusted-types-for is set.
}
// With Trusted Types, a future regression that assigns a raw string to innerHTML throws — the bug surfaces as a CSP violation, not silently as XSS.
// ─── FIX 3 — Render plain text, not HTML ───────────────────────
// If the content is just text (a comment, a chat message), DON'T accept HTML at all.
function renderComment(el, text) {
el.textContent = text; // browser escapes everything
}
// For markdown, use a renderer that produces an AST then a sanitised DOM, e.g.:
// • marked + DOMPurify
// • remark + rehype + rehype-sanitize
// Disable raw HTML pass-through.
// ─── FIX 4 — Stop the renderer from being recursive ─────────────
// If you sanitise, store, then serve the SAME content elsewhere with a different
// renderer (e.g. one place adds it to innerHTML, another uses srcdoc), the mXSS
// risk is the second renderer, not the first. Decide ONE place where HTML becomes
// DOM, and pass plain text or pre-rendered Trusted HTML everywhere else.
// ─── DETECTION ─────────────────────────────────────────────────
// Watch for round-trips through innerHTML in code review:
const el = document.createElement('div');
el.innerHTML = sanitize(input);
const safe = el.innerHTML; // ❌ red flag — re-serialisation
// Browser-side telemetry — Trusted Types violations
document.addEventListener('securitypolicyviolation', (e) => {
if (e.violatedDirective.startsWith('trusted-types')) {
navigator.sendBeacon('/csp-report', JSON.stringify({
sample: e.sample,
source: e.sourceFile,
line: e.lineNumber,
}));
}
});
// ─── REGRESSION TESTS ──────────────────────────────────────────
// Snapshot how your sanitiser handles a set of known mXSS payloads.
// DOMPurify ships a test suite of them; mirror that into your own CI.
import { test, expect } from 'vitest';
import DOMPurify from 'dompurify';
const PAYLOADS = [
'<!-- <img src=x onerror=alert(1)> -->',
'<svg><math href="foo"><a><![CDATA[</a><img src=x>]]></a>',
'<template><div>x</div></template>',
];
for (const dirty of PAYLOADS) {
test(`sanitiser handles ${dirty.slice(0, 40)}`, () => {
const safe = DOMPurify.sanitize(dirty);
const reparsed = new DOMParser().parseFromString(safe, 'text/html').body.innerHTML;
expect(reparsed).toBe(safe); // round-trip stable → no mutation surprise
expect(safe.toLowerCase()).not.toContain('onerror');
expect(safe.toLowerCase()).not.toContain('<script');
});
}
// ─── CHECKLIST ─────────────────────────────────────────────────
// 1. Use a sanitiser (DOMPurify) instead of hand-rolled regex stripping
// 2. Deploy Trusted Types so any innerHTML escape route fails closed
// 3. Render TEXT when text is enough
// 4. Never round-trip user content through innerHTML to re-store it
// 5. Pair sanitisation with strict CSP — script-src 'self' 'nonce-…'
// 6. Snapshot tests for known mXSS payload classes
// 7. Block <svg>, <math>, <template> and <iframe srcdoc> unless explicitly needed
Why it matters
Mutation XSS is what happens when sanitised HTML round-trips through a parser and means something different the second time. Don’t hand-roll the sanitiser — DOMPurify is built specifically against mXSS — and pair it with Trusted Types so any future innerHTML regression fails closed.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Mutation XSS (mXSS): seemingly-safe HTML mutates when the browser parses it. // e.g. <noscript> + entity tricks cause sanitisers to miss payloads. // Defence: use a maintained sanitiser (DOMPurify) configured for your CMS shape.Try it Yourself »
Discussion
Loading…