Hi everyone,
I recently spent some time looking at browser parsing differences around malformed HTML, MathML and sanitizers. I was not specifically hunting for a DOMPurify issue when I started. I had a few small parser test cases, I was comparing how different browser engines handled them, and one Firefox result looked strange enough that I kept digging.
At first, it really did not look like much. The input that started the whole thing was roughly this:
<math><mtext><p><table>
That is it. Broken markup inside MathML.
Browsers recover from malformed markup all the time, and different engines occasionally build slightly different DOM trees. So my first reaction was basically: interesting parser difference, probably nothing more.
It turned out to be a bit more interesting than that.
After following the behavior through DOMPurify, Firefox, namespace handling and a few hundred fuzzing cases, I ended up with a situation where purify accepts an element as MathML, returns a sanitized string, and Firefox reparses that same element into the HTML namespace.
The interesting part is that DOMPurifyrejects that element if it sees it directly in the HTML namespace.
DOMPurify sees safe MathML
↓
accepts it
↓
returns a string
↓
Firefox parses the string again
↓
the element becomes HTML
What I did end up proving is a Firefox-specific namespace mutation that breaks an assumption DOMPurify makes about the namespace of the element it already sanitized.
Let us go through it from the beginning.
A quick note on DOMPurify, Gecko and MathML
If you already know these, feel free to skip this part.
DOMPurify is one of the most widely used HTML sanitization libraries in the web ecosystem. You give it untrusted markup and it removes unsafe or unwanted markup while preserving content that is considered safe. It supports HTML, SVG and MathML, and its normal API returns a sanitized string unless another return mode is requested.
Gecko is Mozilla’s web engine and is the engine behind Firefox. It includes the HTML parser, DOM implementation, layout, networking and other major parts of the browser. When I say “Firefox parser” in this post, I am mainly talking about the Gecko HTML tree builder that turns markup into a DOM.
MathML is the markup language browsers use for mathematical notation. It can live inside ordinary HTML, which means the parser sometimes has to cross boundaries between the HTML, SVG and MathML name spaces.
<math>
<mfrac>
<mi>a</mi>
<mi>b</mi>
</mfrac>
</math>
That namespace boundary is important here. A tag can keep the same local name while becoming a different kind of DOM object depending on which namespace the browser assigns to it.

The first strange Firefox result
I started by comparing the DOM produced from a small MathML parser primitive. The relevant structure was essentially:
<math>
<mtext>
<p>
<table></table>
</p>
<span id="marker">MARKER</span>
</mtext>
</math>
Chromium and WebKit behaved pretty much how I expected. The marker stayed under mtext.
Firefox did something different. The marker ended up outside the MathML subtree.
Chromium / Webkit
math
└── mtext
├── p
├── table
└── marker
Firefox
math
└── mtext
├── p
└── table
marker
Same sanitized markup. Different final tree. At this point I still was not calling it a security issue. A DOM difference is just a DOM difference until it changes something useful
Chromium:

WebKit:

Firefox:

So I tried to make the containment difference matter
The easiest thing to test was styling. Imagine something under mtext is intentionally hidden:
mtext {
visibility: hidden;
}
If the marker stays under mtext, it is hidden. If Firefox reparses it outside that subtree, it is no longer affected by that rule.
That worked. I tested similar cases with display:none, descendant selectors and focus behavior. The same parser difference could now change visibility, focusability, CSS ancestry and interaction.
That was more interesting, but it still depended on the surrounding application. So I built a very small application-style gadget.
document.addEventListener("click", event => {
const action = event.target.closest('[data-action="sensitive"]');
if (!action) return;
if (action.closest("mtext"))
return;
performSensitiveAction();
});
The application assumes anything inside mtext is quarantined. The sanitized tree says the element is inside mtext. Firefox reparses it outside. Now action.closest(“mtext”) returns null and the sensitive action runs.
That gave me a real ancestry-based gadget bypass, but I still wanted something that did not depend on application-specific JavaScript.


