GHSA-gvmj-g25r-r7wr: DOMPurify: SAFE_FOR_TEMPLATES bypass - template expressions survive sanitization inside content when using DOM output modes
Summary
When DOMPurify is configured with both SAFE_FOR_TEMPLATES: true and RETURN_DOM: true (or IN_PLACE: true), an attacker can inject template expressions, such as ${evil}, {{evil}}, or <%evil%>, that survive the sanitization pass inside <template> element content. This bypasses the explicit purpose of SAFE_FOR_TEMPLATES, which is to prevent template engine evaluation of user-supplied content.
Note: The string output path is not affected. Only the DOM return paths (RETURN_DOM: true, RETURN_DOM_FRAGMENT: true, IN_PLACE: true) are vulnerable.
Description
Background
SAFE_FOR_TEMPLATES is designed to strip {{ }}, ${ }, and <% %> expressions from sanitized output so that downstream template engines do not evaluate user-controlled content. The feature operates through two mechanisms:
1. Per-node scrubbing (_sanitizeElements, src/purify.ts:1403), scrubs individual text nodes during the main sanitization walk.
2. Final normalization pass (_scrubTemplateExpressions, src/purify.ts:1115), calls node.normalize() to merge adjacent text nodes, then walks the merged nodes and strips any expressions that only appeared after merging.
The Gap
_scrubTemplateExpressions uses a standard NodeIterator rooted at the output body:
// src/purify.ts:1117
const walker = createNodeIterator.call(
node.ownerDocument || node,
node,
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT | ...,
null
);
Per the DOM specification, a NodeIterator does not descend into <template>.content. The template element's content is a separate DocumentFragment that lives outside the normal child-node tree. For the same reason, node.normalize() (called on line 1116) also does not normalize text nodes inside <template>.content.
This means the final normalization and scrub pass, the only pass that catches expressions formed *by merging split text nodes*, never runs on <template> content.
How Split Text Nodes Are Created
When DOMPurify removes a disallowed element with KEEP_CONTENT: true (the default), it moves
Details
Original advisory: https://github.com/advisories/GHSA-gvmj-g25r-r7wr
Exploitation outlook
EPSS (FIRST.org) estimates each CVE’s probability of exploitation in the next 30 days — here is the CSIRTS.com read on those numbers.
- Low exploitation riskCVE-2026-659000.18% 30-day exploitation probability — currently an unlikely target, but scores change as exploit code circulates. Riskier than 8% of all scored CVEs.
Referenced CVEs
| CVE | CSIRTS overview | External |
|---|---|---|
| CVE-2026-65900 | coverage & exploitation status | NVD · CVE.org |
Same CVEs, other sources
How other CERTs, PSIRTs and databases cover the vulnerabilities in this advisory.
More from GitHub Security Advisories
- mediumGHSA-xm43-3m56-w3wf: Ghost: Paid gift memberships obtainable at minimal cost via the donations feature2026-08-04
- mediumGHSA-chgm-3698-jm42: Ghost: Member existence leak via magic link sign-in response2026-08-04
- highGHSA-xpp7-93x6-v29m: XSS in Ghost's ActivityPub client2026-08-04
- mediumGHSA-7mpp-r37j-x5wh: Ghost: Session Fixation in Ghost Admin2026-08-04
- mediumGHSA-cjc9-q5gf-327p: Ghost: Theme Upload Path Traversal2026-08-04