GHSA-cg2g-xh64-v4q7: DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes
DOMPurify 3.4.4 through 3.4.16 can leave complete template expressions such as `${...}`, `{{...}}` or `<%...%>` inside attached shadow root text when `SAFE_FOR_TEMPLATES` is combined with `IN_PLACE` or a DOM-returning mode. A downstream template engine then evaluates them. Upgrade to dompurify 3.4.17.
Published Oct 22, 2026 · Analysis Oct 22, 2026
What an attacker can do
SAFE_FOR_TEMPLATES strips expressions in two steps. The main walk strips each text node. A post-walk pass, _scrubTemplateExpressions, merges adjacent text nodes and strips again, to catch expressions that only form after merging. That pass recurses into the content of template elements, a change made for CVE-2026-65900, but never into attached shadow roots. node.normalize() and the NodeIterator do not reach shadow trees. _sanitizeShadowDOM applies only the per-node strip.
An attacker splits ${alert(document.domain)} into text nodes $ and {alert(document.domain)} with a disallowed element such as an object element between them. Sanitization removes the element, the fragments become adjacent, and shadowRoot.textContent returns the full expression.
The flaw needs a non-default setup. The app must enable SAFE_FOR_TEMPLATES and a DOM output mode, sanitize a node that carries a shadow root, and feed the shadow root's text into a template evaluator. In engines with declarative shadow DOM, markup that uses a template element with the shadowrootmode attribute, parsed through setHTMLUnsafe, produces the same root, so a plain HTML string can reach the gap. On the client the result is cross-site scripting; with a server-side engine it can become template injection.
Who is affected
The npm package dompurify from 3.4.4 through 3.4.16. Version 3.4.4 introduced the shadow-root sanitization pass and the post-walk safety net. Only apps that combine SAFE_FOR_TEMPLATES: true with IN_PLACE, RETURN_DOM or RETURN_DOM_FRAGMENT and then evaluate the output as a template are exposed.
npm ls dompurifyBundled copies, for example inside another package's dist folder, appear in this tree only if they are separate dependencies.
dompurify
- Ecosystem
- npm
- Affected
- >= 3.4.4, <= 3.4.16
- Fixed in
- 3.4.17
- Check
npm ls dompurify
How to fix it
Upgrade to dompurify 3.4.17:
npm install [email protected]The 3.4.17 release notes describe a fix for "an issue with IN_PLACE, SAFE_FOR_TEMPLATES and shadow roots". The change is in pull request 1672. The advisory text still says "not fixed", which predates the release; the package data and release tag show 3.4.17. Independent of DOMPurify, avoid evaluating sanitized output as a template string.
How to tell if you were hit
Search your code for SAFE_FOR_TEMPLATES together with IN_PLACE or RETURN_DOM. Then check whether the sanitized node can have a shadow root, via attachShadow or declarative shadow DOM, and whether its text reaches a template engine or new Function. In stored content, look for text nodes in shadow roots that contain ${, {{ or <% after sanitization. Check lockfiles for dompurify versions between 3.4.4 and 3.4.16.
Original advisory
From the GitHub Advisory Database (CC BY 4.0).
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.
_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
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:
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);${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
- Exposure: The application calls
DOMPurify.sanitize(node, cfg)(src/purify.ts:2854) withSAFE_FOR_TEMPLATES: trueandIN_PLACE: true(or a shadow-awareRETURN_DOM), passing a DOM node that already carries an attached shadow root. - 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. - 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. - 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-1751recurses into<template>.contentonly). - Primitive: The adjacent fragments serialize as a single concatenated string on
shadowRoot.textContent/shadowRoot.innerHTML, reconstituting the complete expression. - Result: A downstream template engine evaluates the attacker-chosen expression, achieving script execution (or template injection) in the engine's context.
Bypass Evidence
The relevant protection is SAFE_FOR_TEMPLATES, specifically the post-walk pass that defends against expressions that only form after adjacent text nodes merge. That defense was previously extended to <template>.content under CVE-2026-65900 (GHSA-gvmj-g25r-r7wr) by adding querySelectorAll('template') recursion at src/purify.ts:1743-1750. The strongest challenge is whether any code path performs the equivalent merge-and-rescrub inside shadow roots. Enumerating every call site of _scrubTemplateExpressions (src/purify.ts:1717, 1747, 3132, 3141) shows none receive a shadow root, and _sanitizeShadowDOM (src/purify.ts:2710) never calls _scrubTemplateExpressions or normalize(). The per-node strip cannot compensate: MUSTACHE_EXPR (src/regexp.ts:3) requires {{ or }}, TMPLIT_EXPR (src/regexp.ts:5) requires ${, and ERB_EXPR (src/regexp.ts:4) requires <% or %>, none of which appear in a single-character fragment. The CVE-2026-65900 regression tests exercise <template>.content only; no test covers shadow root content under SAFE_FOR_TEMPLATES. The gap is therefore an unfixed case on a distinct remediation boundary (shadow roots) rather than a bypass of the delivered template-content fix.
Affected Versions
- Ecosystem:
npm - Package:
dompurify - Confirmed affected range:
>= 3.4.4, <= 3.4.16 - Latest release checked:
3.4.16(npm registry) - Fix status: not fixed
The lower bound 3.4.4 is the release that introduced both the attached-shadow-root sanitization pass and the _scrubTemplateExpressions safety net; releases 3.4.4 through 3.4.7 carry the safety net with no content-container recursion, and 3.4.8 onward add <template>.content recursion but still omit shadow root recursion. No published release and no current development revision contains a shadow-root fix.
Suggested Fix
Enforce the same post-walk normalize-and-rescrub invariant on attached shadow roots that already applies to <template>.content. After _sanitizeShadowDOM finishes walking a shadow root, run _scrubTemplateExpressions over that shadow root so adjacent text nodes created by element removal are merged and re-stripped; alternatively, have _scrubTemplateExpressions discover and recurse into attached shadow roots as it does for template content. The fix must cover nested shadow roots and shadow roots inside <template>.content, and it must handle all three expression dialects, since any disallowed element can serve as the separator that produces adjacent fragments.
---
Reported by zx (GitHub: @manus-pi).
Credited to manus-pi, manus-use.
Questions
- Is default DOMPurify usage affected?
No. The advisory says exploitation needs non-default settings: SAFE_FOR_TEMPLATES, a DOM-returning mode, a node with a shadow root, and a template evaluator that reads the result.
- Which versions are affected?
3.4.4 through 3.4.16. The fix is in 3.4.17.
- Does it need a programmatic attachShadow call?
No. In browsers with declarative shadow DOM, markup containing a template element with shadowrootmode parsed through setHTMLUnsafe creates the same shadow root from a plain string.
- Is this the same as CVE-2026-65900?
No. That earlier fix covered template content. This one is the same merge-and-rescrub gap on a different boundary, shadow roots.
References
How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology
