iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…