GHSA-gvmj-g25r-r7wr: DOMPurify: SAFE_FOR_TEMPLATES bypass - template expressions survive sanitization inside <template> content when using DOM output modes
SummaryWhen 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.**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.DescriptionBackground`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:**Per-node scrubbing** (`_sanitizeElements`, `src/purify.ts:1403`), scrubs individual text nodes during the main sanitization walk.**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.The Gap`_scrubTemplateExpressions` uses a standard `NodeIterator` rooted at the output body:Per 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`.This means the final normalization and scrub pass, the only pass that catches expressions formed *by merging split text nodes*, never runs on `<template>` content.How Split Text Nodes Are CreatedWhen 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`:If 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.The code comment at `src/purify.ts:1100` explicitly acknowledges the threat class:*"which only form after text-node normalization (e.g. fragments split across stripped elements) cannot survive into a template-evaluating framework."*The implementation guards against this on the main body, but the guard is **not** applied to `<template>` content.Proof of ConceptWhy the Split WorksThe bypass relies on splitting `${...}` across two adjacent custom elements so that neither fragment matches any DOMPurify regex on its own:| Fragment | Against `TMPLIT_EXPR` `/\${[\w\W]*/g` | Against `MUSTACHE_EXPR` `/{{[\w\W]*\|^[\w\W]*}}/g` | Result |
|---|---|---|---|
| `$` | Requires `${` - no `{` follows | No `{{` or `}}` | **Survives** |
| `{alert(document.domain)}` | Requires leading `$` - absent | No `{{`, ends with single `}` not `}}` | **Survives** |
| `${alert(document.domain)}` | Full match - would be stripped | - | Stripped if seen whole |DOMPurify only sees each fragment in isolation. It never merges them before checking, so the expression is never detected.PoC 1 - XSS via `alert()` (baseline confirmation)PoC 2 - Session Hijacking via cookie exfiltrationPoC 3 - End-to-end: realistic application contextThis shows the full path in an application that uses DOMPurify to sanitize user-submitted rich text before rendering it with a custom template engine:**Observed output:** `alert("XSS: " + document.cookie)` executes in the victim's browser context, leaking session tokens to the attacker.PoC 4 - `IN_PLACE` mode (DOM input path)HTML File for testingRoot Cause`_scrubTemplateExpressions` (`src/purify.ts:1115`) does not recurse into `<template>.content`:The fix is to extend `_scrubTemplateExpressions` to explicitly recurse into `<template>.content`, mirroring the approach already used by `_sanitizeShadowDOM` (`src/purify.ts:1753`):Suggested Patch DirectionImpact**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.**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**.Preconditions for Exploitation| Precondition | Notes |
|---|---|
| `SAFE_FOR_TEMPLATES: true` | Non-default - must be explicitly set |
| `RETURN_DOM: true` or `IN_PLACE: true` | Non-default - must be explicitly set |
| Template engine processes `<template>.content` | Application-dependent |What Is NOT AffectedThe **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:
CVSS v4.0: 2