mphantom made it much more interesting
MathML has an element called mphantom. Its contents participate in layout, but they are visually hidden.
This was useful because it gave me a browser-native visibility boundary instead of something I invented with CSS. Even better, mphantom is accepted by DOMPurify’s default MathML configuration.
<math>
<mphantom>
<mtext>
<p><table></table></p>
<dialog open>TEST</dialog>
</mtext>
</mphantom>
</math>
DOMPurify kept the structure. No custom allow-list. No ADD_TAGS. No special hook.
In Chromium and WebKit, the content stayed under mphantom and remained hidden. Firefox moved the later content outside the MathML ancestry. The result became visible and interactive.
I also tested this with a full-screen dialog just to make the difference obvious. The control remained hidden. The Firefox version could cover the viewport.
That was the first time I had a completely native hidden-to-visible transition. It was useful evidence, but it still was not the strongest part of the bug. DOMPurify does not claim to sanitize CSS, and showing a dialog alone was not enough to establish the namespace-policy problem I was looking for.
So I kept looking.

Then mglyph changed namespace
This was the point where the research really changed direction.
I tried putting a MathML mglyph after the same parser primitive:
<math>
<mtext>
<p><table></table></p>
<mglyph id="marker"></mglyph>
</mtext>
</math>
When I inspected DOMPurify’s, DOM directly, I got:
tag: mglyph
namespace: MATHML
constructor: MathMLElement
parent: mtext
Chromium kept it that way. WebKit kept it that way. Firefox did not.
After taking DOMPurify’s sanitized string and inserting it again, Firefox gave me:
tag: mglyph
namespace: HTML
constructor: HTMLUnknownElement
parent: div
The tag name did not change. It was still called mglyph. But its namespace changed from MathML to HTML, and the DOM interface changed with it from MathMLElement to HTMLUnknownElement.
That was much more interesting than simply moving a node somewhere else. At this point the parser was not just changing ancestry. It was changing what kind of Web Platform object the element became.

Does DOMPurify actually care about the namespace?
This was the next question I had to answer. Maybe the namespace changed, but maybe DOMPurify did not consider that important.
The cleanest way to check was to give DOMPurify the final state directly.
<mglyph id="marker"></mglyph>
In ordinary HTML context Firefox parsed it as HTMLUnknownElement. DOMPurify removed it:
removed: 1
marker found: False
So yes, DOMPurify absolutely does care. It rejects an HTML-namespace mglyph.
Now compare that with the mutation path. DOMPurify sees the same local name as a MathML element and accepts it. Firefox then reparses the sanitized string and creates the HTML-namespace state that purify rejected when presented directly.
Direct path
HTML mglyph
↓
DOMPurify
↓
REJECTED
Round-trip path
MathML mglyph
↓
DOMPurify
↓
ACCEPTED
↓
serialize
↓
Firefox reparse
↓
HTML mglyph
That is the core issue. DOMPurify approves the element while it is in one namespace, but Firefox’s second parse moves it into a namespace DOMPurify would have rejected.



I wanted to rule out jsdom
A reasonable objection at this point would be: maybe DOMPurify is using jsdom, jsdom creates one tree, Firefox creates another tree, and that is all you found.
That would still be an interesting cross-parser mismatch, but it would be a different class of problem. So I removed jsdom from the important experiment.
I loaded DOMPurify directly inside Firefox Nightly and did the entire sanitize-and-reinsert cycle there:
const clean = purify.sanitize(dirty);
root.innerHTML = clean;
Same browser. Same Firefox process. DOMPurify sanitizes it. Firefox reparses the sanitizer’s own result.
The mutation still happened. DOMPurify reported removed: 0, but after reinsertion the marker became HTML / HTMLUnknownElement.
That was important because the issue could no longer be explained away as jsdom parser != Firefox parser. Firefox can sanitize the markup itself and then reinterpret the sanitized result differently when that result goes through the HTML parser again.

