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
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
References
- https://github.com/cure53/DOMPurify/security/advisories/GHSA-cmwh-pvxp-8882 web
- https://github.com/cure53/DOMPurify/pull/1482 web
- https://github.com/cure53/DOMPurify/commit/011bc3b2fcc4be17aa76f9030e8668207717acb9 web
- https://github.com/cure53/DOMPurify package
- https://github.com/cure53/DOMPurify/releases/tag/3.4.11 web