Notes · Dissecting Real Systems
growing
Two Parsers Walk Into a DOM
Every HTML sanitizer is a second implementation of a parser it does not control. Mutation XSS is what happens in the gap — and it is why the fix eventually had to move inside the browser.
mXSS vectors bypassed widely deployed server-side XSS protection techniques (like HTML Purifier, kses, htmlLawed, Blueprint and Google Caja), client-side filters (XSS Auditor, IE XSS Filter), Web Application Firewall (WAF) systems, as well as Intrusion Detection and Intrusion Prevention Systems (IDS/IPS).
— Mario Heiderich, Jörg Schwenk, Tilman Frosch, Jonas Magazinius, Edward Z. Yang, mXSS Attacks: Attacking well-secured Web-Applications by using innerHTML Mutations (CCS 2013)
Cite this
Mangalapilly, Y. J. (2026, August). Two Parsers Walk Into a DOM. Saṃhitā Notes. https://yesudeep.com/blog/two-parsers-walk-into-a-dom/ @online{mangalapilly2026two,
author = {Yesudeep Jose Mangalapilly},
title = {Two Parsers Walk Into a DOM},
journal = {Sa\d{m}hit\=a Notes},
year = {2026},
month = {August},
url = {https://yesudeep.com/blog/two-parsers-walk-into-a-dom/},
urldate = {2026-08-12},
} Yesudeep Jose Mangalapilly. “Two Parsers Walk Into a DOM.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/two-parsers-walk-into-a-dom/. TY - ELEC
AU - Mangalapilly, Yesudeep Jose
TI - Two Parsers Walk Into a DOM
T2 - Saṃhitā Notes
PY - 2026
UR - https://yesudeep.com/blog/two-parsers-walk-into-a-dom/
Y2 - 2026-08-12
ER - Why the obvious fix for XSS has a structural flaw, and what replaced it. The sanitizer article in the web security series examines the mechanism everyone reaches for first. By the end you'll know what a sanitizer actually has to do, why mutation XSS is a property of the architecture rather than a bug in any particular filter, why SVG is the worst case, and what changes now that HTML sanitization is specified in the HTML Standard — including the two ways the browser's own implementation was bypassed within weeks of shipping.
Start with the thing that is supposed to work.
You accept rich text from users — comments, a CMS field, a pasted email. You want to render it as markup, because that is the point, so escaping everything is not an option. The standard answer is a sanitizer: hand it the untrusted string, get back a string with the dangerous constructs removed, assign that to innerHTML.
XSS — cross-site scripting: getting script of your choosing to execute in the security context of someone else's origin, which means it inherits their cookies, their storage, and their permissions. MDN: Cross-site scripting.
DOMPurify is the state of that practice, and it is genuinely good software — 17,280 stars, actively maintained, and used approximately everywhere. Its own README describes it plainly:
DOMPurify is a DOM-only, super-fast, uber-tolerant XSS sanitizer for HTML, MathML and SVG.
Note "DOM-only." DOMPurify does not implement its own HTML grammar. It asks the browser to parse the string — into an inert document, so nothing executes — then walks the resulting tree and removes what is not allowed. That is the smartest available design, and it is worth understanding why it is the smartest, because the reason is the subject of this article: using the browser's parser is how you avoid being a second implementation of it.
It gets you most of the way. It does not get you all the way, and the residual gap is not a bug anyone can fix.
What a sanitizer must actually decide
The naive mental model is a filter over characters. Strip <script>, strip onerror, escape the angle brackets you do not recognize.
That model fails on the first realistic input, because whether a given attribute is dangerous depends on which element carries it. href is inert on a <div> and executable on an <a> if its value begins with javascript:. value is inert nearly everywhere and interesting on a SMIL <animate>. Whether <annotation-xml> introduces HTML or MathML parsing depends on an encoding attribute. You cannot answer any of these questions without a tree, and you cannot get a tree without parsing.
So every sanitizer parses. The only question is whose parser it uses.
A sanitizer is not a filter over text. It is a decision about what a document means, made by one implementation, and then acted on by another.
The serialization step in the middle is the whole problem. A sanitizer that returns a string has thrown away its tree and asked the browser to reconstruct one. If the browser reconstructs a different tree than the sanitizer had, the sanitizer's decisions were made about a document that never rendered.
Mutation XSS
Mario Heiderich and colleagues named the class at CCS 2013, in a paper whose central observation is deceptively quiet: browsers do not treat innerHTML as a round trip. Reading innerHTML does not return the string you assigned. It returns a serialization of the current tree, and the serializer makes choices — normalizing case, quoting attributes, adding implied elements, resolving entities — that can produce a string which, parsed again, yields a different tree.
The paper's framing of why this defeats filters is the sentence to sit with:
In browser implementations, mXSS is closely related to performance enhancements applied to the HTML code before rendering; in server side filters, strict filter rules would break many web applications since the mXSS vectors presented in this paper are harmless when sent to the browser.
Read that twice. The vectors are harmless when sent to the browser. They are not smuggled past the filter by obfuscation; they pass because they are genuinely benign at the moment of inspection. The browser then mutates them into something that is not.
Imagine a customs officer who inspects a sealed box, finds nothing prohibited, and stamps it. Between the checkpoint and the destination, the box goes through a machine that reorganizes its contents to pack them more efficiently — and the reorganization happens to assemble the parts into something the officer would have confiscated. Nobody smuggled anything. The inspection was accurate. The rearrangement came after.
The parser differential is why mutation XSS is not a defect in DOMPurify or in HTML Purifier or in any of the systems the paper broke. It is a property of the arrangement: two parses with a mutation between them, and a security decision attached to the first one.
Important
The defense is not "find a better sanitizer." It is to avoid the round trip — keep the tree, or move the decision to the same parse the browser will use. Every serious development since 2013 is a version of that move.
The honest record
It would be easy to tell this story as though the problem were solved in 2013 and everyone has been fine since. The advisory record says otherwise, and the pattern in it is more interesting than a simple "sanitizers are broken."
DOMPurify has published 23 security advisories, 10 with assigned CVEs. Two recent ones are explicitly mutation XSS: a nesting-based mXSS in 2024 (CVE-2024-47875, rated critical) and an mXSS via re-contextualization in March
- Others are prototype-pollution tampering, and several concern
configurable non-default paths — IN_PLACE mode, custom-element handling, hook misuse. One 2026 advisory is described as an incomplete fix of an earlier patch.
The fair reading is not that DOMPurify is negligent — the opposite; a project that publishes 23 advisories is one that is being looked at hard and is telling you what it finds. The fair reading is that the bypasses keep coming, they cluster in the places where the library's behavior diverges from a plain parse, and the library's own README is candid about the boundary of what it can promise:
if you first sanitize HTML and then modify it afterwards, you might easily void the effects of sanitization.
Warning
That caveat is doing more work than it appears to. Any post-processing between sanitization and insertion — a templating pass, a framework's own HTML handling, a "helpful" library that rewrites URLs — reopens the gap. Sanitize last, or you have not sanitized.
SVG, or: the image format that is a document
The sanitizer analysis so far concerns HTML. SVG makes the problem worse, and it is worth being precise about why, because the usual summary ("SVGs can contain JavaScript") is true but leaves out the distinction that actually governs your risk.
SVG is not an image format that happens to allow scripting. It is an XML document format with a full event model. Cloudflare put it bluntly when announcing their own SVG sanitizer:
SVG files aren't just images. They are XML-based documents that are as powerful as HTML pages. They can contain arbitrary JavaScript, fetch external content from other URLs or embed HTML elements.
The SVG 2 specification confirms the mechanisms directly. Event attributes are first-class: "An event attribute always has a name that starts with 'on' followed by the name of the event for which it is intended," and their contents "are always interpreted as ECMAScript." Script in an SVG <script> element has "a 'global' scope across the entire current document."
The distinction that decides your exposure
Here is the fact most summaries omit, and it is load-bearing: whether an SVG can execute script depends on how it is loaded, not on what is inside it.
The SVG 2 spec defines secure static mode for exactly this:
This processing mode is intended for circumstances where an SVG document is to be used as a non-animated image that is not allowed to resolve external references, and which is not intended to be used as an interactive document.
In that mode, script execution is off, external references are off, interactivity is off. And the binding rule states where it applies: an SVG in an <img> element — or anywhere SVG stands in for a raster image, including CSS <image> values — is processed in secure static or secure animated mode.
MDN states the scope limit explicitly, and it is the part to internalize:
Note that the above restrictions are specific to image contexts; they don't apply when SVG content is viewed directly, or when it's embedded as a document via the
<iframe>,<object>, or<embed>elements.
So a user-uploaded SVG rendered through <img src> cannot run script. The same file served at its own URL and visited directly can — same bytes, same server, different processing mode. The load-path distinction makes "we only display uploads in <img> tags" is a real mitigation and "we host user SVG uploads on our origin" is a real vulnerability, and why the two are frequently true of the same system.
Warning
Serving user-uploaded SVG from your own origin is XSS-by-upload the moment anyone navigates to the file. Serve untrusted SVG from a separate origin, or force a download disposition, or rasterize it. Inline <svg> injected into your DOM is a document context too — the <img> exemption does not help you there.
Why allowlisting SVG is hard
svg-hush, Cloudflare's Rust SVG sanitizer, takes the allowlist approach and describes its scope in three lines: it removes scripting, removes hyperlinks to other domains, and removes references to cross-origin resources. Its mechanism is a strict allowlist — it "removes any elements and attributes that aren't in its allowlist and filters all URLs to be same-origin only."
Two things about that project are worth more than the code. First, its authors admit the cost: "It may break some SVG images." An allowlist over a format described in the same post as having "lots of features" will have false positives, and they chose that over false negatives. Second — and this is the line to take away — even the authors of a dedicated allowlist sanitizer do not treat it as sufficient on its own. Their README recommends that you also "serve SVG images with a restrictive Content-Security-Policy."
When the people who wrote the sanitizer tell you to keep the CSP, the sanitizer is a layer, not a solution.
Moving the decision into the parser
The structural fix follows from the diagnosis. If the danger lives in the gap between two parses, close the gap: sanitize during the browser's own parse, so there is no second implementation and no serialization round trip.
The HTML Standard now specifies that design in §8.6. Older accounts that describe HTML sanitization as a Community Group incubation are wrong. The WICG draft still exists and still carries the disclaimer that it "is not a W3C Standard nor is it on the W3C Standards Track." Cite the HTML Standard.
The API is six entry points, in two families:
- Safe:
Element.setHTML(),ShadowRoot.setHTML(),Document.parseHTML() - Unsafe:
Element.setHTMLUnsafe(),ShadowRoot.setHTMLUnsafe(),Document.parseHTMLUnsafe()
plus a Sanitizer object with imperative configuration — allowElement(), removeElement(), allowAttribute(), removeUnsafe() and friends.
Note
Do not read setHTMLUnsafe as the safe API's escape hatch for experts. It shipped years earlier than the safe method (Chrome 124, Firefox 123, Safari 26) for an unrelated reason — declarative shadow DOM — and its "unsafe" is literal. The safe setHTML() is the recent arrival.
Where this sits relative to Trusted Types
There is a natural assumption here that is exactly backwards, and it is worth correcting before the next article, which is about Trusted Types proper.
You might expect setHTML() to produce a TrustedHTML value — the sanitizer vouches for the string, the type system carries the vouching. It does not. The spec gives setHTMLUnsafe() a Trusted Types step, invoking "get trusted type compliant string" with TrustedHTML. setHTML() has no such step at all.
MDN explains the reasoning:
The safe HTML sanitization methods don't use trusted types. Because they always filter all XSS-unsafe entities before input HTML is injected, there is no need to sanitize the input string, or audit the methods.
So the safe method is not a Trusted Types sink. It is exempt — not because it evades enforcement, but because a method that cannot inject unsafe content has nothing for the type system to guard. The unsafe method, which can, is subject to it. That is coherent once you see it, and it is the opposite of what "the sanitizer returns a trusted type" would predict.
Browser support, honestly
The safe methods had limited browser support on 2026-08-12:
| Feature | Chrome | Firefox | Safari |
|---|---|---|---|
Element.setHTML() (safe) | 146 | 148 | not supported |
Sanitizer interface | 146 | 148 | not supported |
Element.setHTMLUnsafe() | 124 | 123 | 26 |
MDN classifies the safe API as limited availability — explicitly not Baseline, because it does not work in some of the most widely used browsers. Global support sat around two thirds on 2026-08-12. Safari shipped neither setHTML() nor the Sanitizer interface on that date. A library sanitizer therefore remains necessary for broad browser support, with the native path as progressive enhancement — and DOMPurify has anticipated this, offering a RETURN_TRUSTED_TYPE option that (in its own careful wording) "tries to return a TrustedHTML value instead of a string."
The complication
It would make a tidy ending to say the browser absorbed the problem and the gap is closed. Two bypasses disclosed in February and March 2026 say otherwise, and they are worth more than a tidy ending because of where they were.
Adam Kues at Searchlight Cyber found two bypasses in Chrome's native Sanitizer, in February and March 2026. The first turned on namespace handling — the implementation split an attribute name on : and took the first two pieces, so xlink:href:x was treated as xlink:href by the check and something else by the renderer. The researcher's account is anticlimactic in the way of many parser bugs:
I quickly hypothesized that this might be implemented by splitting on
:and taking the first two elements. Thus, I triedxlink:href:x, and it worked!
The payload was a SMIL animation — <animate> with attributeName set to that mangled name and values set to a javascript: URL. The payload uses the SVG execution path through the native Sanitizer API. The second bypass was a disagreement between two URL-parsing functions in the same codebase: one returned false for javascript: when the URL was malformed, the other returned true. Both were fixed in Chrome 147.
Sit with what those are. Both are parser differentials — the same class of bug as mutation XSS, one implementation disagreeing with another about what a string means. Moving sanitization into the browser removed the gap between the library and the engine. It did not abolish the possibility of two components disagreeing; it relocated it inside the engine, where there are fewer such gaps and they get fixed on the browser's release train rather than yours.
Native sanitization is a large reduction in the number of places two parsers can disagree. It is not a proof that none remain.
That is a real improvement and an honest one. The Trusted Types article starts from the remaining limit: if you cannot prove your sanitization is complete, you want a mechanism whose guarantee does not depend on completeness. Trusted Types is that mechanism, and its guarantee is narrower than common descriptions claim — not that the value reaching a sink is safe, but that a policy you named produced it. Which turns an unbounded question about every DOM write into a bounded one about a few policies.
Where the untrusted markup comes from
One practical note: inventory gaps often defeat the sanitizer architecture.
The markup a sanitizer protects you from rarely arrives at one door. A CMS field is the obvious one. Less obvious: rich-text pasted from a word processor, which carries conditional comments and vendor markup; email bodies rendered in a web client; user-uploaded SVG, with the document-context risks already described; and third-party embeds whose whole purpose is to inject markup you did not write.
That last category deserves its own care. A tag manager exists to insert scripts into your page at runtime. Google's own CSP guidance for its tag platform recommends nonces, and is direct about the cost of one feature: for custom JavaScript variables, "use 'unsafe-eval' only when absolutely necessary." Whether a given third party can live inside a strict policy is a question to answer per vendor, before deployment, by testing — not by assumption.
The comment field was sanitized. The CMS body was sanitized. The migration script that imported four years of legacy posts wrote directly to the database, because it ran once, on trusted input, from a maintenance shell.
Two years later, nobody remembers that. What they remember is that content in the body column has been sanitized — that is what the sanitizer is for. The one path that skipped it left no trace in the code, because it was not code anymore. It was a row.
Lessons
- A sanitizer is a parser, not a filter. Deciding whether an attribute is dangerous requires knowing which element carries it, which requires a tree.
- Mutation XSS lives in the round trip.
innerHTMLis not idempotent; serializing a tree and reparsing it can yield a different tree, and a filter's decisions apply to the first one. - The vectors are benign at inspection time. That is what makes mXSS a structural problem rather than a filtering failure — the browser creates the danger after the check.
- SVG's risk is a property of the load path. Script runs when the SVG is a document, not when it stands in for an image. Same file, different exposure.
- Even allowlist authors keep the CSP. svg-hush recommends serving sanitized SVG under a restrictive policy anyway. Treat sanitization as a layer.
- Sanitization is now in the HTML Standard, with a safe/unsafe split — and the safe methods are not Trusted Types sinks, because they have nothing to guard.
- Native does not mean proven. Chrome's Sanitizer was bypassed twice within weeks of shipping, both times by parser differentials. Fewer gaps, not zero.
Practice
References
- Heiderich, Schwenk, Frosch, Magazinius, Yang. “mXSS Attacks: Attacking well-secured Web-Applications by using innerHTML Mutations.” ACM CCS, 2013. — the paper that named the class, and the observation that the vectors are harmless when sent to the browser
- WHATWG. “HTML sanitization.” HTML Standard. — the normative spec, including the Trusted Types step that setHTMLUnsafe has and setHTML does not
- MDN. “HTML Sanitizer API.” MDN. — the six entry points, the browser-support table, and the rationale for exempting the safe methods from trusted types
- MDN. “SVG as an image.” MDN. — the four restrictions in image contexts, and the explicit statement that they do not apply to documents
- W3C SVG Working Group. “Conforming processing modes.” SVG 2. — secure static and secure animated mode, and the rule binding them to image contexts
- W3C SVG Working Group. “Scripting and interactivity.” SVG 2. — event attributes interpreted as ECMAScript, and script's document-wide scope
- Cure53. “DOMPurify.” GitHub. — the DOM-only design, the sanitize-last caveat, and RETURN_TRUSTED_TYPE
- Cure53. “DOMPurify security advisories.” GitHub. — the recurring-bypass record, including two advisories that are explicitly mXSS
- Cloudflare. “svg-hush.” GitHub. — an allowlist SVG sanitizer whose own README still recommends a restrictive CSP alongside it
- Costa, Lesinski. “SVG support in Cloudflare Images.” Cloudflare Blog, 2022. — why SVG is a document format rather than an image format, and the recurring attack themes
- Kues. “Two bypasses for Chrome's Sanitizer API.” Searchlight Cyber, 2026. — two parser differentials inside the browser's own implementation, fixed in Chrome 147
- Google. “Use a content security policy with Google tags.” Google Tag Platform. — the nonce recommendation, and the unsafe-eval caveat for custom JavaScript variables
How to cite
Mangalapilly, Y. J. (2026, August). Two Parsers Walk Into a DOM. Saṃhitā Notes. https://yesudeep.com/blog/two-parsers-walk-into-a-dom/ @online{mangalapilly2026two,
author = {Yesudeep Jose Mangalapilly},
title = {Two Parsers Walk Into a DOM},
journal = {Sa\d{m}hit\=a Notes},
year = {2026},
month = {August},
url = {https://yesudeep.com/blog/two-parsers-walk-into-a-dom/},
urldate = {2026-08-12},
} Yesudeep Jose Mangalapilly. “Two Parsers Walk Into a DOM.” Saṃhitā Notes, 2026. https://yesudeep.com/blog/two-parsers-walk-into-a-dom/. TY - ELEC
AU - Mangalapilly, Yesudeep Jose
TI - Two Parsers Walk Into a DOM
T2 - Saṃhitā Notes
PY - 2026
UR - https://yesudeep.com/blog/two-parsers-walk-into-a-dom/
Y2 - 2026-08-12
ER - Webmentions
Annotations
Thank you — your note is held for review and will appear once approved.
Thank you — your note is published.
Please sign in below to leave a note.