The cleanest experiment: string vs DOM return modes
Then I tested DOMPurify’s different return modes.
Normally purify.sanitize(dirty) returns a string. But DOMPurify can also return actual DOM nodes using RETURN_DOM or RETURN_DOM_FRAGMENT.
purify.sanitize(dirty, { RETURN_DOM: true });
purify.sanitize(dirty, { RETURN_DOM_FRAGMENT: true });
This gave me probably the cleanest result in the entire research.
With the default string result:
namespace: HTML
constructor: HTMLUnknownElement
With RETURN_DOM:
namespace: MATHML
constructor: MathMLElement
inside mtext: True
With RETURN_DOM_FRAGMENT:
namespace: MATHML
constructor: MathMLElement
inside mtext: True
So DOMPurify’s sanitized DOM itself looks fine. The mutation only appears when that DOM becomes text and Firefox parses the text again.
sanitized DOM
↓
serialization
↓
string
↓
Firefox HTML parser
↓
different DOM
That is the actual mutation boundary.




The namespace change also changes the API surface
Changing namespace is one thing. I wanted to know whether the resulting element could actually do anything it could not do before.
The MathML version gave me things like:
click = undefined
showPopover = undefined
togglePopover = undefined
After Firefox turned it into HTMLUnknownElement:
click = function
showPopover = function
togglePopover = function
The element had become a real HTMLElement. That meant the namespace transition had observable Web Platform consequences. It was not just a funny namespaceURI value.
Popover gave me a nice browser-native proof
The Popover API turned out to be a useful way to demonstrate the new interface without depending on application-specific JavaScript.
<mglyph id="marker" popover></mglyph>
<button popovertarget="marker">
OPEN
</button>
With the control tree, mglyph stayed MathML. It did not have showPopover(), and the button did not open it as a popover.
After the Firefox namespace mutation, the constructor became HTMLUnknownElement, showPopover became a function, and the exact same declarative button worked.
I also tested the newer commandfor / command=”toggle-popover” mechanism and that worked too. Firefox produced command, beforetoggle and toggle events after the transition.
This was useful because there was no application-specific JavaScript gadget involved anymore. The element had genuinely gained a native HTML capability.


At this point I started looking at Gecko
Once I knew the behavior was reliable, I wanted to understand why Firefox was doing it. This brought me into the HTML parsing rules around foreign content.
One concept that matters here is a MathML text integration point. The HTML Standard explicitly lists mtext as one of those integration points. These are places where the parser has special rules for crossing between MathML and ordinary HTML parsing behavior.
The foreign-content recovery rule for an end tag named p or br says, in essence, that the parser should pop elements until it reaches a MathML text integration point, an HTML integration point, or an element in the HTML namespace, and then reprocess the token using the normal HTML insertion-mode rules.
When I looked at Gecko’s current TreeBuilder.java, I found the p-end-tag recovery path used when no HTML p is found in button scope. In foreign content, that path contains a loop equivalent to:
while (currentPtr >= 0 &&
stack[currentPtr].ns != HTML_NAMESPACE) {
pop();
}
That immediately caught my attention because our stack contains MathML mtext, and mtext is itself an important MathML text integration point.
The Firefox behavior I was seeing is consistent with the parser continuing past that point until it reaches the HTML namespace.
I am intentionally saying consistent with here. Reading one loop does not formally prove the entire tree-construction path. But the standard’s integration-point rule, Gecko’s source and the DOM produced by Firefox line up closely enough that this is a strong root-cause candidate.
WHATWG: popping should stop at a MathML text integration point.

Gecko: this source path appears to pop foreign elements until reaching the HTML namespace.

I fuzzed the structure instead of trusting one payload
At this point I did not want to build an entire finding around a single hand-picked mglyph case. So I built a grammar fuzzer that generated combinations of MathML structures, SVG structures, parser primitives and marker tags.
The run covered 576 candidates.
Only six namespace/interface transitions survived, and all six were essentially the same thing: mglyph going from MathMLElement to HTMLUnknownElement.
I did not find a transition such as SVG image to HTMLImageElement or MathML node to HTMLAnchorElement. The result was Known-HTML-element transitions: 0.
So the behavior appears to be pretty narrow. That is an important limitation. I did not discover a generic “turn foreign markup into arbitrary HTML element” primitive. I found a very specific namespace transition around mglyph.

