GHSA-CG2G-XH64-V4Q7
Vulnerability from github – Published: 2026-10-22 16:25 – Updated: 2026-10-22 16:25Summary
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).
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.4.16"
},
"package": {
"ecosystem": "npm",
"name": "dompurify"
},
"ranges": [
{
"events": [
{
"introduced": "3.4.4"
},
{
"fixed": "3.4.17"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-22T16:25:31Z",
"nvd_published_at": null,
"severity": "CRITICAL"
},
"details": "## Summary\n\nWhen 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 `\u003c%...%\u003e` 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 `\u003ctemplate\u003e.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.\n\n## Root Cause\n\n`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.\n\n```text\n_scrubTemplateExpressions (src/purify.ts:1717-1751):\n - node.normalize() at line 1718 does not merge text nodes inside shadow roots (DOM spec).\n - NodeIterator at lines 1723-1731 does not descend into shadow trees (DOM spec).\n - Explicit recursion at lines 1743-1750 enters \u003ctemplate\u003e.content only, via\n querySelectorAll(\u0027template\u0027); there is no equivalent recursion into attached\n shadow roots.\nShadow roots are sanitized earlier by _sanitizeShadowDOM (src/purify.ts:2710), which\napplies the per-node strip but never performs the post-walk normalize-and-rescrub.\n```\n\nShadow root text nodes are therefore sanitized only per-node and never re-examined as a merged unit.\n\n## Impact\n\nAn 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\u0027s 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 `\u003ctemplate shadowrootmode=\"open\"\u003e` 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\u0027s evaluation context.\n\n## Proof of Concept\n\n```text\nPreconditions:\n - DOMPurify built from the affected source (dist/purify.js or an ESM/CJS bundle).\n - A browser (or DOM implementation) with shadow DOM support.\n - Application constructs a DOM node with an attached shadow root before sanitizing,\n and later evaluates the shadow root\u0027s text content as a template.\n```\n\nBuild 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`:\n\n```javascript\nconst host = document.createElement(\u0027div\u0027);\nconst root = host.attachShadow({ mode: \u0027open\u0027 });\n\n// Split \"${alert(document.domain)}\" so no single fragment matches a regex.\n// \u003cobject\u003e is not in the default ALLOWED_TAGS and is removed during sanitization,\n// leaving the surrounding text-node siblings adjacent.\nroot.appendChild(document.createTextNode(\u0027$\u0027));\nroot.appendChild(document.createElement(\u0027object\u0027));\nroot.appendChild(document.createTextNode(\u0027{alert(document.domain)}\u0027));\n\nDOMPurify.sanitize(host, { IN_PLACE: true, SAFE_FOR_TEMPLATES: true });\n\n// After the disallowed element is removed, the fragments are adjacent and survive.\nhost.shadowRoot.normalize();\nconsole.log(host.shadowRoot.textContent);\n```\n\n```text\n${alert(document.domain)}\n```\n\nA downstream template evaluator reading `shadowRoot.textContent` (for example `new Function(\u0027return `\u0027 + content + \u0027`\u0027)()` or any `${...}`/`{{...}}` engine) then evaluates the fully formed expression.\n\n## Attack Chain\n\n1. **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.\n2. **Control:** The attacker controls the shadow root\u0027s text nodes and the disallowed separator elements between them; each fragment (`$`, `{alert(document.domain)}`) is a valid, individually benign text node.\n3. **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.\n4. **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 `\u003ctemplate\u003e.content` only).\n5. **Primitive:** The adjacent fragments serialize as a single concatenated string on `shadowRoot.textContent` / `shadowRoot.innerHTML`, reconstituting the complete expression.\n6. **Result:** A downstream template engine evaluates the attacker-chosen expression, achieving script execution (or template injection) in the engine\u0027s context.\n\n## Bypass Evidence\n\nThe 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 `\u003ctemplate\u003e.content` under CVE-2026-65900 (GHSA-gvmj-g25r-r7wr) by adding `querySelectorAll(\u0027template\u0027)` 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 `\u003c%` or `%\u003e`, none of which appear in a single-character fragment. The CVE-2026-65900 regression tests exercise `\u003ctemplate\u003e.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.\n\n## Affected Versions\n\n- **Ecosystem:** `npm`\n- **Package:** `dompurify`\n- **Confirmed affected range:** `\u003e= 3.4.4, \u003c= 3.4.16`\n- **Latest release checked:** `3.4.16` ([npm registry](https://www.npmjs.com/package/dompurify/v/3.4.16))\n- **Fix status:** not fixed\n\nThe 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 `\u003ctemplate\u003e.content` recursion but still omit shadow root recursion. No published release and no current development revision contains a shadow-root fix.\n\n## Suggested Fix\n\nEnforce the same post-walk normalize-and-rescrub invariant on attached shadow roots that already applies to `\u003ctemplate\u003e.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 `\u003ctemplate\u003e.content`, and it must handle all three expression dialects, since any disallowed element can serve as the separator that produces adjacent fragments.\n\n---\n\nReported by zx (GitHub: @manus-pi).",
"id": "GHSA-cg2g-xh64-v4q7",
"modified": "2026-10-22T16:25:31Z",
"published": "2026-10-22T16:25:31Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/security/advisories/GHSA-cg2g-xh64-v4q7"
},
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/pull/1672"
},
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/commit/a77a3e78b59f72fa4d1c83f22e121ad9cc7d3575"
},
{
"type": "PACKAGE",
"url": "https://github.com/cure53/DOMPurify"
},
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/releases/tag/3.4.17"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "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",
"type": "CVSS_V4"
}
],
"summary": "DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.