{"id":"GHSA-cg2g-xh64-v4q7","kind":"vulnerability","aliases":["GHSA-cg2g-xh64-v4q7"],"related":[],"title":"DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes","description":"## Summary\n\nWhen DOMPurify is configured with `SAFE_FOR_TEMPLATES: true` together with a DOM-returning mode (`RETURN_DOM: true`, `RETURN_DOM_FRAGMENT: true`, or `IN_PLACE: true`), template expressions such as `{{...}}`, `${...}`, and `<%...%>` survive sanitization inside the text content of an attached shadow root. The post-walk safety net `_scrubTemplateExpressions` (`src/purify.ts:1717`) was extended to recurse into `<template>.content`, but it never recurses into attached shadow roots. An attacker who can supply a DOM node carrying a shadow root (the `IN_PLACE` and shadow-aware `RETURN_DOM` surfaces) can split an expression across text nodes separated by disallowed elements; after those elements are removed the fragments become adjacent and serialize into a complete expression that a downstream template engine will evaluate.\n\n## Root Cause\n\n`SAFE_FOR_TEMPLATES` neutralizes template expressions through two mechanisms: a per-text-node strip during the main walk (`_stripTemplateExpressions`, applied in `_sanitizeElements` at `src/purify.ts:2246`), and a final post-walk pass (`_scrubTemplateExpressions`, `src/purify.ts:1717`) that normalizes adjacent text nodes and re-strips expressions that only form after merging. The per-node strip cannot catch an expression that is split into individual fragments, because each fragment fails the delimiter regexes. The post-walk pass is the only mechanism that would catch the merged form, but it is structurally unable to reach shadow root content.\n\n```text\n_scrubTemplateExpressions (src/purify.ts:1717-1751):\n  - node.normalize() at line 1718 does not merge text nodes inside shadow roots (DOM spec).\n  - NodeIterator at lines 1723-1731 does not descend into shadow trees (DOM spec).\n  - Explicit recursion at lines 1743-1750 enters <template>.content only, via\n    querySelectorAll('template'); there is no equivalent recursion into attached\n    shadow roots.\nShadow roots are sanitized earlier by _sanitizeShadowDOM (src/purify.ts:2710), which\napplies the per-node strip but never performs the post-walk normalize-and-rescrub.\n```\n\nShadow root text nodes are therefore sanitized only per-node and never re-examined as a merged unit.\n\n## Impact\n\nAn attacker who controls DOM-node input containing an attached shadow root can inject template expressions that survive sanitization and are later evaluated by the application's template engine. In client-side contexts this yields cross-site scripting; in a server-side template context it can escalate to template injection. Exploitation requires non-default application configuration: the application must enable `SAFE_FOR_TEMPLATES` and a DOM-returning mode, pass a DOM node that already has an attached shadow root, and feed the sanitized shadow root content into a template evaluator. The shadow root does not have to be built programmatically with `attachShadow`: in engines that support declarative shadow DOM, attacker-supplied markup containing `<template shadowrootmode=\"open\">` parsed through `setHTMLUnsafe` or `Document.parseHTMLUnsafe` produces the same attached shadow root, so a plain HTML string reaches the gap once the application is configured this way. Demonstrated capability is injection of a complete, attacker-chosen expression into post-sanitization shadow root content; the resulting script execution and any confidentiality or integrity consequence depend on the downstream engine's evaluation context.\n\n## Proof of Concept\n\n```text\nPreconditions:\n  - DOMPurify built from the affected source (dist/purify.js or an ESM/CJS bundle).\n  - A browser (or DOM implementation) with shadow DOM support.\n  - Application constructs a DOM node with an attached shadow root before sanitizing,\n    and later evaluates the shadow root's text content as a template.\n```\n\nBuild a host element whose shadow root holds a template expression split across text nodes separated by disallowed elements, then sanitize in place with `SAFE_FOR_TEMPLATES`:\n\n```javascript\nconst host = document.createElement('div');\nconst root = host.attachShadow({ mode: 'open' });\n\n// Split \"${alert(document.domain)}\" so no single fragment matches a regex.\n// <object> is not in the default ALLOWED_TAGS and is removed during sanitization,\n// leaving the surrounding text-node siblings adjacent.\nroot.appendChild(document.createTextNode('$'));\nroot.appendChild(document.createElement('object'));\nroot.appendChild(document.createTextNode('{alert(document.domain)}'));\n\nDOMPurify.sanitize(host, { IN_PLACE: true, SAFE_FOR_TEMPLATES: true });\n\n// After the disallowed element is removed, the fragments are adjacent and survive.\nhost.shadowRoot.normalize();\nconsole.log(host.shadowRoot.textContent);\n```\n\n```text\n${alert(document.domain)}\n```\n\nA downstream template evaluator reading `shadowRoot.textContent` (for example `new Function('return `' + content + '`')()` or any `${...}`/`{{...}}` engine) then evaluates the fully formed expression.\n\n## Attack Chain\n\n1. **Exposure:** The application calls `DOMPurify.sanitize(node, cfg)` (`src/purify.ts:2854`) with `SAFE_FOR_TEMPLATES: true` and `IN_PLACE: true` (or a shadow-aware `RETURN_DOM`), passing a DOM node that already carries an attached shadow root.\n2. **Control:** The attacker controls the shadow root's text nodes and the disallowed separator elements between them; each fragment (`$`, `{alert(document.domain)}`) is a valid, individually benign text node.\n3. **Path:** `_sanitizeAttachedShadowRoots` (`src/purify.ts:2977`) feeds the shadow root into `_sanitizeShadowDOM` (`src/purify.ts:2710`), which strips expressions per text node (`src/purify.ts:2246`) and removes the disallowed separator elements, leaving the fragments adjacent.\n4. **Guard:** The post-walk safety net `_scrubTemplateExpressions` (`src/purify.ts:3131-3132`) is the only pass that would catch the merged expression, but it never recurses into shadow roots (`src/purify.ts:1717-1751` recurses into `<template>.content` only).\n5. **Primitive:** The adjacent fragments serialize as a single concatenated string on `shadowRoot.textContent` / `shadowRoot.innerHTML`, reconstituting the complete expression.\n6. **Result:** A downstream template engine evaluates the attacker-chosen expression, achieving script execution (or template injection) in the engine's context.\n\n## Bypass Evidence\n\nThe relevant protection is `SAFE_FOR_TEMPLATES`, specifically the post-walk pass that defends against expressions that only form after adjacent text nodes merge. That defense was previously extended to `<template>.content` under CVE-2026-65900 (GHSA-gvmj-g25r-r7wr) by adding `querySelectorAll('template')` recursion at `src/purify.ts:1743-1750`. The strongest challenge is whether any code path performs the equivalent merge-and-rescrub inside shadow roots. Enumerating every call site of `_scrubTemplateExpressions` (`src/purify.ts:1717`, `1747`, `3132`, `3141`) shows none receive a shadow root, and `_sanitizeShadowDOM` (`src/purify.ts:2710`) never calls `_scrubTemplateExpressions` or `normalize()`. The per-node strip cannot compensate: `MUSTACHE_EXPR` (`src/regexp.ts:3`) requires `{{` or `}}`, `TMPLIT_EXPR` (`src/regexp.ts:5`) requires `${`, and `ERB_EXPR` (`src/regexp.ts:4`) requires `<%` or `%>`, none of which appear in a single-character fragment. The CVE-2026-65900 regression tests exercise `<template>.content` only; no test covers shadow root content under `SAFE_FOR_TEMPLATES`. The gap is therefore an unfixed case on a distinct remediation boundary (shadow roots) rather than a bypass of the delivered template-content fix.\n\n## Affected Versions\n\n- **Ecosystem:** `npm`\n- **Package:** `dompurify`\n- **Confirmed affected range:** `>= 3.4.4, <= 3.4.16`\n- **Latest release checked:** `3.4.16` ([npm registry](https://www.npmjs.com/package/dompurify/v/3.4.16))\n- **Fix status:** not fixed\n\nThe lower bound 3.4.4 is the release that introduced both the attached-shadow-root sanitization pass and the `_scrubTemplateExpressions` safety net; releases 3.4.4 through 3.4.7 carry the safety net with no content-container recursion, and 3.4.8 onward add `<template>.content` recursion but still omit shadow root recursion. No published release and no current development revision contains a shadow-root fix.\n\n## Suggested Fix\n\nEnforce the same post-walk normalize-and-rescrub invariant on attached shadow roots that already applies to `<template>.content`. After `_sanitizeShadowDOM` finishes walking a shadow root, run `_scrubTemplateExpressions` over that shadow root so adjacent text nodes created by element removal are merged and re-stripped; alternatively, have `_scrubTemplateExpressions` discover and recurse into attached shadow roots as it does for template content. The fix must cover nested shadow roots and shadow roots inside `<template>.content`, and it must handle all three expression dialects, since any disallowed element can serve as the separator that produces adjacent fragments.\n\n---\n\nReported by zx (GitHub: @manus-pi).","summary":"DOMPurify with SAFE_FOR_TEMPLATES and a DOM-returning mode (RETURN_DOM, RETURN_DOM_FRAGMENT, or IN_PLACE) fails to re-check text inside attached shadow roots, so a template expression split across text nodes survives sanitization. An attacker who supplies such a node can get a complete ${...} or {{...}} expression that a downstream template engine evaluates, leading to XSS or template injection.","severity":"critical","cvss":null,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"dompurify","fixed":["3.4.17"],"affected":">= 3.4.4, <= 3.4.16","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["browser","cwe-79","dompurify","javascript","npm","package","shadow-dom","template-engines"],"not_affected_if":["dompurify-outside-3.4.4-3.4.16","no-dom-return-mode","no-safe-for-templates","no-shadow-root-input","string-output-only"],"how_to_check":"Run npm ls dompurify to see whether the installed version is between 3.4.4 and 3.4.16.\nSearch the codebase for SAFE_FOR_TEMPLATES used together with RETURN_DOM, RETURN_DOM_FRAGMENT, or IN_PLACE.\nCheck whether shadow-root text from sanitized nodes is later passed to a template evaluator.","fix":"The sources list the fix as 3.4.17 in the products field but state the issue is not fixed in any published release; confirm before upgrading to 3.4.17.\nUntil a confirmed fix is installed, drop SAFE_FOR_TEMPLATES from DOM-returning or IN_PLACE calls on nodes with shadow roots, or stop evaluating shadow-root text as a template.","status":"open","published_at":"2026-10-22T16:25:31+00:00","first_seen_at":"2026-10-23T19:11:31+00:00","triaged_at":"2026-10-23T20:09:58+00:00","updated_at":"2026-10-23T20:09:58+00:00","category":"package","confidence":0.7,"triage":{"at":"2026-10-23T20:09:58Z","model":"claude-haiku-5-5","output":{"fix":"The sources list the fix as 3.4.17 in the products field but state the issue is not fixed in any published release; confirm before upgrading to 3.4.17.\nUntil a confirmed fix is installed, drop SAFE_FOR_TEMPLATES from DOM-returning or IN_PLACE calls on nodes with shadow roots, or stop evaluating shadow-root text as a template.","summary":"DOMPurify with SAFE_FOR_TEMPLATES and a DOM-returning mode (RETURN_DOM, RETURN_DOM_FRAGMENT, or IN_PLACE) fails to re-check text inside attached shadow roots, so a template expression split across text nodes survives sanitization. An attacker who supplies such a node can get a complete ${...} or {{...}} expression that a downstream template engine evaluates, leading to XSS or template injection.","category":"package","who_cares":["dompurify","npm","javascript","shadow-dom","template-engines","browser"],"confidence":0.7,"analyst_note":"Sources conflict on the fixed version: products field says fixed 3.4.17 while the description says not fixed; check the GHSA page https://github.com/advisories/GHSA-cg2g-xh64-v4q7 and the npm release notes for 3.4.17.","how_to_check":"Run npm ls dompurify to see whether the installed version is between 3.4.4 and 3.4.16.\nSearch the codebase for SAFE_FOR_TEMPLATES used together with RETURN_DOM, RETURN_DOM_FRAGMENT, or IN_PLACE.\nCheck whether shadow-root text from sanitized nodes is later passed to a template evaluator.","needs_analyst":true,"not_affected_if":["no-safe-for-templates","no-dom-return-mode","no-shadow-root-input","string-output-only","dompurify-outside-3.4.4-3.4.16"],"severity_if_unknown":"critical"},"cost_usd":0.00138,"input_tokens":5395,"output_tokens":1681,"severity_before":"critical"},"analyst_status":null,"analyst_note":null,"search":"'-1731':352C '-1750':366C '-1751':333C '-79':32B '1717':159C,266C,332C '1718':337C '1723':351C '1743':365C '2246':256C '2710':390C 'across':77B,200C,673C 'adjac':215C,269C 'affect':621C 'alreadi':509C 'appli':251C,392C 'applic':456C,489C,492C,571C,637C 'attach':11A,69B,148C,171C,379C,440C,512C,558C,644C 'attachshadow':537C 'attack':83B,175C,431C,546C,584C 'attacker-chosen':583C 'attacker-suppli':545C 'awar':192C 'becom':214C 'browser':30B,629C 'build':661C 'built':534C,618C 'bundl':627C 'bypass':5A 'cannot':287C 'capabl':577C 'carri':182C 'catch':288C,315C 'caus':230C 'cg2g':21A,26A 'check':66B 'chosen':585C 'client':462C 'client-sid':461C 'complet':92B,220C,582C 'concept':614C 'confidenti':600C 'configur':111C,490C,573C 'consequ':603C 'const':689C 'construct':638C 'contain':438C,549C 'content':14A,145C,165C,328C,368C,521C,593C,657C 'context':464C,477C,611C 'control':433C 'cross':468C 'cross-sit':467C 'cwe':31B 'declar':542C 'default':488C 'delimit':302C 'demonstr':576C 'depend':604C 'descend':355C 'disallow':205C,678C 'dist/purify.js':623C 'docum':691C 'document.parsehtmlunsafe':554C 'dom':16A,39B,51B,55B,57B,121C,125C,128C,180C,194C,346C,359C,435C,501C,506C,544C,631C,635C,640C 'dom-nod':434C 'dom-return':50B,120C,500C 'dompurifi':1A,33B,43B,109C,617C 'dompurify':29A 'downstream':97B,224C,607C 'earlier':386C 'element':206C,209C,664C,679C 'enabl':494C 'engin':42B,99B,226C,459C,539C,608C 'enter':367C 'equival':376C 'escal':480C 'esm/cjs':626C 'evalu':100B,228C,453C,525C,610C,651C 'examin':424C 'execut':597C 'explicit':361C 'exploit':484C 'express':8A,75B,94B,136C,199C,221C,236C,276C,290C,446C,586C,671C 'extend':161C 'fail':62B,300C 'feed':516C 'final':259C 'form':279C,318C 'fragment':58B,129C,213C,296C,299C 'gap':568C 'get':90B 'ghsa':20A,25A 'ghsa-cg2g-xh64-v4q7':19A,24A 'hold':668C 'host':663C,690C 'html':564C 'impact':429C 'implement':632C 'individu':295C 'inject':106B,444C,483C,579C 'input':437C 'insid':68B,142C,343C 'integr':602C 'javascript':34B,688C 'later':452C,650C 'lead':101B 'line':336C,350C,364C 'main':248C 'markup':548C 'mechan':239C,312C 'merg':281C,317C,340C,427C 'mode':18A,53B,123C,503C 'must':493C 'net':156C 'neutral':234C 'never':168C,399C,421C 'node':79B,88B,181C,202C,244C,271C,285C,342C,396C,412C,419C,436C,507C,641C,675C 'node.normalize':334C 'nodeiter':348C 'non':487C 'non-default':486C 'normal':268C,406C 'normalize-and-rescrub':405C 'npm':35B 'output':17A 'packag':36B 'pars':550C 'pass':263C,308C,504C 'per':242C,284C,395C,418C 'per-nod':283C,394C,417C 'per-text-nod':241C 'perform':400C 'place':61B,133C,188C,683C 'plain':563C 'post':153C,261C,306C,403C,589C 'post-sanit':588C 'post-walk':152C,260C,305C,402C 'precondit':616C 'produc':555C 'programmat':535C 'proof':612C 'queryselectoral':371C 're':65B,274C,423C 're-check':64B 're-examin':422C 're-strip':273C 'reach':325C,566C 'recurs':163C,169C,362C,377C 'regex':303C 'remov':211C 'requir':485C 'rescrub':408C 'result':595C 'return':52B,54B,56B,122C,124C,127C,193C,502C 'root':13A,71B,150C,173C,185C,229C,327C,345C,381C,383C,410C,442C,514C,520C,528C,560C,592C,646C,654C,667C 'safe':2A,45B,113C,231C,495C,685C 'safeti':155C 'sanit':81B,141C,385C,415C,449C,518C,590C,648C,681C 'sanitizeel':253C 'sanitizeshadowdom':388C 'script':470C,596C 'scrubtemplateexpress':157C,264C,330C 'separ':203C,676C 'serial':217C 'server':474C 'server-sid':473C 'sethtmlunsaf':552C 'shadow':12A,38B,70B,149C,172C,184C,191C,326C,344C,357C,380C,382C,409C,441C,513C,519C,527C,543C,559C,591C,634C,645C,653C,666C 'shadow-awar':190C 'shadow-dom':37B 'side':463C,475C 'site':469C 'sourc':622C 'spec':347C,360C 'split':6A,76B,197C,293C,672C 'src/purify.ts':158C,255C,265C,331C,389C 'string':565C 'strip':245C,275C,286C,397C 'striptemplateexpress':250C 'structur':322C 'summari':107C 'suppli':85B,178C,547C 'support':541C,636C 'surfac':195C 'surviv':9A,80B,140C,448C 'templat':4A,7A,41B,47B,74B,98B,105B,115C,135C,225C,233C,235C,372C,445C,458C,476C,482C,497C,524C,660C,670C,687C 'template-engin':40B 'text':67B,78B,144C,201C,243C,270C,329C,341C,411C,615C,656C,674C 'therefor':414C 'togeth':117C 'tree':358C 'true':116C,126C,130C,134C 'two':238C 'unabl':323C 'unit':428C 'v4q7':23A,28A 'via':370C 'walk':154C,249C,262C,307C,404C 'way':575C 'whose':665C 'would':314C 'xh64':22A,27A 'xss':103B 'yield':466C","nicknames":[],"mitigations":[],"who_affected":null,"tier":"B","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-22T16:25:31+00:00","kev":null,"cvss_vector":null,"cvss_by":null,"poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-cg2g-xh64-v4q7","url":"https://github.com/advisories/GHSA-cg2g-xh64-v4q7","severity":"critical","published_at":"2026-10-22T16:25:31+00:00","modified_at":"2026-10-22T16:25:32+00:00","first_seen_at":"2026-10-23T19:11:31+00:00","fetched_at":"2026-10-23T19:11:31+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}