GHSA-x4vx-rjvf-j5p4 DOMPurify: `IN_PLACE` mode trusts attacker-controlled `nodeName` on live non-form nodes, allowing script retention and XSS via attacker-supplied DOM objects
SummaryWhen `DOMPurify.sanitize(root, { IN_PLACE: true })` is called on an attacker-supplied live DOM node, `DOMPurify` still trusts `currentNode.nodeName` for non-`form` nodes in the main `_sanitizeElements` pipeline. A real `<script>` child node whose observable `nodeName` is attacker-controlled can therefore be misclassified as an allowed element and retained. When the sanitized tree is inserted into a live document, the script executes.This affects current `3.4.6`. The recent `IN_PLACE` hardening work covers clobbered `form` handling and foreign-realm shadow/template traversal, but does not harden the main per-node element decision for hostile non-`form` live nodes.AffectedDOMPurify `3.4.6`Any caller that does `DOMPurify.sanitize(node, { IN_PLACE: true })` on attacker-supplied live DOM nodesVerified attacker-controlled node sources:same-origin `iframe` → live node passed by referencesame-origin `window.open()` popup → live node passed by referencesame-origin foreign node adopted into the host document via `document.adoptNode(node)` and then sanitized in-placeNot affected:String-input `DOMPurify.sanitize(dirtyString)`Vulnerability detailsCode paths[A] — `_sanitizeElements` uses the instance-visible `nodeName` for the allow/forbid decision:For non-`form` nodes, `_isClobbered(currentNode)` returns `false` early. The subsequent element decision therefore trusts `currentNode.nodeName` directly.[B] — `_isClobbered` is `form`-specific:The hardening is intentionally scoped to `form`. Non-`form` nodes are not checked for divergence between the instance-visible property view and the trusted prototype getter view.Why the bypass worksThe attack does **not** depend on string HTML parsing. It depends on a hostile live DOM object crossing a trust boundary into `DOMPurify`'s `IN_PLACE` pipeline.If the attacker controls a same-origin subcontext (`iframe` or popup), they can prepare a real DOM subtree there and then pass the live node object by reference to a host page that trusts `DOMPurify.sanitize(node, { IN_PLACE: true })` as its final sanitization step.For the verified primitive below:the real child node is `<script>`its script text is attacker-controlledthe observable `nodeName` is attacker-controlled and made to appear as `"DIV"``_sanitizeElements` therefore classifies the real `<script>` child as an allowed elementthe real `<script>` survives in the sanitized tree and executes on insertionThis primitive survives:direct reference passing`document.adoptNode(node)` followed by `IN_PLACE`It does **not** survive:`importNode``cloneNode`because those paths materialize a fresh node and discard the hostile object semantics.Proof of concept(1) Minimal — runnable in a single browser context(2) End-to-end — PlaywrightObserved:Chromium / Firefox / WebKitImpactDirectXSS via retained real `<script>` nodes inside attacker-supplied live DOM objects.Any consumer that uses `DOMPurify.sanitize(node, { IN_PLACE: true })` as a security boundary for live DOM objects supplied by a lower-trust same-origin subcontext is vulnerable.The typical pattern is:If `foreignNode` is a hostile live DOM object whose real child is `<script>` but whose observable `nodeName` is attacker-controlled, the sanitized output still contains the real script node when re-inserted into the live document.Indirect / second-orderApplications that accept same-origin plugin / extension / widget DOM and rely on `IN_PLACE` as the final sanitization stepEditor or design-tool architectures where lower-trust subcontexts submit live DOM subtrees to a higher-trust host for in-place sanitizationSuggested fixTwo minimal-risk options:Stop trusting instance-visible `nodeName` for the element decision in `IN_PLACE`.Use the cached prototype getter (or another trusted realm-safe primitive) for the allow/forbid decision, just as the recent hardening already does for selected root and shadow-root checks.In other words, the main pipeline should not do:on hostile live objects.Generalize hostile-node detection beyond `form`.The current `_isClobbered()` logic is `form`-specific. A more defensive approach would reject or strictly sanitize any `IN_PLACE` node whose instance-visible critical properties diverge from the trusted prototype getter view, at least for:`nodeName``attributes``childNodes`Either approach would close the verified primitive above.