DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes
## 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 `.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. ```te
- Products
- dompurify
- Affected versions
- Not specified
- Fixed versions
- 3.4.17
- CVEs
- CVE-2026-116804
- Confirmed fixed CVEs
- Not specified
- Updated in this feed
- 1 Nov 2026, 17:55 UTC · Revision 1
Source evidence
DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes
core-osv · Original sourcePublished 22 Oct 2026, 16:25 UTC
## 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 `.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. ```te
osv:GHSA-cg2g-xh64-v4q7
dompurify
Fixed versions: 3.4.17
Complete source record
{
"id": "afb42c92ba1b12eec283d4734ec7b898309629cbe45af2c2c5b8ccff70285fe6",
"url": "https://osv.dev/vulnerability/GHSA-cg2g-xh64-v4q7",
"method": "structured",
"payload": {
"kind": "advisory",
"title": "DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes",
"summary": "## 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 `.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. ```te",
"evidence": "osv:GHSA-cg2g-xh64-v4q7",
"products": [
{
"name": {
"value": "dompurify",
"evidence": "/affected/0/package/name"
},
"prerequisites": [],
"fixed_versions": [
{
"value": "3.4.17",
"evidence": "/affected/0/ranges"
}
],
"affected_versions": []
}
],
"severity": "critical",
"structured": {
"id": "GHSA-cg2g-xh64-v4q7",
"aliases": [
"CVE-2026-116804",
"GHSA-cg2g-xh64-v4q7"
],
"metrics": [
{
"type": "CVSS_V4",
"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"
}
],
"affected": [
{
"ranges": [
{
"type": "SEMVER",
"events": [
{
"introduced": "3.4.4"
},
{
"fixed": "3.4.17"
}
]
}
],
"package": {
"name": "dompurify",
"purl": "pkg:npm/dompurify",
"ecosystem": "npm"
},
"database_specific": {
"source": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-cg2g-xh64-v4q7/GHSA-cg2g-xh64-v4q7.json",
"last_known_affected_version_range": "<= 3.4.16"
}
}
],
"modified": "2026-10-27T10:56:08.031469803Z",
"provider": "osv",
"references": [
"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",
"https://github.com/cure53/DOMPurify/releases/tag/3.4.17"
],
"provider_metadata": {
"cwe_ids": [
"CWE-79"
],
"severity": "CRITICAL",
"github_reviewed": true,
"nvd_published_at": null,
"github_reviewed_at": "2026-10-22T16:25:31Z"
}
},
"related_cves": [
{
"value": "CVE-2026-116804",
"evidence": "/aliases"
}
],
"security_relevant": true,
"severity_evidence": "/database_specific/severity"
},
"source_id": "core-osv",
"document_id": 461503,
"published_at": "2026-10-22T16:25:31+00:00"
}