# Mutation XSS via MathML: how a `<style>` carrier slipped past an HTML sanitizer

Every WYSIWYG editor ships an HTML sanitizer, and every sanitizer eventually meets **mutation XSS (mXSS)** — a payload that looks clean to the cleaner but turns dangerous *after* the browser re-parses it. This post dissects a real one we fixed in Jodit (**GHSA-rxcw-mc6f-6hr3**, CVSS 7.2, fixed in **4.12.28**), because the bug is a textbook lesson for anyone who sanitizes HTML.

Responsibly reported by **Younghun Ko of AhnLab** ([@koyokr](https://github.com/koyokr)).

## The one-sentence version

A dangerous element was hidden as **`<style>` rawtext inside MathML foreign content**, so the sanitizer — which walks *elements* — never saw it. A later serialize-and-reparse then **hoisted** that markup out of `<style>` into a live HTML node, resurrecting an `on*` event handler the cleaner had no chance to strip.

## Background: why mXSS exists

Most sanitizers do this:

```text
parse string -> walk the DOM, drop dangerous nodes/attrs -> serialize back to a string
```

The catch: **`element.innerHTML = serialize(parse(x))` is not guaranteed to equal `parse(x)`**. HTML parsing has foster-parenting, rawtext elements, and namespace (MathML/SVG) rules that can *move* nodes on the way back in. If a node that was harmless in parse #1 becomes harmful in parse #2, you have mXSS.

## The carrier

Here's the payload, trimmed to its skeleton:

```html
<math>
  <mtext>
    <table>
      <mglyph>
        <style><img src="x" onload="/* attacker code */"></style>
      </mglyph>
    </table>
  </mtext>
</math>
```

Three HTML parsing rules conspire here:

1. **`<mglyph>` is a MathML text-integration point** — content inside it is parsed as HTML.
2. **`<style>` is a rawtext element** — everything inside it is *text*, not markup. So `<img ... onload=...>` is parsed as a **text node**, never an element.
3. **`<table>` triggers foster-parenting** — misnested content gets relocated during parsing.

In the sanitizer's parse, the `<img>` is **not an element** — it's a string sitting inside `<style>`. An element-only walk (the kind almost every sanitizer uses) has nothing to clean.

## The mutation

The sanitized value is then assigned back to the editable element — a **second parse**. This time the tree is reshaped: the `<img>` is hoisted out of `<style>` and lands in the **HTML namespace as a live element**, handler intact.

```text
BEFORE  (sanitizer's parse — the <img> is <style> TEXT)
  math
    mtext
      mglyph        integration point -> contents parse as HTML
        style       text: "<img ... onload=...>"   <-- not an element
      table

AFTER  (editor.value re-parsed — the <img> is hoisted out, LIVE)
  p
    math
      mtext
        mglyph
          style     (now empty)
        img         <-- HTML namespace, real element -> its handler fires
        table
```

Now `editor.value` carries a live `<img onload=...>`. A consumer that renders that value as trusted HTML —

```javascript
// The classic sink: stored output rendered as HTML
previewElement.innerHTML = editor.value;
```

— executes the handler with **no user interaction**. Because `onload` (and `onfocus`, etc.) survive, this is a **stored XSS in the default configuration**. (Only `onerror` was stripped by a later pass — and even that fired within a short window.)

## Why the obvious fix is wrong

The tempting fix is "re-run the sanitizer on the serialized output." It doesn't hold: a carrier nested **one level deeper** survives a single re-sanitize, because each reparse can re-expose the next layer. You'd need an unbounded reparse loop, which is both slow and fragile.

## The fix: kill the smuggling at the source

Instead of chasing the mutation after it happens, remove the smuggled markup **before** the walk. The rule is namespace-based: any **HTML-namespace** element the parser placed inside `<math>`/`<svg>` **outside a legitimate integration point** has no business being there.

```typescript
const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';

// Where HTML is legitimately allowed inside MathML/SVG foreign content.
const HTML_INTEGRATION_POINTS = new Set([
    'foreignobject',
    'annotation-xml',
    'desc',
    'title'
]);

function isSmuggledForeignHtml(elm: Element): boolean {
    if (elm.namespaceURI !== HTML_NAMESPACE || elm.closest('math, svg') == null) {
        return false;
    }

    for (let p = elm.parentElement; p; p = p.parentElement) {
        const name = p.nodeName.toLowerCase();
        if (name === 'math' || name === 'svg') break;
        if (HTML_INTEGRATION_POINTS.has(name)) return false; // legit
    }

    return true; // HTML node smuggled into foreign content
}
```

And at the very start of the sanitize pass, drop them — `querySelectorAll` is recursive, so nested carriers fall in a **single pass**, no reparse loop:

```typescript
const foreign = box.querySelectorAll('math *, svg *');

for (let i = 0; i < foreign.length; i++) {
    if (foreign[i].isConnected && isSmuggledForeignHtml(foreign[i])) {
        foreign[i].remove();
    }
}
```

Legitimate MathML/SVG content — and HTML living under a real integration point like `<foreignObject>` — is preserved. Top-level `<style>`/`<script>` are untouched (they're handled by their own rules). The whole thing runs on both the value-set path and the on-change path.

## Lessons for any sanitizer

- **Element-only walks are blind to rawtext.** If your cleaner only inspects element nodes, anything parsed as `<style>`/`<xmp>`/`<noembed>` text is invisible to it.
- **Serialize → reparse can mutate the tree.** Never assume the string you cleaned reproduces the same DOM when re-inserted. MathML/SVG foreign content and foster-parenting are the usual movers.
- **Fix at the source, not the symptom.** Removing the carrier beats re-sanitizing output you can't trust to be stable.
- **Render untrusted editor output carefully.** Even with a good sanitizer, treat stored HTML as data; prefer well-audited rendering paths.

## If you use Jodit

Update to **4.12.28** (or later) — it's published to npm, and Jodit PRO picked it up automatically:

```bash
npm install jodit@latest
```

```javascript
import { Jodit } from 'jodit';

// Sanitization is on by default; nothing to configure for this fix.
const editor = Jodit.make('#editor', { height: 400 });
```

Full advisory: https://github.com/xdan/jodit/security/advisories/GHSA-rxcw-mc6f-6hr3 — and thanks again to Younghun Ko of AhnLab for the careful, coordinated report.

_Full page: https://xdsoft.net/blog/mutation-xss-mathml-style-carrier-sanitizer-bypass_
