GHSA-cg2g-xh64-v4q7: DOMPurify SAFE_FOR_TEMPLATES Bypass in Shadow Roots
DOMPurify 3.4.4–3.4.16 lets split ${...} expressions survive SAFE_FOR_TEMPLATES inside attached shadow roots (Critical, CVSS v4.0 9.2). Upgrade to 3.4.17.
TL;DR
- DOMPurify, an HTML sanitizer with about 45 million weekly npm downloads, fails to strip template expressions inside attached shadow roots when they are split across text nodes and only reassemble after sanitization. The gap needs
SAFE_FOR_TEMPLATES: truetogether with a DOM-node mode (RETURN_DOM,RETURN_DOM_FRAGMENTorIN_PLACE). - An attacker who can supply a DOM node carrying a shadow root splits an expression such as
${...}across text nodes separated by a disallowed element. DOMPurify removes the separator, the fragments become adjacent, and the reconstituted expression survives into the output, where a downstream template engine evaluates it: cross-site scripting, or template injection on the server. - Versions 3.4.4 through 3.4.16 are affected. The advisory rates it Critical, CVSS v4.0 9.2. A proof of concept is public; no exploitation in the wild is known as of October 23, 2026, and the issue is not in the CISA KEV catalog.
- Upgrade to DOMPurify 3.4.17. If you cannot, pass untrusted markup as a string rather than as pre-parsed DOM nodes: on string input DOMPurify's own parser ignores declarative shadow roots.
Am I affected?
You are affected if all of these hold:
- Your dependency resolves to DOMPurify 3.4.4 through 3.4.16.
- Your configuration enables
SAFE_FOR_TEMPLATES(the option that strips{{...}},${...}and<%...%>template syntax so output can be fed to a template engine). - You call
sanitizein a mode that works on DOM nodes instead of returning a string:RETURN_DOM,RETURN_DOM_FRAGMENT, orIN_PLACE(which sanitizes the input node in place). This post calls these DOM-node modes. - The input can be a DOM node that already carries an attached shadow root.
Applications that sanitize untrusted input as strings are not on this path: DOMPurify's own parser ignores declarative shadow roots, as the 3.4.17 README documents.
| Branch | Affected versions | Fixed version |
|---|---|---|
| DOMPurify 3.4.x | 3.4.4 – 3.4.16 | 3.4.17 |
| DOMPurify ≤ 3.4.3 | Not affected: both the shadow-root walk and the post-walk scrub that this bug relies on arrived in 3.4.4 | - |
Check what is installed and where the risky options appear:
# Installed version
npm ls dompurify
node -e "console.log(require('dompurify/package.json').version)"
# Lock file check (matches lockfileVersion 1 "dompurify" and v2/v3 "node_modules/dompurify" keys)
grep -nE '"(node_modules/)?dompurify"' package-lock.json
# Container image check
docker run --rm <your-image> node -e "console.log(require('dompurify/package.json').version)"
# Find the risky configuration combination in your code
# (-A 8 lets the second grep see sibling options on adjacent lines)
grep -rn -A 8 "SAFE_FOR_TEMPLATES" --include="*.js" --include="*.ts" . | grep -iE "RETURN_DOM|IN_PLACE"
One subtlety widens the input surface: the shadow root does not have to be built programmatically with attachShadow. In browsers that support declarative shadow DOM, markup containing <template shadowrootmode="open"> parsed through setHTMLUnsafe() or Document.parseHTMLUnsafe() produces the same attached shadow root. A plain HTML string therefore reaches the gap once the application parses it that way before sanitizing.
How do I fix it?
Upgrade DOMPurify:
npm install dompurify@3.4.17
# or: pnpm add dompurify@3.4.17 | yarn add dompurify@3.4.17
If you cannot upgrade immediately, change the call pattern rather than the version:
- Pass untrusted markup to
sanitizeas a string. DOMPurify's parser ignores declarative shadow roots on string input, so no attached shadow root exists for the walk to miss. This is the documented behavior in the 3.4.17 README. - If you must accept DOM nodes, do not combine
SAFE_FOR_TEMPLATESwithIN_PLACE,RETURN_DOM, orRETURN_DOM_FRAGMENTuntil you have upgraded. A fragment can carry attached shadow roots too, so it is on the same path as the other two modes.
The limit of the workaround is that it depends on call-site discipline across your codebase, while the fix removes the gap inside the sanitizer for every caller.
What is the root cause?
SAFE_FOR_TEMPLATES neutralizes template expressions through two mechanisms, and the bug is that the second one cannot see shadow roots.
Mechanism one is a per-text-node strip applied during the main walk: as _sanitizeElements visits each node (src/purify.ts:2246 in the affected releases), _stripTemplateExpressions removes anything matching the expression regexes. A split expression defeats it, because each fragment on its own fails the delimiter patterns: MUSTACHE_EXPR needs {{ or }}, TMPLIT_EXPR needs ${, and ERB_EXPR needs <% or %>. A single $ is just text.
Mechanism two is the safety net for exactly that case. After the walk finishes, _scrubTemplateExpressions (src/purify.ts:1717) calls normalize() on the tree, which merges adjacent text nodes, then re-strips expressions that only formed after the merge. This is the only pass that can catch a split expression. The advisory documents why it does not reach shadow content:
_scrubTemplateExpressions (src/purify.ts:1717-1751):
- node.normalize() at line 1718 does not merge text nodes inside shadow
roots (DOM spec).
- The NodeIterator at lines 1723-1731 does not descend into shadow trees
(DOM spec).
- The explicit recursion at lines 1743-1750 enters <template>.content
only, via querySelectorAll('template'); there is no equivalent recursion
into attached shadow roots.
Shadow roots do get sanitized, but only by the earlier _sanitizeShadowDOM pass (src/purify.ts:2710), which applies the per-node strip and removes disallowed elements, and does not perform the post-walk normalize-and-rescrub. So shadow-root text is sanitized per node and not re-examined as a merged unit.
The invariant: adjacent text nodes are merged and re-stripped after element removal. It holds for the main tree and for <template> content. Nothing enforces it inside attached shadow roots, so a split expression whose fragments only become adjacent after sanitization survives there.
The gap is a distinct boundary rather than a regression of the earlier fix: the previous advisory in this series, CVE-2026-65900 (GHSA-gvmj-g25r-r7wr), extended the post-walk pass to recurse into <template>.content, and its regression tests cover template content only. Shadow roots were left out of that extension.
How is it exploited?
The attacker builds a host element whose shadow root holds an expression split across text nodes separated by a disallowed element, then the application sanitizes it in place with SAFE_FOR_TEMPLATES:
// Proof of concept from GHSA-cg2g-xh64-v4q7 (Cure53 advisory), trimmed
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 });
host.shadowRoot.normalize();
console.log(host.shadowRoot.textContent);
// "${alert(document.domain)}"
The attack path, step by step:
- The application calls
DOMPurify.sanitize(node, cfg)withSAFE_FOR_TEMPLATES: trueand a DOM-node mode (IN_PLACE,RETURN_DOM, orRETURN_DOM_FRAGMENT), passing a DOM node that already carries an attached shadow root. - The attacker controls the shadow root's text nodes and the disallowed separator element between them. Each fragment,
$and{alert(document.domain)}, is individually benign text. _sanitizeAttachedShadowRootsfeeds the shadow root into_sanitizeShadowDOM, which strips expressions per text node and removes the disallowed separator. The fragments become adjacent.- The post-walk safety net, the only pass that would catch the merged expression, does not enter shadow roots.
- The adjacent fragments serialize as one string on
shadowRoot.textContentorshadowRoot.innerHTML, reconstituting the complete expression. - A downstream template evaluator reads the shadow root's content and evaluates the attacker's expression: script execution in the engine's context, or template injection if the sink is a server-side engine.
The exploit needs a sink that evaluates what DOMPurify returned. The advisory demonstrates the injection primitive and names typical evaluators: new Function('return `' + content + '`')() or any ${...} and {{...}} engine. The confidentiality or integrity consequence depends on that downstream evaluation context.
How does the split expression reach the template engine?
The split expression survives because the merge-and-rescrub pass runs on the main tree and template content only, with no case for attached shadow roots. The flow from attacker input to evaluation:
with attached shadow root"] --> B["App sanitizes with a DOM-node mode
plus SAFE_FOR_TEMPLATES"] B --> C["Per-node strip misses the
split expression fragments"] C --> D["Disallowed separator element
removed, fragments adjacent"] D --> E["Post-walk scrub runs on main tree
and template content only"] E --> F["shadowRoot.textContent serializes
the complete expression"] F --> G["Downstream template engine
evaluates attacker expression"]
The missing check is the merge-and-rescrub pass: it never enters shadow roots, so nothing re-strips the merged fragments.
What can an attacker do?
The demonstrated capability is injection of a complete, attacker-chosen template expression into post-sanitization shadow root content. In a client-side context the result is cross-site scripting in the application's origin. Where the sanitized content feeds a server-side template, the result is template injection, which in engines with rich expressions frequently escalates to remote code execution on the server.
The conditions an attacker needs, from the advisory:
- The application enables
SAFE_FOR_TEMPLATESand a DOM-node mode at the same time. - The application passes a DOM node that already has an attached shadow root, which declarative shadow DOM plus
setHTMLUnsafe()orDocument.parseHTMLUnsafe()can produce from a plain HTML string on supporting engines. - The application feeds the sanitized shadow root content into a template evaluator.
- No authentication, privileges or user interaction are required at the sanitizer itself. The CVSS v4.0 vector is
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N, with attack complexity high and attack requirements present precisely because all three application-side conditions must line up.
If those conditions hold, the attacker's expression runs with the permissions of the evaluating engine, which means the impact follows the sink, from stolen session data in the browser to command execution behind a permissive server-side template.
Why does the fix work?
DOMPurify 3.4.17 (pull request #1672, commit a77a3e7) extends the post-walk invariant to shadow roots. After _sanitizeShadowDOM finishes walking a shadow root, it now runs the same normalize-and-rescrub the main tree gets:
// cure53/DOMPurify, dist/purify.cjs.js (release 3.4.17)
const _sanitizeShadowDOM = function (fragment) {
...
+ if (SAFE_FOR_TEMPLATES) _scrubOwnCharacterData(fragment);
_executeHooks(hooks.afterSanitizeShadowDOM, fragment, null);
};
The old recursive _scrubTemplateExpressions is split in two. _scrubOwnCharacterData is the non-recursive core: it calls normalize() and walks the node's own text, comment, CDATA and processing-instruction nodes to strip expressions. A wrapper keeps recursing into <template>.content. Because _sanitizeShadowDOM is already the recursive entry point for nested shadow roots and template content, running the core per fragment scrubs everything once instead of rescanning nested fragments once per ancestor.
With the merge-and-rescrub running on each shadow root, the split-expression trick no longer works: after the disallowed separator is removed and normalize() merges the adjacent fragments, the re-strip pass sees the complete ${...} sequence and removes it, exactly as it does in the main tree.
Is the fix complete? For open shadow roots on the affected call paths, yes, and the same release hardens the surrounding machinery, including stricter in-place neutralization of nodes that leave the tree mid-sanitization.
One boundary remains and is now documented in the README rather than fixed: a closed shadow root cannot be read from script, so DOMPurify can neither see nor sanitize its content. That is a platform limit, not a sanitizer gap, and the documented guidance stands: pass untrusted markup as a string.
How do I check if I was compromised?
No indicators of compromise have been published for this advisory. Absent public guidance, review these general signals:
- Places where sanitized DOM-node output feeds a template engine, an
eval-shaped call, ornew Function. Any expression-shaped content in the sanitized output at those call sites means the bypass fired. - Stored content that contains template syntax (
${,{{,<%) that originated from user-supplied markup, in shadow roots or anywhere else, since such content is what the bypass preserves. - Client-side error logs or CSP reports naming a domain the application does not normally contact. That points to an outbound request or expression evaluation triggered after sanitization.
If you find evidence of evaluation, treat the surrounding session or server context as exposed according to what the evaluating engine could reach.
Timeline
- October 12, 2026: Cure53 publishes advisory GHSA-cg2g-xh64-v4q7 and releases DOMPurify 3.4.17 with pull request #1672. Reporter zx (GitHub: @manus-pi) is credited with the report; @manus-use is also credited in the GitHub advisory.
- October 22, 2026: The advisory is published and reviewed in the GitHub Advisory Database.
- October 23, 2026: This post. No exploitation in the wild is known; the issue is not in the CISA KEV catalog.
FAQ
Is the default DOMPurify configuration affected?
No. The gap needs SAFE_FOR_TEMPLATES together with a DOM-node mode and DOM-node input carrying an attached shadow root. Default string sanitization does not hit the affected path, because DOMPurify's parser ignores declarative shadow roots on string input.
What is SAFE_FOR_TEMPLATES for?
It is the option for applications that feed sanitized markup into a template engine. It strips template expression syntax, {{...}}, ${...} and <%...%>, so an attacker cannot smuggle an expression through the sanitizer for the engine to execute later. This advisory shows that strip was incomplete inside shadow roots.
Why were shadow roots missed when template content was already fixed?
The earlier fix in this series, CVE-2026-65900, added recursion into <template>.content and its tests covered only that container. Attached shadow roots are a separate DOM boundary that the post-walk pass's NodeIterator cannot enter, so they needed their own case, which 3.4.17 adds.
Is there a CVE for this issue? As of October 23, 2026 the advisory GHSA-cg2g-xh64-v4q7 has no CVE identifier; it is tracked by its GHSA id.
How widely deployed is the affected package?
The dompurify package recorded 45,293,289 downloads in the week of October 16-22, 2026 on npm. A bundled or vendored copy does not show up in npm ls, so check downstream bundles too.
Can I keep using IN_PLACE with untrusted nodes after upgrading? 3.4.17 scrubs shadow roots on that path and hardens in-place behavior when nodes are moved or removed mid-walk. The documented caveat that remains is platform-level: closed shadow roots are invisible to any script-side sanitizer, so keep passing untrusted markup as strings where you can.
Review your application exposure
Start a web application security assessment with Ostorlab. Use the affected-version and call-site checks above to establish whether your DOMPurify deployment reaches this vulnerability.
References
- Cure53 advisory GHSA-cg2g-xh64-v4q7: https://github.com/cure53/DOMPurify/security/advisories/GHSA-cg2g-xh64-v4q7 (checked October 23, 2026)
- GitHub Advisory Database entry: https://github.com/advisories/GHSA-cg2g-xh64-v4q7 (checked October 23, 2026)
- Fix commit a77a3e7 (release 3.4.17): https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575 (checked October 23, 2026)
- Pull request #1672: https://github.com/cure53/DOMPurify/pull/1672 (checked October 23, 2026)
- DOMPurify 3.4.17 release: https://github.com/cure53/DOMPurify/releases/tag/3.4.17 (checked October 23, 2026)
- Prior advisory in the series, CVE-2026-65900 (GHSA-gvmj-g25r-r7wr): https://github.com/advisories/GHSA-gvmj-g25r-r7wr (checked October 23, 2026)
- npm download counts for
dompurify(45,293,289 downloads, week of October 16-22, 2026): https://api.npmjs.org/downloads/point/last-week/dompurify (checked October 24, 2026)