GitHub Advisoryadvisory · npm

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)

MEDIUM Published 2026-06-18 Modified 2026-07-23 Official page ↗
Aliases
CVE-2026-65898
Ecosystem
npm
Packages
dompurify
Severity
Moderate
CVSS vector
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
Weaknesses
CWE-471, CWE-665, CWE-79
Reviewed
2026-06-18

Affected packages

• npm dompurify: < 3.4.11 → fixed in 3.4.11

Description

Summary DOMPurify 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`. That 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. If 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. --- Affected configuration (preconditions) The vulnerability is triggered when an application does both: 1. Calls `DOMPurify.setConfig(...)` once (the recommended pattern for a fixed, persistent policy), and 2. Registers an `uponSanitizeAttribute` hook that writes `data.allowedAttributes[name] = true` to conditionally allow an attribute (e.g. only for elements bearing a trust marker). This 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. --- Root cause (source: `src/purify.ts`, v3.4.10) The 3.4.7 clone-guard — only inside `_parseConfig`: ``` // src/purify.ts _parseConfig() (lines ~950-968) // "if a hook is registered AND the set still points at the default constant, clone it. // The hook then mutates the clone ... and the next default-cfg call rebinds to the untouched original." if ( ... && hooks.uponSanitizeAttribute.length > 0) { ALLOWED_TAGS = clone(ALLOWED_TAGS); // line 961 } if ( ... hooks.uponSanitizeAttribute.length > 0 ... ) { ALLOWED_ATTR = clone(ALLOWED_ATTR); // line 968 } ``` `sanitize()` skips `_parseConfig` on the persistent-config path: ``` // src/purify.ts DOMPurify.sanitize() (line 2369) if (!SET_CONFIG) { _parseConfig(cfg); // { if (node.getAttribute && node.getAttribute('data-trusted') === '1') { data.allowedAttributes['onerror'] = true; // intended: trusted-only } }); // 1) A trusted widget is rendered once DP.sanitize(''); // 2) Later, ATTACKER-controlled content (NO data-trusted) is sanitized on the same instance console.log(DP.sanitize('')); // OUTPUT: XSS ``` PoC 2 — it is a DOMPurify state-leak, not "the app allowed `on*`" (attribute-agnostic) ```js // Same setConfig + hook shape, but the hook allows a BENIGN attribute (title). // The leak is identical -> the defect is a shared-state mutation in DOMPurify, // independent of which attribute the hook touches. DP.setConfig({ ALLOWED_TAGS: ['span'], ALLOWED_ATTR: [] }); DP.addHook('uponSanitizeAttribute', (n, d) => { if (n.getAttribute && n.getAttribute('data-trusted') === '1') d.allowedAttributes['title'] = true; }); DP.sanitize('x'); console.log(DP.sanitize('x')); // -> x (leaked) ``` PoC 3 — control: WITHOUT `setConfig()` the 3.4.7 guard holds ```js const DP2 = createDOMPurify(new JSDOM('').window); DP2.addHook('uponSanitizeAttribute', (n, d) => { if (n.getAttribute && n.getAttribute('data-trusted') === '1') d.allowedAttributes['onerror'] = true; }); DP2.sanitize('', { ALLOWED_TAGS: ['img'], ALLOWED_ATTR: ['src'] }); console.log(DP2.sanitize('', { ALLOWED_TAGS: ['img'], ALLOWED_ATTR: ['src'] })); // OUTPUT: ` and ``, not only the originally-blessed ``. • It persists for the instance lifetime (survived 5/5 subsequent default calls). • `clearConfig()` does restore a clean state (this is the bound of the impact). --- Impact Stored 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` `` fires with no user interaction (browser-confirmed; see Validation). 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. --- Realistic attack scenario A platform mixes admin-authored interactive widgets with user-generated content through one sanitizer instance: 1. The app installs a persistent baseline policy via `setConfig({ ALLOWED_TAGS: [...], ALLOWED_ATTR: [...] })`. 2. 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. 3. An admin renders one trusted widget. From that point on, every user-submit…

Related

Alias · CVE (1)

  • CVE-2026-65898 DOMPurify before 3.4.11 fails to clone the ALLOWED_ATTR allowlist when setConfig() is used with an uponSanitizeAttribute hook, allowing the… vulnerability

Weakness · CWE (3)

  • CWE-471 Modification of Assumed-Immutable Data (MAID) weakness
  • CWE-665 Improper Initialization weakness
  • CWE-79 Improper Neutralization of Input During Web Page Generation weakness

References