TL;DR
DOMPurify 3.1.3 through 3.3.1 contained an mXSS edge case that became exploitable when sanitized output was reparsed inside special wrappers such as xmp, iframe, noembed, noframes, noscript, and script. The bug received CVE-2026-0540, was publicly disclosed by Fluid Attacks as advisory daft, and was fixed in 3.3.2 with a small patch series rather than a single one-line change.
What made this one fun is how we found it. Cristian Vargas and I were playing a CTF and, almost by accident, tried an XSS route against a sink that looked correctly sanitized. Both of us managed to pop it anyway. At that point we stopped thinking about the challenge and started asking the more interesting question: was this actually a DOMPurify edge case? It turned out the answer was yes, and it was not part of the challenge after all.
How We Found It
This bug did not start with a source audit. It started with a mistake.
Cristian and I were solving a CTF challenge and went after an XSS sink that was already going through DOMPurify. The sink should have been boring. Instead, we both managed to bypass it.
That was the moment the challenge stopped mattering.
The payload was surviving sanitization as inert-looking attribute data, but after the application wrapped the sanitized result and reparsed it with innerHTML, the browser produced a different DOM tree. That is the classic shape of mutation XSS: the dangerous behavior does not exist in the first parse, it appears in the second one.
Once we realized that, we built a minimal reproducer outside the challenge and confirmed the behavior consistently enough to turn it into a proper report.
Why This Happened In DOMPurify
The interesting part was not the innerHTML sink by itself. DOMPurify’s own docs already warn that sanitizing once and then changing context afterwards can void the effects of sanitization. The more specific issue was that the library’s SAFE_FOR_XML handling was already trying to defend against dangerous sequences in attribute values, but its regex did not cover all the wrappers that mattered here.
Before the fix, the relevant check in src/purify.ts looked like this:
/((--!?|])>)|<\/(style|title|textarea)/iThat meant DOMPurify knew certain closing sequences inside attributes were too risky to keep when SAFE_FOR_XML was on, but it was only accounting for style, title, and textarea.
The missing cases were the raw-text or raw-text-like wrappers that made our PoC work:
xmpiframenoembednoframesnoscript
And, in a follow-up patch commit, script was added as well.
Since SAFE_FOR_XML is enabled by default, this was not an obscure opt-in corner case. It was a default-on safeguard that simply did not model the full set of wrappers it needed to care about.
Reproducing The Behavior
Our PoC used a server-side DOMPurify instance backed by jsdom, returned the sanitized string to the browser, and then deliberately reparsed it inside special wrappers.
The minimal server-side path was:
const { JSDOM } = require('jsdom');const createDOMPurify = require('dompurify');
const window = new JSDOM('').window;const DOMPurify = createDOMPurify(window);
app.post('/sanitize', (req, res) => { const sanitized = DOMPurify.sanitize(req.body.input); res.json({ sanitized });});The client-side sink was:
const sanitized = data.sanitized || '';sink.innerHTML = '<' + wrapper + '>' + sanitized + '</' + wrapper + '>';With wrapper = "xmp" and this payload:
<img src=x alt="</xmp><img src=x onerror=alert(1)>">the second parse produced a live onerror handler.
Fuzzing The Wrappers
Once we had a working xmp case, the next question was obvious: how many other wrappers behave the same way?
We fuzzed the wrappers listed in DOMPurify’s special-content handling and then broadened that into a larger tag sweep. The interesting set in the vulnerable flow ended up being:
xmpiframenoembednoframesnoscriptscript
That final script case matters because it was not part of the first rawtext-regex expansion. It appeared in the follow-up patch, which is one reason the remediation story here is more accurately described as a patch series than a single fix.
One nuance is important: this is not the same thing as saying “browser-only DOMPurify is trivially bypassed everywhere.” In my testing, a browser-only PoC using DOMPurify directly in the page escaped the closing sequences and did not reproduce. The exploitable condition showed up in the end-to-end flow where sanitized output was produced and later reparsed into a different context.
That distinction is also why this bug sat in an awkward but interesting place between a library issue and an integration misuse pattern.
Advisory Evidence
PoC Video
XSS Triggered

What DOMPurify’s Docs And Threat Model Say
DOMPurify’s documentation already contains two warnings that matter here.
First, the README explicitly says that if you sanitize HTML and then modify it afterwards, you can void the effects of sanitization. That maps directly to a flow that sanitizes once and later reparses the result inside a new wrapper.
Second, the threat model says DOMPurify does not protect against “faulty use or flipping of markup context.” Their example is sanitizing HTML and then throwing it into SVG or another XML-based context. The underlying principle is the same: if you change parsing context after sanitization, you can create a bypass.
So the honest framing is not “DOMPurify promised to solve every context switch and completely failed.” The honest framing is narrower and more useful:
- DOMPurify documents that context flipping is dangerous and partly out of scope.
- At the same time,
SAFE_FOR_XMLis a default-on defense specifically meant to neutralize risky structural sequences in attributes. - That defense missed several wrappers, and upstream patched the gap.
In other words, even if the issue lives near the edge of the stated threat model, the project still chose to harden the sanitizer and ship a fix. That is the right outcome, and it is one reason I think this advisory is worth studying.
The Patch Was Not One Commit
If you only look at the release page, it is easy to treat 3.3.2 as a single black-box fix. That misses what actually happened.
The remediation on the 3.x line landed as a short sequence:
- Commit
729097fexpanded theSAFE_FOR_XMLregex to includexmp,noscript,iframe,noembed, andnoframes. - Commit
302b51dextended that regex again to also coverscript. - Release commit
5e56114bundled the final3.3.2release.
The core source-level change ended up like this:
/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/iThere was also a backport on the legacy 2.x branch. Commit d59bfe7 carried the same idea into 2.5.9, which matters for deployments still pinned to the MSIE-compatible line.
So if you are documenting this issue, the accurate story is:
- one advisory
- one public fixed version on
3.x - multiple security-relevant commits
- one legacy-branch backport