GHSA-cg2g-xh64-v4q7
DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes
- Risk score
- 70/100
- CVSS
- 9.2 v4.0
- Published
- 22 Oct 2026
Open in the live forecast Today's forecast
What to know
- What it is
- DOMPurify has a vulnerability when configured with SAFE_FOR_TEMPLATES: true and a DOM-returning mode, allowing an attacker to bypass sanitization and execute template expressions. An attacker can supply a DOM node with a shadow root to split an expression across text nodes, which will be evaluated by a downstream template engine.
- Affected
- {'products': ['DOMPurify'], 'packages': [{'ecosystem': 'npm', 'name': 'dompurify', 'vulnerable': '>= 3.4.4, <= 3.4.16'}], 'versions': ['3.4.4', '3.4.5', '3.4.6', '3.4.7', '3.4.8', '3.4.9', '3.4.10', '3.4.11', '3.4.12', '3.4.13', '3.4.14', '3.4.15', '3.4.16']}
- Fix
- Upgrade to version 3.4.17 or later
- Exploitation
- Public proof of concept
Not confirmed. 3.4.5, 3.4.6, 3.4.7, 3.4.8, 3.4.9, 3.4.10, 3.4.11, 3.4.12 isn't in the advisory. Check the vendor's advisory before acting on this.
Story map
At a glance
dompurify: Amber, patch this week. DOMPurify has a vulnerability when configured with SAFE_FOR_TEMPLATES: true and a DOM-returning mode, allowing an attacker to bypass sanitization and execute template expressions. An attacker can supply a DOM node with a shadow root to split an expression across text nodes, which will be evaluated by a downstream template engine.
The flaws
- GHSA-cg2g-xh64-v4q7: DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes (CVSS 9.2)
- GHSA-mrv2-3pqr-j5jr: DOMPurify IN_PLACE: hook that removes a parent or sibling leaves the detached subtree's event handlers armed (incomplete fix for GHSA-p98j-92pf-mc4p) (CVSS 7.6)
The fix
Fix: Upgrade to version 3.4.17 or later
Who is exposed: {'products': ['DOMPurify'], 'packages': [{'ecosystem': 'npm', 'name': 'dompurify', 'vulnerable': '>= 3.4.4, <= 3.4.16'}], 'versions': ['3.4.4', '3.4.5', '3.4.6', '3.4.7', '3.4.8', '3.4.9', '3.4.10', '3.4.11', '3.4.12', '3.4.13', '3.4.14', '3.4.15', '3.4.16']}
Where it stands
- Public proof of concept
Connects to
- Same weakness: Cross-site scripting (CWE-79). 399 other warnings in 30 days
Why it is Amber
- CVSS 9.2 critical
- Public exploit or proof of concept referenced
- Remotely exploitable without logging in
- On your watchlist: npm
Affected packages
| Package | Ecosystem | Vulnerable | Fixed in |
|---|---|---|---|
| dompurify | npm | >= 3.4.4, <= 3.4.16 | 3.4.17 |
References
- https://github.com/advisories/GHSA-cg2g-xh64-v4q7
- https://github.com/cure53/DOMPurify/security/advisories/GHSA-cg2g-xh64-v4q7
- https://github.com/cure53/DOMPurify/pull/1672
- https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575
- https://github.com/cure53/DOMPurify/releases/tag/3.4.17
- https://github.com/advisories/GHSA-cg2g-xh64-v4q7
Original description
## Summary When DOMPurify is configured with `SAFE_FOR_TEMPLATES: true` together with a DOM-returning mode (`RETURN_DOM: true`, `RETURN_DOM_FRAGMENT: true`, or `IN_PLACE: true`), template expressions such as `{{...}}`, `${...}`, and `<%...%>` survive sanitization inside the text content of an attached shadow root. The post-walk safety net `_scrubTemplateExpressions` (`src/purify.ts:1717`) was extended to recurse into `<template>.content`, but it never recurses into attached shadow roots. An attacker who can supply a DOM node carrying a shadow root (the `IN_PLACE` and shadow-aware `RETURN_DOM` surfaces) can split an expression across text nodes separated by disallowed elements; after those elements are removed the fragments become adjacent and serialize into a complete expression that a downstream template engine will evaluate. ## Root Cause `SAFE_FOR_TEMPLATES` neutralizes template expressions through two mechanisms: a per-text-node strip during the main walk (`_stripTemplateExpressions`, applied in `_sanitizeElements` at `src/purify.ts:2246`), and a final post-walk pass (`_scrubTemplateExpressions`, `src/purify.ts:1717`) that normalizes adjacent text nodes and re-strips expressions that only form after merging. The per-node strip cannot catch an expression that is split into individual fragments, because each fragment fails the delimiter regexes. The post-walk pass is the only mechanism that would catch the merged form, but it is structurally unable to reach shadow root content. ```text _scrubTemplateExpressions (src/purify.ts:1717-1751): - node.normalize() at line 1718 does not merge text nodes inside shadow roots (DOM spec). - NodeIterator at lines 1723-1731 does not descend into shadow trees (DOM spec). - Explicit recursion at lines 1743-1750 enters <template>.content only, via querySelectorAll('template'); there is no equivalent recursion into attached shadow roots. Shadow roots are sanitized earlier by _sanitizeShadowDOM (src/purify.ts:2710), which applies the per-node strip but never performs the post-walk normalize-and-rescrub. ``` Shadow root text nodes are therefore sanitized only per-node and never re-examined as a merged unit. ## Impact An attacker who controls DOM-node input containing an attached shadow root can inject template expressions that survive sanitization and are later evaluated by the application's template engine. In client-side contexts this yields cross-site scripting; in a server-side template context it can escalate to template injection. Exploitation requires non-default application configuration: the application must enable `SAFE_FOR_TEMPLATES` and a DOM-returning mode, pass a DOM node that already has an attached shadow root, and feed the sanitized shadow root content into a template evaluator. The shadow root does not have to be built programmatically with `attachShadow`: in engines that support declarative shadow DOM, attacker-supplied markup containing `<template shadowrootmode="open">` parsed through `setHTMLUnsafe` or `Document.parseHTMLUnsafe` produces the same attached shadow root, so a plain HTML string reaches the gap once the application is configured this way. Demonstrated capability is injection of a complete, attacker-chosen expression into post-sanitization shadow root content; the resulting script execution and any confidentiality or integrity consequence depend on the downstream engine's evaluation context. ## Proof of Concept ```text Preconditions: - DOMPurify built from the affected source (dist/purify.js or an ESM/CJS bundle). - A browser (or DOM implementation) with shadow DOM support. - Application constructs a DOM node with an attached shadow root before sanitizing, and later evaluates the shadow root's text content as a template. ``` Build a host element whose shadow root holds a template expression split across text nodes separated by disallowed elements, then sanitize in place with `SAFE_FOR_TEMPLATES`: ```javascript const host = document.createElement('div'); const root = host.attachShadow({ mode: 'open' }); // Split "${alert(document.domain)}" so no single fragment matches a regex. // <object> is not in the default ALLOWED_TAGS and is removed during sanitization, // leaving the surrounding text-node siblings adjacent. root.appendChild(document.createTextNode('$')); root.appendChild(document.createElement('object')); root.appendChild(document.createTextNode('{alert(document.domain)}')); DOMPurify.sanitize(host, { IN_PLACE: true, SAFE_FOR_TEMPLATES: true }); // After the disallowed element is removed, the fragments are adjacent and survive. host.shadowRoot.normalize(); console.log(host.shadowRoot.textContent); ``` ```text ${alert(document.domain)} ``` A downstream template evaluator reading `shadowRoot.textContent` (for example `new Function('return `' + content + '`')()` or any `${...}`/`{{...}}` engine) then evaluates the fully formed expression. ## Attack Chain 1. **Exposure:** The application calls `DOMPurify.sanitize(node, cfg)` (`src/purify.ts:2854`) with `SAFE_FOR_TEMPLATES: true` and `IN_PLACE: true` (or a shadow-aware `RETURN_DOM`), passing a DOM node that already carries an attached shadow root. 2. **Control:** The attacker controls the shadow root's text nodes and the disallowed separator elements between them; each fragment (`$`, `{alert(document.domain)}`) is a valid, individually benign text node. 3. **Path:** `_sanitizeAttachedShadowRoots` (`src/purify.ts:2977`) feeds the shadow root into `_sanitizeShadowDOM` (`src/purify.ts:2710`), which strips expressions per text node (`src/purify.ts:2246`) and removes the disallowed separator elements, leaving the fragments adjacent. 4. **Guard:** The post-walk safety net `_scrubTemplateExpressions` (`src/purify.ts:3131-3132`) is the only pass that would catch the merged expression, but it never recurses into shadow roots (`src/purify.ts:1717-1751` recurses into `<template>.content` only). 5. **Primitive:** The adjacent fragments seriali
Last activity 22 Oct 2026. Patch Forecast updates every hour.