{"id":"GHSA-6688-9rhm-gjv2","kind":"vulnerability","aliases":["GHSA-6688-9rhm-gjv2"],"related":[],"title":"DOMPurify 3.4.x: IN_PLACE returns a force-removed rawtext root whose text carries attacker markup — pure HTML reparse executes","description":"## Environment\r\n\r\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)\r\n- Config: `DOMPurify.sanitize(node, { IN_PLACE: true })` on a Node input; `SAFE_FOR_XML` at its default (`true`)\r\n\r\n## Summary\r\n\r\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.\r\n\r\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.\r\n\r\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.\r\n\r\n## Steps to reproduce\r\n\r\n```js\r\nconst { JSDOM } = require('jsdom');\r\nconst createDOMPurify = require('dompurify');   // 3.4.15\r\n\r\nconst window = new JSDOM('').window;\r\nconst DOMPurify = createDOMPurify(window);\r\n\r\nconst styleRoot = window.document.createElement('style');\r\nstyleRoot.setAttribute('onclick', 'alert(1)');    // attribute payload\r\nstyleRoot.textContent = '</style><img src=x onerror=1>';  // text payload\r\nwindow.document.body.appendChild(styleRoot);\r\n\r\nconst returned = DOMPurify.sanitize(styleRoot, { IN_PLACE: true });\r\n\r\nconsole.log(returned === styleRoot);                       // true (same node)\r\nconsole.log(styleRoot.parentNode === null);                // true (detached)\r\nconsole.log(styleRoot.outerHTML);\r\n// <style></style><img src=x onerror=1></style>\r\nconsole.log(styleRoot.getAttribute('onclick'));            // null  (attribute neutralized)\r\nconsole.log(styleRoot.textContent);                        // '</style><img src=x onerror=1>' (text survives)\r\n\r\n// plain HTML reparse (no foreign-content context involved):\r\nconst probe = window.document.createElement('div');\r\nprobe.innerHTML = returned.outerHTML || styleRoot.outerHTML;\r\nconsole.log(probe.querySelectorAll('img').length);         // 1\r\nconsole.log(probe.querySelector('img').getAttribute('onerror')); // \"1\"\r\n```\r\n\r\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.\r\n\r\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.\r\n\r\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.\r\n\r\n## Root cause\r\n\r\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**.\r\n\r\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.\r\n\r\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.\r\n\r\n## Impact\r\n\r\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.\r\n\r\n## Affected versions\r\n\r\n- Verified live: 3.4.15 (current).\r\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).\r\n- Per cure53 advisory convention the affected range is reported as `<= 3.4.15` (current at time of writing).\r\n\r\nCVSS 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).\r\n\r\n## Suggested remediation\r\n\r\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.\r\n\r\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`.\r\n\r\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.\r\n\r\n## Prior art / differentiation\r\n\r\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.\r\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.\r\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.\r\n- The 3.4.9 release notes credit @mozfreedyb for the IN_PLACE handling improvements that this residual escapes on the text axis.\r\n\r\n## Applicability scope (stated up front)\r\n\r\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.","summary":"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.","severity":"low","cvss":null,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"dompurify","fixed":["3.4.16"],"affected":"<= 3.4.15","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["browser","cwe-79","dompurify","javascript","node","package"],"not_affected_if":["default-config","no-in-place","string-output-only"],"how_to_check":"- Run `npm ls dompurify` to find every installed copy and version.\n- Search the codebase for `IN_PLACE` near `DOMPurify.sanitize` calls.\n- Check whether the returned node is serialized (outerHTML, innerHTML) and then re-inserted into the page.","fix":"- dompurify: `3.4.16`","status":"open","published_at":"2026-09-23T15:17:45+00:00","first_seen_at":"2026-11-09T22:53:20+00:00","triaged_at":"2026-11-09T23:01:23+00:00","updated_at":"2026-11-09T23:01:23+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-11-09T23:01:23Z","model":"claude-haiku-5-5","output":{"fix":"- dompurify: `3.4.16`","reach":"internet","title":"DOMPurify: IN_PLACE mode returns a removed style node carrying live markup","impact":"high","summary":"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.","audience":"developers","category":"package","nicknames":[],"who_cares":["dompurify","javascript","browser","node"],"confidence":0.8,"fix_status":"available","needs_auth":"no","worst_case":"attacker markup executes script in the victim's page","analyst_note":"","how_to_check":"- Run `npm ls dompurify` to find every installed copy and version.\n- Search the codebase for `IN_PLACE` near `DOMPurify.sanitize` calls.\n- Check whether the returned node is serialized (outerHTML, innerHTML) and then re-inserted into the page.","plain_action":"","product_name":"","needs_analyst":false,"not_affected_if":["default-config","no-in-place","string-output-only"],"who_is_affected":"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.","user_interaction":"yes","product_ecosystem":"","needs_special_setup":"yes","severity_if_unknown":"unknown"},"cost_usd":0.001064,"input_tokens":7685,"output_tokens":591,"severity_before":"low"},"analyst_status":null,"analyst_note":null,"search":"'-6688':23A,29A '-79':37B '-9':24A,30A '0':317C '1':256C,262C '1122':438C '1336':174C '1700':385C '1714':386C '20':110C '26':112C '29.1.1':108C '3.4':2A '3.4.15':98C,265C,383C '3.4.9':133C '30.0.1':106C '339':434C '385':427C 'add':143C 'allowlist':179C 'alongsid':428C 'alreadi':411C 'applic':64B 'attack':16A,76B 'attribut':180C,230C,272C 'away':324C 'b':400C 'becom':87B 'browser':35B 'call':269C 'cannot':165C 'carri':15A,412C 'caus':380C 'children':318C 'class':142C 'close':155C,424C 'comment':404C,454C 'config':114C 'console.log':213C,219C,224C,226C,232C,252C,257C 'const':206C,245C 'contain':59B 'content':190C,242C,409C 'context':243C,292C 'contrast':301C,334C 'css':352C 'current':99C 'cwe':36B 'default':129C 'detach':140C,167C,223C,348C,446C 'detached-root':139C 'detect':419C 'differ':327C 'dist/purify.js':172C,382C 'div':248C 'dom':311C,314C 'dompurifi':1A,38B,42B,97C 'dompurify':34A 'dompurify.removed':443C 'dompurify.sanitize':115C,208C 'element':55B,201C,414C,430C,444C 'emit':464C 'end':417C 'environ':96C 'execut':21A 'exit':478C 'fail':154C 'fail-clos':153C 'fals':342C,350C 'fix':134C 'forc':9A,51B,195C 'force-remov':8A,50B,194C 'forceremov':158C,436C 'foreign':241C 'foreign-cont':240C 'form':191C 'fragment':315C 'fulli':322C 'gate':359C 'getattribut':260C 'ghsa':22A,28A 'gjv2':27A,33A 'handler':86B,300C 'happen':366C 'html':19A,60B,74B,237C,291C,469C 'img':82B,254C,259C,295C 'independ':103C 'input':123C,306C 'intent':451C 'involv':244C 'isunsafenod':381C 'javascript':39B 'jsdom':105C 'length':255C 'line':173C,384C,426C,433C,437C 'liter':390C,422C,462C 'live':88B,298C 'markup':17A,61B,78B,431C 'materi':293C 'miss':185C 'mode':46B 'mxss':362C 'neutral':231C,274C 'neutralizesubtre':170C 'node':40B,67B,116C,122C,161C,218C,267C,288C,305C,344C,371C,388C,441C 'node.js':109C 'non':178C 'non-allowlist':177C 'npm':100C 'null':221C,229C 'observ':263C 'onclick':228C,271C 'one':266C,268C 'onerror':85B,261C,299C 'open':375C,395C,474C 'packag':41B 'page':95B 'pars':71B,285C 'parser':470C 'pass':171C 'path':151C,333C 'payload':203C,278C,320C 'place':5A,45B,118C,138C,149C,211C,309C,331C,477C 'plain':236C,290C,351C 'probe':246C,364C,425C,432C 'probe.innerhtml':249C 'probe.queryselector':258C 'probe.queryselectorall':253C 'protect':145C 'pure':18A 'rawtext':11A,53B,187C,200C 're':70B,284C,374C,394C,473C 're-open':373C,393C,472C 're-pars':69B,283C 'record':439C 'releas':101C 'remov':10A,52B,164C,182C,196C,337C,347C,355C,357C,387C,449C,459C 'repars':20A,238C,378C,398C 'reproduc':102C 'return':6A,48B,150C,207C,214C,287C,310C,313C,332C 'returned.outerhtml':250C 'rhm':26A,32A 'rhm-gjv2':25A,31A 'root':12A,141C,197C,379C 'rule':435C 'run':91B 'safe':124C,339C 'sanit':323C 'script':92B 'select':162C 'serial':65B,281C,370C,463C 'shape':399C,457C 'sourc':403C 'specif':367C 'state':455C 'stay':349C 'still':58B 'strip':176C 'style':54B 'styleroot':205C,209C,215C 'styleroot.getattribute':227C 'styleroot.outerhtml':225C,251C 'styleroot.parentnode':220C 'styleroot.textcontent':233C 'subtre':183C 'summari':131C 'surviv':235C,279C 'tag':376C,396C,418C 'text':14A,57B,189C,202C,234C,277C,353C,363C,391C,407C,423C 'text-cont':188C 'text-on':406C 'th':479C 'trigger':338C 'true':119C,130C,212C,216C,222C,312C,316C 'two':144C 'typeerror':156C 'upstream':453C 'verbatim':280C,466C 'whose':13A,56B,389C 'window.document.body.appendchild':204C 'window.document.createelement':247C 'without':307C 'would':372C,392C 'x':3A,111C,113C 'xml':126C,341C","nicknames":[],"mitigations":[],"who_affected":"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.","tier":"C","verified":{},"analyst_revisit_at":null,"fix_status":"available","audience":"developers","plain_action":null,"pkeys":["dompurify"],"ptokens":["dompurify"],"labels":["browser","cwe79","dompurify","javascript","low","node","npm","package"],"campaign_type":null,"search_a":"'-6688':23A,29A '-9':24A,30A '3.4':2A 'attack':16A 'carri':15A 'dompurifi':1A 'dompurify':34A 'execut':21A 'forc':9A 'force-remov':8A 'ghsa':22A,28A 'gjv2':27A,33A 'html':19A 'markup':17A 'place':5A 'pure':18A 'rawtext':11A 'remov':10A 'repars':20A 'return':6A 'rhm':26A,32A 'rhm-gjv2':25A,31A 'root':12A 'text':14A 'whose':13A 'x':3A","search_b":"'-79':37B 'applic':64B 'attack':76B 'becom':87B 'browser':35B 'contain':59B 'cwe':36B 'dompurifi':38B,42B 'element':55B 'forc':51B 'force-remov':50B 'handler':86B 'html':60B,74B 'img':82B 'javascript':39B 'live':88B 'markup':61B,78B 'mode':46B 'node':40B,67B 'onerror':85B 'packag':41B 'page':95B 'pars':71B 'place':45B 'rawtext':53B 're':70B 're-pars':69B 'remov':52B 'return':48B 'run':91B 'script':92B 'serial':65B 'still':58B 'style':54B 'text':57B 'whose':56B","search_c":"'0':317C '1':256C,262C '1122':438C '1336':174C '1700':385C '1714':386C '20':110C '26':112C '29.1.1':108C '3.4.15':98C,265C,383C '3.4.9':133C '30.0.1':106C '339':434C '385':427C 'add':143C 'allowlist':179C 'alongsid':428C 'alreadi':411C 'attribut':180C,230C,272C 'away':324C 'b':400C 'call':269C 'cannot':165C 'carri':412C 'caus':380C 'children':318C 'class':142C 'close':155C,424C 'comment':404C,454C 'config':114C 'console.log':213C,219C,224C,226C,232C,252C,257C 'const':206C,245C 'content':190C,242C,409C 'context':243C,292C 'contrast':301C,334C 'css':352C 'current':99C 'default':129C 'detach':140C,167C,223C,348C,446C 'detached-root':139C 'detect':419C 'differ':327C 'dist/purify.js':172C,382C 'div':248C 'dom':311C,314C 'dompurifi':97C 'dompurify.removed':443C 'dompurify.sanitize':115C,208C 'element':201C,414C,430C,444C 'emit':464C 'end':417C 'environ':96C 'exit':478C 'fail':154C 'fail-clos':153C 'fals':342C,350C 'fix':134C 'forc':195C 'force-remov':194C 'forceremov':158C,436C 'foreign':241C 'foreign-cont':240C 'form':191C 'fragment':315C 'fulli':322C 'gate':359C 'getattribut':260C 'handler':300C 'happen':366C 'html':237C,291C,469C 'img':254C,259C,295C 'independ':103C 'input':123C,306C 'intent':451C 'involv':244C 'isunsafenod':381C 'jsdom':105C 'length':255C 'line':173C,384C,426C,433C,437C 'liter':390C,422C,462C 'live':298C 'markup':431C 'materi':293C 'miss':185C 'mxss':362C 'neutral':231C,274C 'neutralizesubtre':170C 'node':116C,122C,161C,218C,267C,288C,305C,344C,371C,388C,441C 'node.js':109C 'non':178C 'non-allowlist':177C 'npm':100C 'null':221C,229C 'observ':263C 'onclick':228C,271C 'one':266C,268C 'onerror':261C,299C 'open':375C,395C,474C 'pars':285C 'parser':470C 'pass':171C 'path':151C,333C 'payload':203C,278C,320C 'place':118C,138C,149C,211C,309C,331C,477C 'plain':236C,290C,351C 'probe':246C,364C,425C,432C 'probe.innerhtml':249C 'probe.queryselector':258C 'probe.queryselectorall':253C 'protect':145C 'rawtext':187C,200C 're':284C,374C,394C,473C 're-open':373C,393C,472C 're-pars':283C 'record':439C 'releas':101C 'remov':164C,182C,196C,337C,347C,355C,357C,387C,449C,459C 'repars':238C,378C,398C 'reproduc':102C 'return':150C,207C,214C,287C,310C,313C,332C 'returned.outerhtml':250C 'root':141C,197C,379C 'rule':435C 'safe':124C,339C 'sanit':323C 'select':162C 'serial':281C,370C,463C 'shape':399C,457C 'sourc':403C 'specif':367C 'state':455C 'stay':349C 'strip':176C 'styleroot':205C,209C,215C 'styleroot.getattribute':227C 'styleroot.outerhtml':225C,251C 'styleroot.parentnode':220C 'styleroot.textcontent':233C 'subtre':183C 'summari':131C 'surviv':235C,279C 'tag':376C,396C,418C 'text':189C,202C,234C,277C,353C,363C,391C,407C,423C 'text-cont':188C 'text-on':406C 'th':479C 'trigger':338C 'true':119C,130C,212C,216C,222C,312C,316C 'two':144C 'typeerror':156C 'upstream':453C 'verbatim':280C,466C 'whose':389C 'window.document.body.appendchild':204C 'window.document.createelement':247C 'without':307C 'would':372C,392C 'x':111C,113C 'xml':126C,341C","cause_entry":null,"cause_path":null,"disclosures_checked_at":null,"first_public_at":"2026-09-23T15:17:45+00:00","kev":null,"cvss_vector":null,"cvss_by":null,"poc_count":0,"sources":[{"source":"ghsa-repo","external_id":"GHSA-6688-9rhm-gjv2","url":"https://github.com/cure53/DOMPurify/security/advisories/GHSA-6688-9rhm-gjv2","severity":"low","published_at":"2026-09-23T15:17:45+00:00","modified_at":"2026-09-23T15:17:45+00:00","first_seen_at":"2026-11-09T22:53:20+00:00","fetched_at":"2026-11-09T22:53:20+00:00","note":null}],"documents":[{"id":110517,"url":"https://github.com/cure53/DOMPurify/security/advisories/GHSA-6688-9rhm-gjv2","title":"Maintainer's advisory: github.com","kind":"vendor","source":"ghsa-repo","publisher":"vendor","text":null,"published_at":null,"relation":"advisory","confidence":null,"note":"Maintainer's advisory: github.com"}],"disclosures":[],"exploit_sources":[],"notice":"Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai"}