DOMPURIFY · GHSA-6688-9rhm-gjv2 · Package
FIX → 3.4.16
DOMPurify's IN_PLACE mode can return a force-removed rawtext style element whose text still contains HTML markup. If the application serializes that node and re-parses it as HTML, the attacker's markup, such as an img with an onerror handler, becomes live and can run script in the page.
first public Sep 23, 2026 · 1 source
Could this affect your app?
Free first check from your website. Connect your repo for the full answer.
Applications that call DOMPurify.sanitize with IN_PLACE set to true on a DOM node, then serialize the returned node and re-insert it into a page. Calls that use the default string output, or that only move the returned node with appendChild, are not exposed according to the advisory. Upstream warns against passing live, connected attacker-controlled trees into IN_PLACE.
Not affected if default-config, no-in-place, string-output-only
Who cares browser, dompurify, javascript, node
npm ls dompurify to find every installed copy and version.IN_PLACE near DOMPurify.sanitize calls.Upgrade to 3.4.16
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 whose text still carries live markup. Serializing that node and re-parsing it in materializes the payload — no foreign-content context required.
Check it with your coding agent
It reads this page and tells you if your code is affected.
Are you affected?
Ask from here. InfraStack checks the affected versions against your stack and answers in your inbox.
InfraStack checks the affected versions against your stack and answers in Slack, with the evidence and the fix.
3.4.16<style>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).CVSS 3.1: 6.1 Medium — AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N, aligned with the published baseline of the family (GHSA-r47g-fvhr-h676).
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. Movin