GHSA-76MC-F452-CXCM
Vulnerability from github – Published: 2026-06-15 19:59 – Updated: 2026-06-15 19:59Hook mutation of data.allowedTags / data.allowedAttributes permanently pollutes DEFAULT_ALLOWED_TAGS / DEFAULT_ALLOWED_ATTR
CWE: CWE-501 (Trust Boundary Violation — hook-scoped mutation leaks to global default sets) via CWE-693 (Protection Mechanism Failure — the default allow-list is silently widened for all subsequent sanitize calls)
Summary
The data.allowedTags and data.allowedAttributes fields passed to uponSanitizeElement and uponSanitizeAttribute hooks are direct references to the library's live ALLOWED_TAGS / ALLOWED_ATTR sets. For sanitize calls that don't supply an explicit cfg.ALLOWED_TAGS / cfg.ALLOWED_ATTR array, those live sets are themselves direct references to the module-level DEFAULT_ALLOWED_TAGS / DEFAULT_ALLOWED_ATTR constants. A hook that mutates these fields — a natural-looking pattern for "allow X for this iteration" — permanently writes new entries into the default constants for the DOMPurify instance's lifetime. Every subsequent sanitize call that doesn't override the config inherits the widened defaults, so an attacker payload that uses the poisoned tag/attribute name survives sanitization. removeAllHooks(), clearConfig(), and even passing a fresh cfg: {} do not recover; only constructing a new DOMPurify instance does.
The maintainer's existing defense at src/purify.ts:696-700 explicitly clones DEFAULT_ALLOWED_TAGS before mutating it via cfg.ADD_TAGS (array form), demonstrating awareness of this exact class. The hook path remained uncovered.
Affected
- DOMPurify ≤ 3.4.5, including
mainat7996f1dc78eb8b7922388aed75d94a9f8fad9a36 - Any application that installs a hook on
uponSanitizeElementoruponSanitizeAttributethat writes todata.allowedTags[...] = trueordata.allowedAttributes[...] = trueand later sanitizes attacker-influenced content with default config (no explicitcfg.ALLOWED_TAGS/cfg.ALLOWED_ATTRarray)
Vulnerability details
[A] — data.allowedTags is a reference to ALLOWED_TAGS
src/purify.ts:1206-1209:
_executeHooks(hooks.uponSanitizeElement, currentNode, {
tagName,
allowedTags: ALLOWED_TAGS, // [A] direct reference; hook mutation
// mutates the very ALLOWED_TAGS the
// library checks on the next element
});
src/purify.ts:1494-1500 (the matching attribute hook):
const hookEvent = {
attrName: '',
attrValue: '',
keepAttr: true,
allowedAttributes: ALLOWED_ATTR, // [A'] same pattern
forceKeepAttr: undefined,
};
[B] — ALLOWED_TAGS = DEFAULT_ALLOWED_TAGS for default-cfg sanitize calls
src/purify.ts:527-531:
ALLOWED_TAGS =
objectHasOwnProperty(cfg, 'ALLOWED_TAGS') &&
arrayIsArray(cfg.ALLOWED_TAGS)
? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc)
: DEFAULT_ALLOWED_TAGS; // [B] reference assignment; ALLOWED_TAGS
// IS the DEFAULT_ALLOWED_TAGS object
(The ALLOWED_ATTR = DEFAULT_ALLOWED_ATTR path at :532-536 is symmetric.)
The mismatch
A hook author who writes data.allowedTags['script'] = true reasonably expects per-call scope — the API name is "data", suggesting per-event payload. But [A] makes this a direct reference, and [B] makes that reference equal to the module-level default for the common default-cfg path. The hook's mutation therefore writes to a constant that every subsequent default-cfg sanitize call rebinds to.
The maintainer already recognized this class for the ADD_TAGS array path — src/purify.ts:696-700:
} else if (arrayIsArray(cfg.ADD_TAGS)) {
if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) {
ALLOWED_TAGS = clone(ALLOWED_TAGS); // explicitly clone DEFAULT before
// mutating to avoid this pollution
}
addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc);
}
The same defensive clone is missing from the hook code paths.
Proof of concept
// 1) fresh DOMPurify, default config — script is blocked
DOMPurify.sanitize('<svg><script>alert(1)</script></svg>');
// → "<svg></svg>"
// 2) install a hook that mutates data.allowedTags (natural-looking pattern)
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
data.allowedTags['script'] = true;
});
// 3) one sanitize call WITH the hook — script survives (expected during the hook)
DOMPurify.sanitize('<svg><script>alert(1)</script></svg>');
// → "<svg><script>alert(1)</script></svg>"
// 4) remove the hook
DOMPurify.removeAllHooks();
DOMPurify.clearConfig();
// 5) sanitize attacker content with default config — POLLUTION PERSISTS
DOMPurify.sanitize('<svg><script>alert(1)</script></svg>');
// → "<svg><script>alert(1)</script></svg>" ← script survived without any hook
// 6) the only recovery: create a fresh DOMPurify instance
const fresh = DOMPurify(window);
fresh.sanitize('<svg><script>alert(1)</script></svg>');
// → "<svg></svg>" ← clean
Observed (Chromium 148.0.7778.96, DOMPurify HEAD 7996f1d):
| step | input | output | bypass? |
|---|---|---|---|
| 1 fresh baseline | <svg><script>__</script></svg> |
<svg></svg> |
no |
| 1b fresh baseline | <a onclick=__>x</a> |
<a>x</a> |
no |
| 2 with hook (script) | <svg><script>__</script></svg> |
<svg><script>__</script></svg> |
yes (expected) |
| 2b with hook (onclick) | <a onclick=__>x</a> |
<a onclick="__">x</a> |
yes (expected) |
3 after removeAllHooks() |
same | <svg><script>__</script></svg> |
YES (pollution) |
3b after removeAllHooks() |
same | <a onclick="__">x</a> |
YES (pollution) |
4 after clearConfig() |
same | <svg><script>__</script></svg> |
YES |
4b after clearConfig() |
same | <a onclick="__">x</a> |
YES |
5 explicit restrictive cfg.ALLOWED_TAGS=['svg'] |
same | <svg></svg> |
no (cloned set) |
| 6 back to no cfg | same | <svg><script>__</script></svg> |
YES |
| 6b back to no cfg | same | <a onclick="__">x</a> |
YES |
7 fresh DOMPurify(window) instance |
same | <svg></svg> |
no |
| 7b fresh instance | <a onclick=__>x</a> |
<a>x</a> |
no |
Impact
Direct
Any application using DOMPurify that has any registered hook with the pattern data.allowedTags[...] = true or data.allowedAttributes[...] = true. The hook need not be designed to be permissive — it might be intended to temporarily allow a custom tag for one specific element shape. After the hook has executed even once, every subsequent default-config sanitize call carries the widened defaults, including:
- attacker content rendered via separate code paths (e.g., the same library serving a comments section and a profile bio, where the bio uses the hook and the comments use plain
DOMPurify.sanitize(text)) - third-party libraries that call
DOMPurify.sanitizeon the same instance
The bypass survives DOMPurify.removeAllHooks() and DOMPurify.clearConfig() — the obvious "reset" calls a dev would reach for. Detection requires reading the DEFAULT_ALLOWED_TAGS / DEFAULT_ALLOWED_ATTR sets directly, which are not part of the public API.
Indirect / second-order
- Editor / preview libraries that compose with DOMPurify — if any consumer registers a hook that mutates
data.allowedTags, every other consumer's sanitize calls inherit the widening. - Test suites that exercise multiple sanitize configurations — once a test's hook pollutes the defaults, later tests that assume default behavior may pass with widened defaults and miss real regressions.
- Long-running servers (SSR, edge functions) that reuse a single DOMPurify instance — pollution accumulates over the process lifetime.
Why the existing maintainer defense for ADD_TAGS doesn't catch this
src/purify.ts:696-700 already documents awareness:
} else if (arrayIsArray(cfg.ADD_TAGS)) {
if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) {
ALLOWED_TAGS = clone(ALLOWED_TAGS);
}
addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc);
}
The clone-before-mutate pattern is exactly what's needed at the hook callsites (:1206-1209 and :1494-1500) but was not extended there. The new entries this report's bypass adds to the defaults survive the same way ADD_TAGS array entries would have survived before that fix landed.
Suggested fix
Three minimal-impact options, in order of preference:
- Hand the hook a defensive copy (most surgical):
ts
_executeHooks(hooks.uponSanitizeElement, currentNode, {
tagName,
allowedTags: { ...ALLOWED_TAGS }, // shallow copy; mutations stay scoped
});
Doc note: "data.allowedTags is a snapshot; to widen the live set, use cfg.ADD_TAGS or set the value to true in the snapshot and check the snapshot from a subsequent attribute hook." Hooks that read it for inspection still work; hooks that intended cross-call mutation must be rewritten to use a proper config path (which is the correct API anyway).
-
Clone-on-write inside the hook path, mirroring the existing
ADD_TAGSdefense at:696-700: detect thatALLOWED_TAGS === DEFAULT_ALLOWED_TAGSafter the hook returns, and if so, replace it with a clone for subsequent processing. This preserves the live-mutation semantics for in-call effects while preventing cross-call leakage. -
Lazy-clone
ALLOWED_TAGS/ALLOWED_ATTRfrom defaults on first mutation: install a Proxy or accessor that triggers a clone before mutation. Largest surface area, but bulletproof.
Option (1) is the cleanest API contract: hook event objects should be event-local, never references to library-internal state.
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "dompurify"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.4.7"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-501",
"CWE-693"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T19:59:09Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "# Hook mutation of `data.allowedTags` / `data.allowedAttributes` permanently pollutes `DEFAULT_ALLOWED_TAGS` / `DEFAULT_ALLOWED_ATTR`\n\n**CWE**: CWE-501 (Trust Boundary Violation \u2014 hook-scoped mutation leaks to global default sets) via CWE-693 (Protection Mechanism Failure \u2014 the default allow-list is silently widened for all subsequent sanitize calls)\n\n## Summary\n\nThe `data.allowedTags` and `data.allowedAttributes` fields passed to `uponSanitizeElement` and `uponSanitizeAttribute` hooks are **direct references** to the library\u0027s live `ALLOWED_TAGS` / `ALLOWED_ATTR` sets. For sanitize calls that don\u0027t supply an explicit `cfg.ALLOWED_TAGS` / `cfg.ALLOWED_ATTR` array, those live sets are themselves direct references to the module-level `DEFAULT_ALLOWED_TAGS` / `DEFAULT_ALLOWED_ATTR` constants. A hook that mutates these fields \u2014 a natural-looking pattern for \"allow `X` for this iteration\" \u2014 permanently writes new entries into the default constants for the DOMPurify instance\u0027s lifetime. Every subsequent sanitize call that doesn\u0027t override the config inherits the widened defaults, so an attacker payload that uses the poisoned tag/attribute name survives sanitization. `removeAllHooks()`, `clearConfig()`, and even passing a fresh `cfg: {}` do not recover; only constructing a new DOMPurify instance does.\n\nThe maintainer\u0027s existing defense at `src/purify.ts:696-700` explicitly clones `DEFAULT_ALLOWED_TAGS` before mutating it via `cfg.ADD_TAGS` (array form), demonstrating awareness of this exact class. The hook path remained uncovered.\n\n## Affected\n\n- DOMPurify \u2264 3.4.5, including `main` at `7996f1dc78eb8b7922388aed75d94a9f8fad9a36`\n- Any application that installs a hook on `uponSanitizeElement` or `uponSanitizeAttribute` that writes to `data.allowedTags[...] = true` or `data.allowedAttributes[...] = true` and later sanitizes attacker-influenced content with default config (no explicit `cfg.ALLOWED_TAGS` / `cfg.ALLOWED_ATTR` array)\n\n## Vulnerability details\n\n### [A] \u2014 `data.allowedTags` is a reference to `ALLOWED_TAGS`\n\n`src/purify.ts:1206-1209`:\n\n```ts\n_executeHooks(hooks.uponSanitizeElement, currentNode, {\n tagName,\n allowedTags: ALLOWED_TAGS, // [A] direct reference; hook mutation\n // mutates the very ALLOWED_TAGS the\n // library checks on the next element\n});\n```\n\n`src/purify.ts:1494-1500` (the matching attribute hook):\n\n```ts\nconst hookEvent = {\n attrName: \u0027\u0027,\n attrValue: \u0027\u0027,\n keepAttr: true,\n allowedAttributes: ALLOWED_ATTR, // [A\u0027] same pattern\n forceKeepAttr: undefined,\n};\n```\n\n### [B] \u2014 `ALLOWED_TAGS = DEFAULT_ALLOWED_TAGS` for default-cfg sanitize calls\n\n`src/purify.ts:527-531`:\n\n```ts\nALLOWED_TAGS =\n objectHasOwnProperty(cfg, \u0027ALLOWED_TAGS\u0027) \u0026\u0026\n arrayIsArray(cfg.ALLOWED_TAGS)\n ? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc)\n : DEFAULT_ALLOWED_TAGS; // [B] reference assignment; ALLOWED_TAGS\n // IS the DEFAULT_ALLOWED_TAGS object\n```\n\n(The `ALLOWED_ATTR = DEFAULT_ALLOWED_ATTR` path at `:532-536` is symmetric.)\n\n### The mismatch\n\nA hook author who writes `data.allowedTags[\u0027script\u0027] = true` reasonably expects per-call scope \u2014 the API name is *\"data\"*, suggesting per-event payload. But [A] makes this a direct reference, and [B] makes that reference equal to the module-level default for the common default-cfg path. The hook\u0027s mutation therefore writes to a *constant* that every subsequent default-cfg sanitize call rebinds to.\n\nThe maintainer already recognized this class for the `ADD_TAGS` array path \u2014 `src/purify.ts:696-700`:\n\n```ts\n} else if (arrayIsArray(cfg.ADD_TAGS)) {\n if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) {\n ALLOWED_TAGS = clone(ALLOWED_TAGS); // explicitly clone DEFAULT before\n // mutating to avoid this pollution\n }\n addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc);\n}\n```\n\nThe same defensive clone is missing from the hook code paths.\n\n## Proof of concept\n\n```js\n// 1) fresh DOMPurify, default config \u2014 script is blocked\nDOMPurify.sanitize(\u0027\u003csvg\u003e\u003cscript\u003ealert(1)\u003c/script\u003e\u003c/svg\u003e\u0027);\n// \u2192 \"\u003csvg\u003e\u003c/svg\u003e\"\n\n// 2) install a hook that mutates data.allowedTags (natural-looking pattern)\nDOMPurify.addHook(\u0027uponSanitizeElement\u0027, (node, data) =\u003e {\n data.allowedTags[\u0027script\u0027] = true;\n});\n\n// 3) one sanitize call WITH the hook \u2014 script survives (expected during the hook)\nDOMPurify.sanitize(\u0027\u003csvg\u003e\u003cscript\u003ealert(1)\u003c/script\u003e\u003c/svg\u003e\u0027);\n// \u2192 \"\u003csvg\u003e\u003cscript\u003ealert(1)\u003c/script\u003e\u003c/svg\u003e\"\n\n// 4) remove the hook\nDOMPurify.removeAllHooks();\nDOMPurify.clearConfig();\n\n// 5) sanitize attacker content with default config \u2014 POLLUTION PERSISTS\nDOMPurify.sanitize(\u0027\u003csvg\u003e\u003cscript\u003ealert(1)\u003c/script\u003e\u003c/svg\u003e\u0027);\n// \u2192 \"\u003csvg\u003e\u003cscript\u003ealert(1)\u003c/script\u003e\u003c/svg\u003e\" \u2190 script survived without any hook\n\n// 6) the only recovery: create a fresh DOMPurify instance\nconst fresh = DOMPurify(window);\nfresh.sanitize(\u0027\u003csvg\u003e\u003cscript\u003ealert(1)\u003c/script\u003e\u003c/svg\u003e\u0027);\n// \u2192 \"\u003csvg\u003e\u003c/svg\u003e\" \u2190 clean\n```\n\nObserved (Chromium 148.0.7778.96, DOMPurify HEAD `7996f1d`):\n\n| step | input | output | bypass? |\n|---|---|---|---|\n| 1 fresh baseline | `\u003csvg\u003e\u003cscript\u003e__\u003c/script\u003e\u003c/svg\u003e` | `\u003csvg\u003e\u003c/svg\u003e` | no |\n| 1b fresh baseline | `\u003ca onclick=__\u003ex\u003c/a\u003e` | `\u003ca\u003ex\u003c/a\u003e` | no |\n| 2 with hook (script) | `\u003csvg\u003e\u003cscript\u003e__\u003c/script\u003e\u003c/svg\u003e` | `\u003csvg\u003e\u003cscript\u003e__\u003c/script\u003e\u003c/svg\u003e` | yes (expected) |\n| 2b with hook (onclick) | `\u003ca onclick=__\u003ex\u003c/a\u003e` | `\u003ca onclick=\"__\"\u003ex\u003c/a\u003e` | yes (expected) |\n| 3 after `removeAllHooks()` | same | `\u003csvg\u003e\u003cscript\u003e__\u003c/script\u003e\u003c/svg\u003e` | **YES (pollution)** |\n| 3b after `removeAllHooks()` | same | `\u003ca onclick=\"__\"\u003ex\u003c/a\u003e` | **YES (pollution)** |\n| 4 after `clearConfig()` | same | `\u003csvg\u003e\u003cscript\u003e__\u003c/script\u003e\u003c/svg\u003e` | **YES** |\n| 4b after `clearConfig()` | same | `\u003ca onclick=\"__\"\u003ex\u003c/a\u003e` | **YES** |\n| 5 explicit restrictive `cfg.ALLOWED_TAGS=[\u0027svg\u0027]` | same | `\u003csvg\u003e\u003c/svg\u003e` | no (cloned set) |\n| 6 back to no cfg | same | `\u003csvg\u003e\u003cscript\u003e__\u003c/script\u003e\u003c/svg\u003e` | **YES** |\n| 6b back to no cfg | same | `\u003ca onclick=\"__\"\u003ex\u003c/a\u003e` | **YES** |\n| 7 fresh `DOMPurify(window)` instance | same | `\u003csvg\u003e\u003c/svg\u003e` | no |\n| 7b fresh instance | `\u003ca onclick=__\u003ex\u003c/a\u003e` | `\u003ca\u003ex\u003c/a\u003e` | no |\n\n## Impact\n\n### Direct\n\nAny application using `DOMPurify` that has any registered hook with the pattern `data.allowedTags[...] = true` or `data.allowedAttributes[...] = true`. The hook need not be designed to be permissive \u2014 it might be intended to *temporarily* allow a custom tag for one specific element shape. After the hook has executed even once, every subsequent default-config sanitize call carries the widened defaults, including:\n\n- attacker content rendered via separate code paths (e.g., the same library serving a comments section and a profile bio, where the bio uses the hook and the comments use plain `DOMPurify.sanitize(text)`)\n- third-party libraries that call `DOMPurify.sanitize` on the same instance\n\nThe bypass survives `DOMPurify.removeAllHooks()` and `DOMPurify.clearConfig()` \u2014 the obvious \"reset\" calls a dev would reach for. Detection requires reading the `DEFAULT_ALLOWED_TAGS` / `DEFAULT_ALLOWED_ATTR` sets directly, which are not part of the public API.\n\n### Indirect / second-order\n\n- **Editor / preview libraries** that compose with DOMPurify \u2014 if any consumer registers a hook that mutates `data.allowedTags`, every other consumer\u0027s sanitize calls inherit the widening.\n- **Test suites** that exercise multiple sanitize configurations \u2014 once a test\u0027s hook pollutes the defaults, later tests that assume default behavior may pass with widened defaults and miss real regressions.\n- **Long-running servers** (SSR, edge functions) that reuse a single DOMPurify instance \u2014 pollution accumulates over the process lifetime.\n\n### Why the existing maintainer defense for `ADD_TAGS` doesn\u0027t catch this\n\n`src/purify.ts:696-700` already documents awareness:\n\n```ts\n} else if (arrayIsArray(cfg.ADD_TAGS)) {\n if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) {\n ALLOWED_TAGS = clone(ALLOWED_TAGS);\n }\n addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc);\n}\n```\n\nThe clone-before-mutate pattern is exactly what\u0027s needed at the hook callsites (`:1206-1209` and `:1494-1500`) but was not extended there. The new entries this report\u0027s bypass adds to the defaults survive the same way `ADD_TAGS` array entries would have survived before that fix landed.\n\n## Suggested fix\n\nThree minimal-impact options, in order of preference:\n\n1. **Hand the hook a defensive copy** (most surgical):\n\n ```ts\n _executeHooks(hooks.uponSanitizeElement, currentNode, {\n tagName,\n allowedTags: { ...ALLOWED_TAGS }, // shallow copy; mutations stay scoped\n });\n ```\n\n Doc note: \"`data.allowedTags` is a snapshot; to widen the live set, use `cfg.ADD_TAGS` or set the value to true in the snapshot and check the snapshot from a subsequent attribute hook.\" Hooks that read it for inspection still work; hooks that intended cross-call mutation must be rewritten to use a proper config path (which is the correct API anyway).\n\n2. **Clone-on-write inside the hook path**, mirroring the existing `ADD_TAGS` defense at `:696-700`: detect that `ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS` after the hook returns, and if so, replace it with a clone for subsequent processing. This preserves the live-mutation semantics for in-call effects while preventing cross-call leakage.\n\n3. **Lazy-clone `ALLOWED_TAGS`/`ALLOWED_ATTR` from defaults on first mutation**: install a Proxy or accessor that triggers a clone before mutation. Largest surface area, but bulletproof.\n\nOption (1) is the cleanest API contract: hook event objects should be event-local, never references to library-internal state.",
"id": "GHSA-76mc-f452-cxcm",
"modified": "2026-06-15T19:59:09Z",
"published": "2026-06-15T19:59:09Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/security/advisories/GHSA-76mc-f452-cxcm"
},
{
"type": "PACKAGE",
"url": "https://github.com/cure53/DOMPurify"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "DOMPurify: Hook mutation of `data.allowedTags` / `data.allowedAttributes` permanently pollutes `DEFAULT_ALLOWED_TAGS` / `DEFAULT_ALLOWED_ATTR`"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.