{"schemaVersion":3,"id":"GHSA-6688-9RHM-GJV2","aliases":[],"status":"published","duplicateOf":null,"title":null,"description":"DOMPurify: IN_PLACE returns a force-removed rawtext root whose text carries attacker markup — pure HTML reparse executes\n\n## 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 (`<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.\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('jsdom');\nconst createDOMPurify = require('dompurify');   // 3.4.15\n\nconst window = new JSDOM('').window;\nconst DOMPurify = createDOMPurify(window);\n\nconst styleRoot = window.document.createElement('style');\nstyleRoot.setAttribute('onclick', 'alert(1)');    // attribute payload\nstyleRoot.textContent = '</style><img src=x onerror=1>';  // 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// <style></style><img src=x onerror=1></style>\nconsole.log(styleRoot.getAttribute('onclick'));            // null  (attribute neutralized)\nconsole.log(styleRoot.textContent);                        // '</style><img src=x onerror=1>' (text survives)\n\n// plain HTML reparse (no foreign-content context involved):\nconst probe = window.document.createElement('div');\nprobe.innerHTML = returned.outerHTML || styleRoot.outerHTML;\nconsole.log(probe.querySelectorAll('img').length);         // 1\nconsole.log(probe.querySelector('img').getAttribute('onerror')); // \"1\"\n```\n\nObserved 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.\n\nContrast 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.\n\nContrast 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.\n\n## Root cause\n\n`_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**.\n\nThe 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.\n\nDifferential (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.\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–3.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 `<= 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 — `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.\n\n**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`.\n\nA 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.\n\n## Prior art / differentiation\n\n- GHSA-r47g-fvhr-h676 (fixed 3.4.6): clobbered-form **root** removal — different trigger; this report's 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 — 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.\n- GHSA-h8r8-wccr-v5f2 (medium): mXSS via re-contextualization in the standard (non-IN_PLACE) serialize path — 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'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.","dates":{"published":"2026-10-05T23:43:53.000Z","modified":"2026-10-06T00:00:09.538Z","reserved":null,"kevAdded":[],"kevDue":[]},"cvss":{"headline":{"score":null,"severity":"unknown","version":null,"versionLabel":null,"source":null,"vector":null,"computed":false,"selection":"none"},"metrics":[]},"epss":{"probability":null,"percentile":null,"severity":"unknown","asOf":null,"change":{"pp":null,"baselineDate":null,"baselineScore":null},"history":[],"last30":[]},"kev":{"state":"not-listed","entries":[],"listedCount":0,"totalCatalogs":3,"catalogs":[{"source":"cisa","label":"CISA","listed":false,"entry":null},{"source":"enisa","label":"ENISA","listed":false,"entry":null},{"source":"circl","label":"CIRCL","listed":false,"entry":null}]},"ransomware":{"state":"none-recorded","sources":[]},"exploits":[],"exploitCount":0,"weaknesses":[],"affected":[{"vendor":"Npm","product":"dompurify","name":"dompurify","label":"dompurify","ecosystem":"Npm","cpePart":null,"purl":"pkg:npm/dompurify","purlType":"npm","versions":[{"text":"< 3.4.16","noFixRecorded":false,"fixedIn":null}],"versionsText":"< 3.4.16","eol":null,"hubHref":null}],"affectedCount":1,"eol":{"hasEol":false,"count":0},"references":[{"url":"https://github.com/cure53/DOMPurify/security/advisories/GHSA-6688-9rhm-gjv2","label":"github.com/cure53/DOMPurify/security/advisories/GHSA-6688-9rhm-gjv2","hostname":"github.com","tags":["WEB"]},{"url":"https://github.com/cure53/DOMPurify/pull/1636","label":"github.com/cure53/DOMPurify/pull/1636","hostname":"github.com","tags":["WEB"]},{"url":"https://github.com/cure53/DOMPurify/commit/b9b9d80f7e401771c2ccaef5f45def7eec8f27d7","label":"github.com/cure53/DOMPurify/commit/b9b9d80f7e401771c2ccaef5f45def7eec8f27d7","hostname":"github.com","tags":["WEB"]},{"url":"https://github.com/cure53/DOMPurify","label":"github.com/cure53/DOMPurify","hostname":"github.com","tags":["PACKAGE"]},{"url":"https://github.com/cure53/DOMPurify/releases/tag/3.4.16","label":"github.com/cure53/DOMPurify/releases/tag/3.4.16","hostname":"github.com","tags":["WEB"]}],"lineage":{"upstream":[],"downstream":["CGA-237P-83W4-53J4","CGA-243J-CWGH-63MV","CGA-25CJ-9MFQ-8W8F","CGA-2659-47WW-HVWW","CGA-26FQ-7FHQ-F594","CGA-26RW-VJ9P-FXRG","CGA-2755-6R4F-QW25","CGA-2CJH-X5QG-X6GC","CGA-2GV6-7QXC-GC2Q","CGA-2H9W-HXH5-CXR2","CGA-2JMW-G8X6-53M3","CGA-2Q72-XCJV-R8M2","CGA-3FGC-PC3H-6RW5","CGA-3M6Q-QQM9-25M7","CGA-3MFJ-69PG-39MJ","CGA-3MJ4-FX4H-M2CG","CGA-3QF6-5H9P-FWW5","CGA-3W2F-7345-5VVV","CGA-42HP-JGMM-P5WQ","CGA-4322-JCMW-4GJ4","CGA-443Q-MF2H-V3R5","CGA-45GV-6GW7-H4PV","CGA-463H-3483-HJ5X","CGA-4JGX-45CW-G45M","CGA-4QCM-Q679-V325","CGA-4VW9-23Q5-3XJ3","CGA-4WR6-VGV2-32V5","CGA-5244-2PJV-JXQW","CGA-5825-R3XV-9G9W","CGA-5C7F-RG98-4863","CGA-5MHW-R3VV-V47X","CGA-5WWX-8WRW-M5WF","CGA-5XFG-WP6V-VP62","CGA-63XF-M2X6-27H6","CGA-67JH-8MMG-3225","CGA-69FW-W2CP-7PQ5","CGA-6C2H-W64Q-3249","CGA-6F8R-QXQ6-3VRH","CGA-6FG5-P3CM-J8PF","CGA-6GH7-46R3-6WMP","CGA-6QRV-4C87-WQJW","CGA-7F9M-49XQ-3F7Q","CGA-7JMQ-J6QW-PPJQ","CGA-7QGM-XRF7-R46V","CGA-7RR6-697Q-W4R9","CGA-8765-CVCR-3474","CGA-87WQ-CPJH-44MR","CGA-8C6Q-8877-FFM4","CGA-8F5V-VQQ8-755G","CGA-8H72-7C2X-2P7J","CGA-8PWX-5XQM-7PRM","CGA-8X4H-H85X-MMQ9","CGA-8XJF-R49F-WRVG","CGA-94MM-77RH-64HF","CGA-9CX8-XQR9-P76H","CGA-C2WM-4CQV-67P9","CGA-C3GR-MPWR-6X6W","CGA-C62G-J2JC-W4Q3","CGA-C96J-7JJP-PRQJ","CGA-CC83-7699-4785","CGA-CGM7-MC3J-JP9C","CGA-CM33-8HV2-XP4H","CGA-CR84-GFF5-JPCC","CGA-CX6W-Q3HC-8FXC","CGA-F38Q-R5M3-JCPR","CGA-F66V-HPFM-QRHQ","CGA-F8XX-PFHM-2J4F","CGA-FC4Q-7HCM-C66G","CGA-FGVW-79F6-3XQH","CGA-FPQ4-4J7C-J54W","CGA-FR5R-4758-9455","CGA-FW8Q-GC5W-7W7C","CGA-FXQ6-XQVV-5P2G","CGA-G2MW-4CRV-754M","CGA-G3PW-7V2W-5CXJ","CGA-G4HJ-7FQQ-3675","CGA-G84H-578M-69HG","CGA-GC7G-62Q4-QRFQ","CGA-GFX3-H2FP-W9W6","CGA-GGWR-X62Q-R4WJ","CGA-GQ7M-4WXW-FHG5","CGA-GVJ4-F3C5-PGM3","CGA-GXM9-RMQ2-WPPV","CGA-H543-9MPV-8555","CGA-H8V3-462X-WR2G","CGA-H8W6-3X3P-MQMH","CGA-HH4Q-FWV6-85CW","CGA-HJ7M-9C72-R34F","CGA-HRX2-PJ5G-WV42","CGA-J29Q-4FH4-5CC6","CGA-JCQR-WP7W-33HV","CGA-JJ33-R6P2-2CQ4","CGA-JPJV-Q3MJ-JJ2X","CGA-JW68-9P2X-6394","CGA-JWX5-95QR-JJ87","CGA-M2G4-C2C7-9RG4","CGA-M56X-C844-4445","CGA-M66V-6PMX-26M6","CGA-MFCJ-45V5-CHMV","CGA-MH2Q-MGFP-GRMM","CGA-MM3C-589H-2F9J","CGA-MP3X-XH5F-FHM5","CGA-MQFQ-MMHF-J4W2","CGA-MQXM-MFVX-M863","CGA-MV8F-47QX-H6H4","CGA-MW9V-53WF-CWP4","CGA-P543-G5XX-442F","CGA-P6M2-RF74-PXXF","CGA-P6W6-G82H-J79X","CGA-PFMR-2X24-VPJ2","CGA-PFWR-5923-H28V","CGA-PR5G-RHXQ-XFCX","CGA-PWQG-FGCV-W9QV","CGA-Q52R-R722-67GM","CGA-Q628-M3JJ-HVMH","CGA-Q894-25PR-HVG2","CGA-Q8W2-MF8M-XPPC","CGA-Q8WC-86JP-XCF7","CGA-QH9R-FX2Q-HRHQ","CGA-QHH8-QXHQ-JPRC","CGA-QRF3-JXQJ-GWR4","CGA-R24M-9FJQ-H87W","CGA-R2P6-QV3Q-HC2J","CGA-R95M-MCRC-2JJM","CGA-RCF6-3XM8-33QM","CGA-RCRQ-3M82-44FP","CGA-RG98-HX7Q-G89H","CGA-RGRP-4484-9Q83","CGA-RJCF-82X7-3326","CGA-RW2V-2HJF-6FV3","CGA-RXQ6-JFWJ-Q776","CGA-V65Q-WH4G-75CH","CGA-V7Q6-9CC9-9R3C","CGA-VC3G-92JQ-C3G4","CGA-VCJQ-4XXF-MX7V","CGA-VFRX-332M-2W76","CGA-W4JV-JHVQ-MCR5","CGA-W6XF-MQMJ-3JH7","CGA-WFGH-G746-7F2H","CGA-WW3F-4J8C-F7RP","CGA-WWHR-3V8P-695R","CGA-WX2X-W7PP-QGRF","CGA-X29J-GP8P-54PQ","CGA-X665-H6WP-7RH9","CGA-X6P2-XXFX-7QH2","CGA-X758-3PCQ-8XF6","CGA-XCJ3-29FQ-9GH4","CGA-XCJH-3Q48-32J3","CGA-XCWX-8H46-3M7J","CGA-XHRW-6XR5-7HXJ","CGA-XM35-4XFC-4Q27","CGA-XMGH-4M3F-XCJQ","CGA-XP82-4G6W-39M9","CGA-XQ6Q-G49M-68G3","CGA-XR82-FH33-R7H8","CGA-XW43-GFV6-RM78","CGA-XWV8-GRC2-JQGF"],"duplicates":[],"related":[]},"timeline":[{"kind":"published","date":"2026-10-05T23:43:53.000Z","label":"Published","detail":"Vulnerability first disclosed.","source":null},{"kind":"modified","date":"2026-10-06T00:00:09.538Z","label":"Last modified","detail":"Vulnerability information updated.","source":null}],"risk":{"score":0,"level":"minimal","profile":"Default","thresholds":{"critical":75,"high":50,"medium":25,"low":10}},"meta":{"dataAsOf":"2026-10-21T05:51:51.095Z","stale":false,"partial":false}}