GHSA-cg2g-xh64-v4q7

Updated on 22 Oct 2026

Severity

Awaiting Analysis

Details

Overview

About vulnerability

Summary

When 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.

Root Cause

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.

_scrubTemplateExpressions (src/purify.ts:1717-1751):
- node.normalize() at line 1718 does not merge text nodes inside shadow roots (DOM spec).
- NodeIterator at lines 1723-1731 does not descend into shadow trees (DOM spec).
- Explicit recursion at lines 1743-1750 enters <template>.content only, via
querySelectorAll('template'); there is no equivalent recursion into attached
shadow roots.
Shadow roots are sanitized earlier by _sanitizeShadowDOM (src/purify.ts:2710), which
applies the per-node strip but never performs the post-walk normalize-and-rescrub.

Shadow root text nodes are therefore sanitized only per-node and never re-examined as a merged unit.

Impact

An 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.

Proof of Concept

Preconditions:
- DOMPurify built from the affected source (dist/purify.js or an ESM/CJS bundle).
- A browser (or DOM implementation) with shadow DOM support.
- Application constructs a DOM node with an attached shadow root before sanitizing,
and later evaluates the shadow root's text content as a template.

Build 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:

const host = document.createElement('div');
const root = host.attachShadow({ mode: 'open' });

// Split "${alert(document.domain)}" so no single fragment matches a regex.
// <object> is not in the default ALLOWED_TAGS and is removed during sanitization,
// leaving the surrounding text-node siblings adjacent.
root.appendChild(document.createTextNode('$'));
root.appendChild(document.createElement('object'));
root.appendChild(document.createTextNode('{alert(document.domain)}'));

DOMPurify.sanitize(host, { IN_PLACE: true, SAFE_FOR_TEMPLATES: true });

// After the disallowed element is removed, the fragments are adjacent and survive.
host.shadowRoot.normalize();
console.log(host.shadowRoot.textContent);
${alert(document.domain)}

A downstream template evaluator reading shadowRoot.textContent (for example new Function('return ’ + content + ‘')() or any ${...}/{{...}} engine) then evaluates the fully formed expression.

Attack Chain

  1. 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.
  2. 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.
  3. 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.
  4. 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).
  5. Primitive: The adjacent fragments serialize as a single concatenated string on shadowRoot.textContent / shadowRoot.innerHTML, reconstituting the complete expression.
  6. Result: A downstream template engine evaluates the attacker-chosen expression, achieving script execution (or template injection) in the engine’s context.

Bypass Evidence

The 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.

Affected Versions

The 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.

Suggested Fix

Enforce 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.


Reported by zx (GitHub: @manus-pi).

Details

Affected product:
CopilotKit/CopilotKit , Next.js , copilotkit-react-native , copilotkit-react-textarea , copilotkit-react-ui , copilotkit-vue , create-sanity , crystal , docs , docusaurus-theme-mermaid , dompurify , fumadocs-sanity , gateway , graphiql , graphiql-plugin-code-exporter , graphiql-plugin-collections , graphiql-plugin-doc-explorer , graphiql-plugin-history , graphiql-plugin-query-builder , graphiql-react , graphql-hive-gateway , graphql-hive/console , html2pdf.js , isomorphic-dompurify , jspdf , ledger-live , lynx-js-css-extract-webpack-plugin , lynx-js-luna-stage , lynx-js-luna-studio , lynx-js-lynx-bundle-rslib-config , lynx-js-react-refresh-webpack-plugin , lynx-js-react-rsbuild-plugin , lynx-js-react-webpack-plugin , lynx-js-rsbuild-plugin , lynx-js-rspeedy , lynx-js-template-webpack-plugin , lynx-js-vanilla-rsbuild-plugin , lynx-js-web-core , lynx-js-web-elements-compat , lynx-stack , mermaid , modern-js-main-doc , monaco-editor , monaco-graphql , monaco-react , octanejs-monaco-editor , octanejs-streamdown , posthog-js , rails-actiontext , rspress , runtime-cli , sanity , sanity-cli , sanity-cli-build , sanity-plugin-mux-input , sanity-preview-url-secret , sanity-vercel-protection-bypass , sanity-vision , strapi-content-manager , strapi-content-releases , strapi-i18n , strapi-plugin-cloud , strapi-plugin-color-picker , strapi-plugin-documentation , strapi-plugin-graphql , strapi-plugin-sentry , strapi-plugin-users-permissions , strapi-review-workflows , strapi-strapi , streamdown , streamdown-mermaid , streamdown-vue , svg2pdf.js , swagger-ui , trix , vaadin-avatar , vaadin-avatar-group , vaadin-field-highlighter , vaadin-markdown , vaadin-message-list , vaadin-rich-text-editor , vaadin-tooltip , verdaccio-ui-components , yummies
Affected packages:
dompurify @ 3.4.13 (+789 more)

Fixes