');\n// → \"\"\n\n// 2) install a hook that mutates data.allowedTags (natural-looking pattern)\nDOMPurify.addHook('uponSanitizeElement', (node, data) => {\n data.allowedTags['script'] = true;\n});\n\n// 3) one sanitize call WITH the hook — script survives (expected during the hook)\nDOMPurify.sanitize('');\n// → \"\"\n\n// 4) remove the hook\nDOMPurify.removeAllHooks();\nDOMPurify.clearConfig();\n\n// 5) sanitize attacker content with default config — POLLUTION PERSISTS\nDOMPurify.sanitize('');\n// → \"\" ← script survived without any hook\n\n// 6) the only recovery: create a fresh DOMPurify instance\nconst fresh = DOMPurify(window);\nfresh.sanitize('');\n// → \"\" ← clean\n```\n\nObserved (Chromium 148.0.7778.96, DOMPurify HEAD `7996f1d`):\n\n| step | input | output | bypass? |\n|---|---|---|---|\n| 1 fresh baseline | `` | `` | no |\n| 1b fresh baseline | `x` | `x` | no |\n| 2 with hook (script) | `` | `` | yes (expected) |\n| 2b with hook (onclick) | `x` | `x` | yes (expected) |\n| 3 after `removeAllHooks()` | same | `` | **YES (pollution)** |\n| 3b after `removeAllHooks()` | same | `x` | **YES (pollution)** |\n| 4 after `clearConfig()` | same | `` | **YES** |\n| 4b after `clearConfig()` | same | `x` | **YES** |\n| 5 explicit restrictive `cfg.ALLOWED_TAGS=['svg']` | same | `` | no (cloned set) |\n| 6 back to no cfg | same | `` | **YES** |\n| 6b back to no cfg | same | `x` | **YES** |\n| 7 fresh `DOMPurify(window)` instance | same | `` | no |\n| 7b fresh instance | `x` | `x` | 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 — 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()` — 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 — if any consumer registers a hook that mutates `data.allowedTags`, every other consumer's sanitize calls inherit the widening.\n- **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.\n- **Long-running servers** (SSR, edge functions) that reuse a single DOMPurify instance — pollution accumulates over the process lifetime.\n\n### Why the existing maintainer defense for `ADD_TAGS` doesn't 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'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.\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.","datePublished":"2026-06-15","dateModified":"2026-09-10","author":{"@type":"Organization","name":"Safeguard"},"url":"https://gold.safeguard.sh/vulnerability/CVE-2026-65902","about":{"@type":"Thing","name":"CVE-2026-65902","additionalProperty":[{"@type":"PropertyValue","name":"severity","value":"medium"},{"@type":"PropertyValue","name":"cvss","value":"6.1"},{"@type":"PropertyValue","name":"fixedVersion","value":"3.4.7"}]}}

Loading vulnerability details...