Write-up: Stored XSS in TinyMCE via media plugin data-mce-object serialization (High, GHSA-mf2p-h6hf-fcwm)

In short. The TinyMCE media plugin builds a preview element by assigning attacker-controlled data-mce-html to innerHTML on a live element, and it does this before the sanitizer runs. An <img onerror=...> placed in editor content therefore executes in the hosting page's origin during serialization, when editor.getContent() is called. The returned HTML string is clean, which is why the bug is easy to miss. Fixed in 8.9.3, rated High (GHSA-mf2p-h6hf-fcwm).

The returned HTML is clean. The script already ran. editor.getContent() page.example says document.domain OK returns: <p><video><img src="dave"></video></p> 1. You call getContent() to serialize the editor 2. An <img onerror> in the content runs during the call 3. The returned string is clean, so the execution hides
The JavaScript executes while the editor serializes its content. The value handed back to the application is already sanitized, so nothing in the output hints that code ran.

Background

TinyMCE is one of the most widely embedded rich-text editors. Its optional media plugin renders embeds (video, audio, iframes) inside the editor. To do that it keeps the real markup in a data-mce-html attribute on a placeholder element and rebuilds a preview from it. The rebuild is where this goes wrong.

The exposure is not plain display of stored editor output. It is content that travels back through an editor, which is the normal pattern for collaborative editing, review workflows and re-editing. Untrusted HTML enters an editor through setContent() or the initial value, and is later serialized through getContent(), which is fired automatically by editor.save() and form submit, by autosave, and by preview.

Preconditions

preconditions
media plugin enabled   (opt-in, but commonly enabled; no other non-default option needed)
untrusted HTML round-trips through an editor (setContent/initial value, then getContent)

Root cause: sanitize runs too late

In the media plugin's buildMediaElement(), the attacker-controlled data-mce-html is written to innerHTML on a live element created with document.createElement(), and only afterwards does Sanitize.parseAndSanitize() run. Assigning to innerHTML on a live document parses and activates the markup immediately, so an onerror handler fires before the sanitizer ever sees it.

buildMediaElement(), in order 1. el = document.createElement(data-mce-object) a live DOM node, attached to the real document 2. el.innerHTML = data-mce-html the <img onerror> parses and executes right here onerror fires in page origin 3. Sanitize.parseAndSanitize() runs after the assignment, too late to stop execution Safe path (<= 7.9.2 / <= 8.5.0): sanitize the raw string first, then build the element from an inert AST, so nothing ever executes.
The dangerous assignment happens on a live document before the sanitizer runs. The earlier releases sanitized the string first and built the node from an inert representation.

Proof of concept

On TinyMCE 8.5.1 or later, initialised with plugins: 'media', run this in the browser console.

browser console, about 10 seconds
var ed = tinymce.activeEditor || tinymce.editors[0];
ed.setContent('<span data-mce-object="video" data-mce-html="<img src=dave onerror=alert(document.domain)>">x</span>');
ed.getContent();

The alert(document.domain) executes in the page origin, while getContent() returns a clean <p><video><img src="dave"></video></p>. I verified every reproduction path end to end against the 8.9.2 bundle.

Affected versions

This is a regression, not a long-standing issue. The vulnerable construction was introduced in the media refactor shipped in 7.9.3 and 8.5.1. Everything up to and including 7.9.2 and 8.5.0 is safe, because those releases pass the raw string to the sanitizer before building any DOM.

A regression, introduced in 7.9.3 and 8.5.1 7.x <= 7.9.2 safe 7.9.3 affected 8.x <= 8.5.0 safe 8.5.1 to 8.9.2 affected 8.9.3 fixed The 5.11.x LTS from the same release batch is worth checking as well.
The affected range, confirmed by diffing the tagged sources. FilterContent.ts in 8.9.2 shows the construction at lines 13, 64 and 69.

Impact and severity

An attacker who can get HTML into an editor that is later serialized runs script in the hosting page's origin, in the context of whoever is editing. In a review or collaboration flow that is another user, often a privileged one.

rating
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N  ->  8.7 (High)
CWE-79

The vector matches the one the maintainers assigned to the comparable sanitizer-bypass issue GHSA-mh5m-5hw4-5c69.

The fix

Sanitize data-mce-html before any innerHTML assignment, and build the preview element through an inert path (an AST, DOMParser, or <template>.content) rather than a live document.createElement, which is how the editor behaved before the refactor. The issue is fixed in 8.9.3 and tracked as GHSA-mf2p-h6hf-fcwm.

Disclosure

I reported this privately to the Tiny security team on 24 September 2026 under their coordinated-disclosure policy, and kept it confidential until the fix shipped. The team confirmed the issue the next day and asked how I would like to be credited in the advisory. They published GHSA-mf2p-h6hf-fcwm on 7 October 2026. My thanks to the team for the clear, professional handling throughout.

Timeline

timeline
2026-09-24: reported privately to the Tiny security team
2026-09-25: confirmed by the maintainers
2026-10-07: GHSA-mf2p-h6hf-fcwm published, fixed in 8.9.3
2026-10-13: this write-up published

References