Then I fuzzed the new HTMLElement surface
I also tested a broader set of attributes and native behaviors on the escaped mglyph: ordinary URL-bearing attributes, form-related attributes, resource-related attributes, Popover, hidden, inert, draggable, tabindex and a few other HTMLElement features.
The final matrix covered 23 profiles.
Every one of them reproduced the namespace/interface transition. The capability summary was:
Firefox-only request gains: []
Firefox-only navigation gains: []
Firefox-only popover gains:
['POPOVER_TARGET', 'COMMAND_POPOVER']
Namespace/interface transitions: 23 / 23
So HTMLUnknownElement gaining HTMLElement did not suddenly turn it into an image, link or form control. Putting href or src on it did not create a Firefox-only navigation or request capability. Popover remained the cleanest new native behavior I found.


So what did I actually find?
At this point, the behavior was narrow enough that I could describe the core issue without overcomplicating it.
What I can demonstrate very cleanly is this: DOMPurify receives a MathML mglyph, sees a MathMLElement and allows it. purify then serializes that sanitized DOM into its normal string output. Firefox parses the string again, and the same mglyph becomes an HTMLUnknownElement.
But when I give DOMPurify an HTML-namespace mglyph directly, purify rejects it.
I approve this element because it is MathML. -> Firefox later gives the page the same local name in the HTML namespace.
That is the part I consider the actual bug: a Firefox-specific DOMPurify namespace-policy round-trip bypass primitive. The namespace change also produces real semantic differences because the node gains HTMLElement APIs and can participate in native features such as Popover.
There is also a pretty simple workaround
One nice thing came out of the return-mode experiments. The mutation depended on the serialize/reparse step.
const clean = purify.sanitize(dirty);
element.innerHTML = clean;
That path goes through the problematic round-trip in the tested Firefox environment.
But in my tests, returning DOM nodes directly preserved the MathML namespace:
purify.sanitize(dirty, {
RETURN_DOM: true
});
purify.sanitize(dirty, {
RETURN_DOM_FRAGMENT: true
});
So avoiding the string round-trip avoids this exact mutation in the environments I tested.
Similarly, if an application does not need MathML at all, restricting the sanitizer to the HTML profile removes the relevant foreign-content surface. I would still describe these as workarounds observed during testing, not as an official vendor fix.
How Mozilla responded
I reported this behavior to Mozilla after finishing the research. Mozilla did not consider it a standalone security finding. Their reasoning was that the security argument in the report depended on sanitized markup being serialized and parsed again. In their view, malformed HTML is not guaranteed to produce an identical DOM after a parse/serialize/reparse cycle, and sanitizers do not guarantee that their output will remain safe if it is later reparsed in a different parsing context.
So the parsing difference itself was not really the disputed part. Mozilla’s position was that the demonstrated security consequence relied on a reparsing step that sits outside the sanitizer’s security guarantee. For that reason, they did not treat the report, in the form I submitted it, as a new Firefox security vulnerability.
Final thoughts
What I found interesting about this research is how small each step looked by itself.
It started with:
<math><mtext><p><table
Then Firefox put something in a different place. Then that placement changed CSS and interaction. Then native MathML visibility could be escaped. Then mglyph changed namespace. Then DOMPurify turned out to reject that destination namespace when it saw it directly. Then the DOM-returning sanitizer modes stayed stable, but the normal string round-trip did not.
That final part is really the key for me. The sanitizer is not obviously producing a dangerous DOM. The sanitized DOM looks fine. The interesting state only appears later:
sanitize
↓
serialize
↓
reparse
And that is exactly why parser differentials around sanitizers are worth looking at.
But I do know that Firefox can turn a DOMPurify-approved MathML element into an HTML namespace state that DOMPurify itself rejects when presented directly.
And I think that is strange enough to be worth documenting.
If I manage to take it further, I will probably write a follow-up.
See you in the next article.
