DOMPurify.sanitize(node, { IN_PLACE: true }) on a Node input; SAFE_FOR_XML at its default (true)The 3.4.9 fix for the IN_PLACE detached-root class added two protections on the IN_PLACE return path: a fail-closed TypeError in _forceRemove when a node selected for removal cannot be detached, and a _neutralizeSubtree pass (dist/purify.js line 1336) that strips non-allowlisted attributes from removed subtrees.
Both miss the rawtext text-content form. When the force-removed root is a rawtext element (<style>), the payload lives in the node's text: the node detaches fine (the TypeError guard is not reached), _neutralizeSubtree strips nothing (there are no attributes), and the IN_PLACE exit returns the detached, never-sanitized <style> whose text still carries live markup. Serializing that node and re-parsing it in plain HTML context materializes the payload — no foreign-content context required.
The same Node input sanitized without IN_PLACE returns an empty result: the only difference is the IN_PLACE return path handing the killed node back.
const { JSDOM } = require('jsdom');
const createDOMPurify = require('dompurify'); // 3.4.15
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
const styleRoot = window.document.createElement('style');
styleRoot.setAttribute('onclick', 'alert(1)'); // attribute payload
styleRoot.textContent = '</style><img src=x onerror=1>'; // text payload
window.document.body.appendChild(styleRoot);
const returned = DOMPurify.sanitize(styleRoot, { IN_PLACE: true });
console.log(returned === styleRoot); // true (same node)
console.log(styleRoot.parentNode === null); // true (detached)
console.log(styleRoot.outerHTML);
// <style></style><img src=x onerror=1></style>
console.log(styleRoot.getAttribute('onclick')); // null (attribute neutralized)
console.log(styleRoot.textContent); // '</style><img src=x onerror=1>' (text survives)
// plain HTML reparse (no foreign-content context involved):
const probe = window.document.createElement('div');
probe.innerHTML = returned.outerHTML || styleRoot.outerHTML;
console.log(probe.querySelectorAll('img').length); // 1
console.log(probe.querySelector('img').getAttribute('onerror')); // "1"
Observed on 3.4.15: one node, one call — the onclick attribute is neutralized while the text payload (</style><img src=x onerror=1>) survives verbatim; serializing and re-parsing the returned node in plain HTML context materializes the img with the live onerror handler.
Contrast on the same Node input without IN_PLACE: RETURN_DOM: true → <body></body>; RETURN_DOM_FRAGMENT: true → 0 children — the payload is fully sanitized away. The only difference is the IN_PLACE return path.
Contrast on the removal trigger: SAFE_FOR_XML: false → the node is not removed (detached stays false); plain CSS text → not removed. The removal is gated by the mXSS text probes and happens specifically because the serialized node would re-open tags on reparse.
_isUnsafeNode (dist/purify.js 3.4.15, lines 1700–1714) removes nodes whose literal text would re-open tags on reparse — shape (b) in the source comment is "text-only content that already carries the element's OWN end tag", detected by the LITERAL_TEXT_CLOSE probe (line 385) alongside the ELEMENT_MARKUP_PROBE (line 339) rules. _forceRemove (line 1122) records the node in DOMPurify.removed ({element}) and detaches it. The removal is intentional: the upstream comment states these shapes are removed because the literal serializer emits them verbatim for the HTML parser to re-open.
The IN_PLACE exit then hands the force-removed root back to the caller — the very node whose removal DOMPurify.removed just recorded (verified: DOMPurify.removed.some(e => e.element === root) is true on the returned instance). The 3.4.9 _neutralizeSubtree pass (line 1336) addresses only the attribute form — its own docstring: "walks a removed subtree and strips every attribute" (purpose: cancel queued resource events). Rawtext text content is out of its scope, so the removal that was performed specifically to prevent reparse is undone by returning the node: you removed it to stop the reparse, then returned it.
Differential (one node, one call, same removal path): the onclick attribute is neutralized by the existing pass while the text payload survives verbatim — the attribute axis is covered, the text axis is the gap.
Identical blast radius to the published IN_PLACE family: an application that sanitizes a Node in IN_PLACE mode and re-inserts (or serializes and then re-inserts) the result materializes attacker markup in plain HTML context: script execution in the page. Moving the returned node via appendChild alone is safe; the round trip through serialization is what fires the payload. No foreign-content context is required with the close-tag payload.
_neutralizeSubtree and the IN_PLACE return path are present in 3.4.9–3.4.14; releases before 3.4.9 predate the fix entirely (unconditional return; individual pre-3.4.9 releases not dynamically tested).<= 3.4.15 (current at time of writing).Primary (root-cause, covers every form): at the IN_PLACE exit, check whether the returned root was recorded during sanitization — DOMPurify.removed.some(e => e.element === root) — and fail closed: throw the same TypeError style used by the 3.4.9 detach guard ("a node selected for removal could not be safely returned; refusing to sanitize in place"), or return null. This is consistent with the existing fail-closed design and covers all present and future root-kill reasons in one check.
Secondary (form-specific): extend _neutralizeSubtree to neutralize text content of rawtext descendants — the elements in LITERAL_TEXT_ELEMENT_NAMES (style, script, xmp, iframe, noembed, noframes, plaintext, noscript) — by rewriting textContent to a defanged form, matching the probe coverage of _isUnsafeNode/LITERAL_TEXT_CLOSE.
A regression test asserting that a force-removed rawtext root comes back with no /<[/\w!]/ match in textContent (and is not returned at all under the primary fix) prevents re-introduction.
style element killed by the text probe._neutralizeSubtree's attribute stripping and is not that advisory.The payload materializes when the application serializes and re-parses the sanitizer output (innerHTML assignment, template rendering, markdown/HTML round trips) or otherwise consumes the returned node's markup. Moving the returned node via appendChild alone does not trigger it. Applications that pass live, connected attacker trees into IN_PLACE are explicitly warned against by upstream's own source comment; this report concerns the serialize-and-reinsert consumption pattern that the IN_PLACE mode exists to serve.
{
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-05T23:43:53Z",
"nvd_published_at": null,
"severity": "LOW"
}