---
id: "GHSA-cg2g-xh64-v4q7"
kind: "vulnerability"
aliases: ["GHSA-cg2g-xh64-v4q7"]
title: "DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes"
severity: "critical"
cvss: null
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "dompurify", "fixed": ["3.4.17"], "affected": ">= 3.4.4, <= 3.4.16", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["browser", "cwe-79", "dompurify", "javascript", "npm", "package", "shadow-dom", "template-engines"]
not_affected_if: ["dompurify-outside-3.4.4-3.4.16", "no-dom-return-mode", "no-safe-for-templates", "no-shadow-root-input", "string-output-only"]
published_at: "2026-10-22T16:25:31+00:00"
first_seen_at: "2026-10-23T19:11:31+00:00"
updated_at: "2026-10-23T20:09:58+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-cg2g-xh64-v4q7", "first_seen_at": "2026-10-23T19:11:31+00:00"}]
---

# DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes

DOMPurify with SAFE_FOR_TEMPLATES and a DOM-returning mode (RETURN_DOM, RETURN_DOM_FRAGMENT, or IN_PLACE) fails to re-check text inside attached shadow roots, so a template expression split across text nodes survives sanitization. An attacker who supplies such a node can get a complete ${...} or {{...}} expression that a downstream template engine evaluates, leading to XSS or template injection.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:dompurify affected >= 3.4.4, <= 3.4.16 fixed 3.4.17

## How to check

Run npm ls dompurify to see whether the installed version is between 3.4.4 and 3.4.16.
Search the codebase for SAFE_FOR_TEMPLATES used together with RETURN_DOM, RETURN_DOM_FRAGMENT, or IN_PLACE.
Check whether shadow-root text from sanitized nodes is later passed to a template evaluator.

## Fix

The sources list the fix as 3.4.17 in the products field but state the issue is not fixed in any published release; confirm before upgrading to 3.4.17.
Until a confirmed fix is installed, drop SAFE_FOR_TEMPLATES from DOM-returning or IN_PLACE calls on nodes with shadow roots, or stop evaluating shadow-root text as a template.

## Timeline

- 2026-10-23T19:11:31+00:00 ghsa https://github.com/advisories/GHSA-cg2g-xh64-v4q7

## From the sources

## 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 serialize as a single concatenated string on `shadowRoot.textContent` / `shadowRoot.innerHTML`, reconstituting the complete expression.
6. **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](https://www.npmjs.com/package/dompurify/v/3.4.16))
- **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 carr

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
