CVE-2026-47761
Description
TinyMCE is an open source rich text editor. Prior to 5.11.1, 7.9.3, and 8.5.1, there is a stored XSS vulnerability in the media plugin. Attackers can inject malicious scripts via crafted data-mce-* attributes, which are executed when content is rendered. Impacts users of TinyMCE with the media plugin enabled. This vulnerability is fixed in 5.11.1, 7.9.3, and 8.5.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A stored XSS vulnerability in TinyMCE's media plugin allows attackers to inject scripts via crafted data-mce-* attributes, fixed in versions 5.11.1, 7.9.3, and 8.5.1.
Vulnerability
TinyMCE versions prior to 5.11.1, 7.9.3, and 8.5.1 contain a stored cross-site scripting (XSS) vulnerability in the media plugin. The bug allows injection of malicious scripts through crafted data-mce-object, data-mce-p-*, data-mce-href, data-mce-src, or data-mce-style attributes, which are not properly sanitized during content rendering [1][2][3]. This affects all configurations where the media plugin is enabled.
Exploitation
An attacker requires the ability to input or edit content in a TinyMCE editor instance that has the media plugin enabled. The attacker crafts HTML with specially designed data-mce-* attributes containing JavaScript payloads. When the content is saved and later rendered in the editor or on a page using the editor's output, the malicious script executes in the context of the user's browser [1][3]. No additional authentication beyond the ability to submit content is needed if the editor is publicly accessible.
Impact
Successful exploitation allows an attacker to execute arbitrary JavaScript in the browser of any user viewing the affected content. This can lead to session hijacking, sensitive data exfiltration, defacement, or other malicious actions at the privilege level of the victim user [3].
Mitigation
The vulnerability is fixed in TinyMCE 5.11.1, 7.9.3, and 8.5.1. Users should upgrade to these or later versions. For TinyMCE 5.x LTS, the fix is available as part of a commercial long-term support contract [3]. No official workaround is available [3].
AI Insight generated on May 28, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
68da3e85238b0TINY-14357: Fixed data-mce-object injection (#18)
4 files changed · +145 −47
.changes/unreleased/tinymce-TINY-14357-2026-04-28.yaml+6 −0 added@@ -0,0 +1,6 @@ +project: tinymce +kind: Security +body: Fixed media plugin `data-mce-object` injection leading to stored XSS. +time: 2026-04-28T14:13:22.407181+02:00 +custom: + Issue: TINY-14357
modules/tinymce/src/plugins/media/main/ts/core/FilterContent.ts+79 −45 modified@@ -1,13 +1,85 @@ -import { Arr, Obj } from '@ephox/katamari'; +import { Arr, Obj, Optional, Type } from '@ephox/katamari'; import type Editor from 'tinymce/core/api/Editor'; -import AstNode from 'tinymce/core/api/html/Node'; +import type AstNode from 'tinymce/core/api/html/Node'; import * as Nodes from './Nodes'; import * as Sanitize from './Sanitize'; declare let unescape: any; +const buildMediaElement = (editor: Editor, node: AstNode) => { + const realElmName = node.attr('data-mce-object') as string; + const element = document.createElement(realElmName); + + // Add width/height to everything but audio + if (realElmName !== 'audio') { + const className = node.attr('class'); + const firstChild = node.firstChild; + + if (className && className.indexOf('mce-preview-object') !== -1 && firstChild) { + const width = firstChild.attr('width'); + const height = firstChild.attr('height'); + + if (Type.isString(width)) { + element.setAttribute('width', width); + } + + if (Type.isString(height)) { + element.setAttribute('height', height); + } + } else { + const width = node.attr('width'); + const height = node.attr('height'); + + if (Type.isString(width)) { + element.setAttribute('width', width); + } + + if (Type.isString(height)) { + element.setAttribute('height', height); + } + } + } + + const style = node.attr('style'); + if (Type.isString(style)) { + element.setAttribute('style', style); + } + + // Unprefix all placeholder attributes + const attribs = node.attributes ?? []; + let ai = attribs.length; + while (ai--) { + const attrName = attribs[ai].name; + + if (attrName.indexOf('data-mce-p-') === 0) { + element.setAttribute(attrName.substr(11), attribs[ai].value); + } + } + + // Inject innerhtml + const innerHtml = node.attr('data-mce-html'); + if (Type.isString(innerHtml)) { + element.innerHTML = unescape(innerHtml); + } else { + element.innerHTML = '\u00a0'; + } + + const fragment = Sanitize.parseAndSanitize(editor, element.outerHTML); + const newElement = fragment.getAll(realElmName)[0]; + + if (Type.isNonNullable(newElement)) { + if (!Type.isString(innerHtml)) { + newElement.empty(); + } + + return Optional.some(newElement); + } else { + return Optional.none(); + } +}; + const setup = (editor: Editor): void => { editor.on('PreInit', () => { const { schema, serializer, parser } = editor; @@ -34,56 +106,18 @@ const setup = (editor: Editor): void => { parser.addNodeFilter('iframe,video,audio,object,embed', Nodes.placeHolderConverter(editor)); // Replaces placeholder images with real elements for video, object, iframe etc - serializer.addAttributeFilter('data-mce-object', (nodes, name) => { + serializer.addAttributeFilter('data-mce-object', (nodes) => { let i = nodes.length; while (i--) { const node = nodes[i]; if (!node.parent) { continue; } - const realElmName = node.attr(name) as string; - const realElm = new AstNode(realElmName, 1); - - // Add width/height to everything but audio - if (realElmName !== 'audio') { - const className = node.attr('class'); - if (className && className.indexOf('mce-preview-object') !== -1 && node.firstChild) { - realElm.attr({ - width: node.firstChild.attr('width'), - height: node.firstChild.attr('height') - }); - } else { - realElm.attr({ - width: node.attr('width'), - height: node.attr('height') - }); - } - } - - realElm.attr({ - style: node.attr('style') - }); - - // Unprefix all placeholder attributes - 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); - } - } - - // Inject innerhtml - const innerHtml = node.attr('data-mce-html'); - if (innerHtml) { - const fragment = Sanitize.parseAndSanitize(editor, realElmName, unescape(innerHtml)); - Arr.each(fragment.children(), (child) => realElm.append(child)); - } - - node.replace(realElm); + buildMediaElement(editor, node).fold( + () => node.remove(), + (realElm) => node.replace(realElm) + ); } }); });
modules/tinymce/src/plugins/media/main/ts/core/Sanitize.ts+2 −2 modified@@ -5,11 +5,11 @@ import * as Options from '../api/Options'; import { Parser } from './Parser'; -const parseAndSanitize = (editor: Editor, context: string, html: string): AstNode => { +const parseAndSanitize = (editor: Editor, html: string): AstNode => { const getEditorOption = editor.options.get; const sanitize = getEditorOption('xss_sanitization'); const validate = Options.shouldFilterHtml(editor); - return Parser(editor.schema, { sanitize, validate }).parse(html, { context }); + return Parser(editor.schema, { sanitize, validate }).parse(html); }; export {
modules/tinymce/src/plugins/media/test/ts/browser/SanitizationTest.ts+58 −0 added@@ -0,0 +1,58 @@ +import { describe, it } from '@ephox/bedrock-client'; +import { TinyAssertions, TinyHooks } from '@ephox/wrap-mcagar'; + +import type Editor from 'tinymce/core/api/Editor'; +import Plugin from 'tinymce/plugins/media/Plugin'; + +describe('browser.tinymce.plugins.media.SanitizationTest', () => { + const hook = TinyHooks.bddSetupLight<Editor>({ + plugins: 'media', + base_url: '/project/tinymce/js/tinymce' + }, [ Plugin ]); + + const runTest = (testCase: { inputHtml: string; expectedHtml: string }) => { + const editor = hook.editor(); + editor.setContent(testCase.inputHtml); + TinyAssertions.assertContent(editor, testCase.expectedHtml); + }; + + it('TINY-14357: Event attributes should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="a" data-mce-p-href="#" data-mce-p-onclick="alert(1)">x</p>', + expectedHtml: '<a href="#"></a>' + })); + + it('TINY-14357: javascript urls in links should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="a" data-mce-p-href="javascript:alert(1)">x</p>', + expectedHtml: '<a></a>' + })); + + it('TINY-14357: javascript urls in images should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="img" data-mce-p-src="javascript:alert(1)">x</p>', + expectedHtml: '<img>' + })); + + it('TINY-14357: javascript urls in object should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="object" data-mce-p-data="javascript:alert(1)">x</p>', + expectedHtml: '<object></object>' + })); + + it('TINY-14357: scripts as data-mce-object should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="script" data-mce-html="alert(1)">x</p>', + expectedHtml: '' + })); + + it('TINY-14357: video with javascript urls should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="video" data-mce-p-src="javascript:alert(1)">x</p>', + expectedHtml: '<video></video>' + })); + + it('TINY-14357: video with event attributes should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="video" data-mce-p-src="about:blank" data-mce-p-onclick="alert(1)">x</p>', + expectedHtml: '<video src="about:blank"></video>' + })); + + it('TINY-14357: video element with valid properties and inner html should still work', () => runTest({ + inputHtml: `<p data-mce-object="video" data-mce-p-src="about:blank" data-mce-html='<a href="#">Unsupported content</a>'>x</p>`, + expectedHtml: '<video src="about:blank"><a href="#">Unsupported content</a></video>' + })); +});
1d4c86b9c516TINY-14357: Fixed data-mce-object injection (#19)
4 files changed · +144 −46
.changes/unreleased/tinymce-TINY-14357-2026-04-28.yaml+6 −0 added@@ -0,0 +1,6 @@ +project: tinymce +kind: Security +body: Fixed media plugin `data-mce-object` injection leading to stored XSS. +time: 2026-04-28T14:13:22.407181+02:00 +custom: + Issue: TINY-14357
modules/tinymce/src/plugins/media/main/ts/core/FilterContent.ts+78 −44 modified@@ -1,4 +1,4 @@ -import { Arr, Obj } from '@ephox/katamari'; +import { Arr, Obj, Optional, Type } from '@ephox/katamari'; import Editor from 'tinymce/core/api/Editor'; import AstNode from 'tinymce/core/api/html/Node'; @@ -8,6 +8,78 @@ import * as Sanitize from './Sanitize'; declare let unescape: any; +const buildMediaElement = (editor: Editor, node: AstNode) => { + const realElmName = node.attr('data-mce-object') as string; + const element = document.createElement(realElmName); + + // Add width/height to everything but audio + if (realElmName !== 'audio') { + const className = node.attr('class'); + const firstChild = node.firstChild; + + if (className && className.indexOf('mce-preview-object') !== -1 && firstChild) { + const width = firstChild.attr('width'); + const height = firstChild.attr('height'); + + if (Type.isString(width)) { + element.setAttribute('width', width); + } + + if (Type.isString(height)) { + element.setAttribute('height', height); + } + } else { + const width = node.attr('width'); + const height = node.attr('height'); + + if (Type.isString(width)) { + element.setAttribute('width', width); + } + + if (Type.isString(height)) { + element.setAttribute('height', height); + } + } + } + + const style = node.attr('style'); + if (Type.isString(style)) { + element.setAttribute('style', style); + } + + // Unprefix all placeholder attributes + const attribs = node.attributes ?? []; + let ai = attribs.length; + while (ai--) { + const attrName = attribs[ai].name; + + if (attrName.indexOf('data-mce-p-') === 0) { + element.setAttribute(attrName.substr(11), attribs[ai].value); + } + } + + // Inject innerhtml + const innerHtml = node.attr('data-mce-html'); + if (Type.isString(innerHtml)) { + element.innerHTML = unescape(innerHtml); + } else { + element.innerHTML = '\u00a0'; + } + + const fragment = Sanitize.parseAndSanitize(editor, element.outerHTML); + const newElement = fragment.getAll(realElmName)[0]; + + if (Type.isNonNullable(newElement)) { + if (!Type.isString(innerHtml)) { + newElement.empty(); + } + + return Optional.some(newElement); + } else { + return Optional.none(); + } +}; + const setup = (editor: Editor): void => { editor.on('PreInit', () => { const { schema, serializer, parser } = editor; @@ -34,56 +106,18 @@ const setup = (editor: Editor): void => { parser.addNodeFilter('iframe,video,audio,object,embed', Nodes.placeHolderConverter(editor)); // Replaces placeholder images with real elements for video, object, iframe etc - serializer.addAttributeFilter('data-mce-object', (nodes, name) => { + serializer.addAttributeFilter('data-mce-object', (nodes) => { let i = nodes.length; while (i--) { const node = nodes[i]; if (!node.parent) { continue; } - const realElmName = node.attr(name) as string; - const realElm = new AstNode(realElmName, 1); - - // Add width/height to everything but audio - if (realElmName !== 'audio') { - const className = node.attr('class'); - if (className && className.indexOf('mce-preview-object') !== -1 && node.firstChild) { - realElm.attr({ - width: node.firstChild.attr('width'), - height: node.firstChild.attr('height') - }); - } else { - realElm.attr({ - width: node.attr('width'), - height: node.attr('height') - }); - } - } - - realElm.attr({ - style: node.attr('style') - }); - - // Unprefix all placeholder attributes - 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); - } - } - - // Inject innerhtml - const innerHtml = node.attr('data-mce-html'); - if (innerHtml) { - const fragment = Sanitize.parseAndSanitize(editor, realElmName, unescape(innerHtml)); - Arr.each(fragment.children(), (child) => realElm.append(child)); - } - - node.replace(realElm); + buildMediaElement(editor, node).fold( + () => node.remove(), + (realElm) => node.replace(realElm) + ); } }); });
modules/tinymce/src/plugins/media/main/ts/core/Sanitize.ts+2 −2 modified@@ -4,11 +4,11 @@ import AstNode from 'tinymce/core/api/html/Node'; import * as Options from '../api/Options'; import { Parser } from './Parser'; -const parseAndSanitize = (editor: Editor, context: string, html: string): AstNode => { +const parseAndSanitize = (editor: Editor, html: string): AstNode => { const getEditorOption = editor.options.get; const sanitize = getEditorOption('xss_sanitization'); const validate = Options.shouldFilterHtml(editor); - return Parser(editor.schema, { sanitize, validate }).parse(html, { context }); + return Parser(editor.schema, { sanitize, validate }).parse(html); }; export {
modules/tinymce/src/plugins/media/test/ts/browser/SanitizationTest.ts+58 −0 added@@ -0,0 +1,58 @@ +import { describe, it } from '@ephox/bedrock-client'; +import { TinyAssertions, TinyHooks } from '@ephox/wrap-mcagar'; + +import type Editor from 'tinymce/core/api/Editor'; +import Plugin from 'tinymce/plugins/media/Plugin'; + +describe('browser.tinymce.plugins.media.SanitizationTest', () => { + const hook = TinyHooks.bddSetupLight<Editor>({ + plugins: 'media', + base_url: '/project/tinymce/js/tinymce' + }, [ Plugin ]); + + const runTest = (testCase: { inputHtml: string; expectedHtml: string }) => { + const editor = hook.editor(); + editor.setContent(testCase.inputHtml); + TinyAssertions.assertContent(editor, testCase.expectedHtml); + }; + + it('TINY-14357: Event attributes should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="a" data-mce-p-href="#" data-mce-p-onclick="alert(1)">x</p>', + expectedHtml: '<a href="#"></a>' + })); + + it('TINY-14357: javascript urls in links should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="a" data-mce-p-href="javascript:alert(1)">x</p>', + expectedHtml: '<a></a>' + })); + + it('TINY-14357: javascript urls in images should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="img" data-mce-p-src="javascript:alert(1)">x</p>', + expectedHtml: '<img>' + })); + + it('TINY-14357: javascript urls in object should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="object" data-mce-p-data="javascript:alert(1)">x</p>', + expectedHtml: '<object></object>' + })); + + it('TINY-14357: scripts as data-mce-object should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="script" data-mce-html="alert(1)">x</p>', + expectedHtml: '' + })); + + it('TINY-14357: video with javascript urls should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="video" data-mce-p-src="javascript:alert(1)">x</p>', + expectedHtml: '<video></video>' + })); + + it('TINY-14357: video with event attributes should not be allowed', () => runTest({ + inputHtml: '<p data-mce-object="video" data-mce-p-src="about:blank" data-mce-p-onclick="alert(1)">x</p>', + expectedHtml: '<video src="about:blank"></video>' + })); + + it('TINY-14357: video element with valid properties and inner html should still work', () => runTest({ + inputHtml: `<p data-mce-object="video" data-mce-p-src="about:blank" data-mce-html='<a href="#">Unsupported content</a>'>x</p>`, + expectedHtml: '<video src="about:blank"><a href="#">Unsupported content</a></video>' + })); +});
0029dada0303TINY-14333: Fixed stored XSS vulnerability through `data-mce-` prefixed `src`, `href`, `style` attributes (#23)
4 files changed · +102 −0
.changes/unreleased/tinymce-TINY-14333-2026-04-29.yaml+6 −0 added@@ -0,0 +1,6 @@ +project: tinymce +kind: Security +body: Fixed stored XSS vulnerability through `data-mce-` prefixed `src`, `href`, `style` attributes. +time: 2026-04-29T11:07:42.702682+02:00 +custom: + Issue: TINY-14333
modules/tinymce/src/core/main/ts/init/InitContentBody.ts+6 −0 modified@@ -142,6 +142,12 @@ const mkSerializerSettings = (editor: Editor): DomSerializerSettings => { const createParser = (editor: Editor): DomParser => { const parser = DomParser(mkParserSettings(editor), editor.schema); + parser.addAttributeFilter('data-mce-src,data-mce-href,data-mce-style', (nodes, name) => { + for (let i = 0; i < nodes.length; i++) { + nodes[i].attr(name, null); + } + }); + // Convert src and href into data-mce-src, data-mce-href and data-mce-style parser.addAttributeFilter('src,href,style,tabindex', (nodes, name) => { const dom = editor.dom;
modules/tinymce/src/core/test/ts/browser/content/EditorContentRetainAttributesTest.ts+41 −0 added@@ -0,0 +1,41 @@ +import { describe, it } from '@ephox/bedrock-client'; +import { TinyAssertions, TinyHooks } from '@ephox/wrap-mcagar'; + +import type Editor from 'tinymce/core/api/Editor'; + +describe('browser.tinymce.core.content.EditorContentRetailAttributesTest', () => { + const hook = TinyHooks.bddSetupLight<Editor>({ + base_url: '/project/tinymce/js/tinymce' + }, []); + + const testContentRetainAttributes = (testCase: { input: string; expected: string }) => { + const editor = hook.editor(); + + editor.setContent(testCase.input); + TinyAssertions.assertContent(editor, testCase.expected); + + editor.setContent(''); + editor.insertContent(testCase.input); + TinyAssertions.assertContent(editor, testCase.expected); + }; + + it('TINY-14333: Should support valid style, src and href', () => testContentRetainAttributes({ + input: '<p><span style="color: red;">Red</span> <a href="#">link</a> <img src="about:blank"></p>', + expected: '<p><span style="color: red;">Red</span> <a href="#">link</a> <img src="about:blank"></p>' + })); + + it('TINY-14333: data-mce-style attribute in input should be ignored', () => testContentRetainAttributes({ + input: `<p><span data-mce-style="color: url('javascript:alert(1)');" style="color: red;">Red</span></p>`, + expected: '<p><span style="color: red;">Red</span></p>' + })); + + it('TINY-14333: data-mce-src attribute in input should be ignored', () => testContentRetainAttributes({ + input: '<p><img data-mce-src="javascript:alert(1)" src="about:blank"></p>', + expected: '<p><img src="about:blank"></p>' + })); + + it('TINY-14333: data-mce-href attribute in input should be ignored', () => testContentRetainAttributes({ + input: '<p><a data-mce-href="javascript:alert(1)" href="about:blank">link</a></p>', + expected: '<p><a href="about:blank">link</a></p>' + })); +});
modules/tinymce/src/core/test/ts/browser/paste/PasteRetainAttributesTest.ts+49 −0 added@@ -0,0 +1,49 @@ +import { Clipboard } from '@ephox/agar'; +import { beforeEach, context, describe, it } from '@ephox/bedrock-client'; +import { TinyAssertions, TinyDom, TinyHooks } from '@ephox/wrap-mcagar'; + +import type Editor from 'tinymce/core/api/Editor'; + +describe('browser.tinymce.core.paste.PasteRetainAttributesTest', () => { + const hook = TinyHooks.bddSetupLight<Editor>({ + base_url: '/project/tinymce/js/tinymce', + indent: false, + paste_webkit_styles: 'all' + }, []); + + context('XSS paste tests', () => { + const payload = [ + '<p>', + '<a data-mce-href="javascript:alert(1)" href="#">Click here</a>', + '<img data-mce-src="javascript:alert(1)" src="about:blank">', + `<span data-mce-style="background: url('javascript:alert(1)');" style="color: red;">Red</span>`, + '</p>' + ].join(''); + const expectedOutput = [ + '<p>', + '<a href="#">Click here</a>', + '<img src="about:blank">', + `<span style="color: red;">Red</span>`, + '</p>' + ].join(''); + + beforeEach(() => { + const editor = hook.editor(); + editor.setContent(''); + }); + + it('TINY-14333: Paste XSS payload using mceInsertClipboardContent command', () => { + const editor = hook.editor(); + + editor.execCommand('mceInsertClipboardContent', false, { html: payload }); + TinyAssertions.assertContent(editor, expectedOutput); + }); + + it('TINY-14333: Paste XSS payload using paste event', () => { + const editor = hook.editor(); + + Clipboard.pasteItems(TinyDom.body(editor), { 'text/html': payload }); + TinyAssertions.assertContent(editor, expectedOutput); + }); + }); +});
a7d77018dd27TINY-14353: Fixed stored XSS vulnerability through `mce:protected` comments (#24)
6 files changed · +131 −31
.changes/unreleased/tinymce-TINY-14353-2026-04-29.yaml+6 −0 added@@ -0,0 +1,6 @@ +project: tinymce +kind: Security +body: Fixed stored XSS vulnerability through `mce:protected` comments. +time: 2026-04-29T09:58:39.925795+02:00 +custom: + Issue: TINY-14353
modules/tinymce/src/core/main/ts/dom/DomSerializerFilters.ts+0 −5 modified@@ -129,11 +129,6 @@ const register = (htmlParser: DomParser, settings: DomSerializerSettings, dom: D node.name = '#cdata'; node.type = 4; node.value = dom.decode(value.replace(/^\[CDATA\[|\]\]$/g, '')); - } else if (value?.indexOf('mce:protected ') === 0) { - node.name = '#text'; - node.type = 3; - node.raw = true; - node.value = unescape(value).substr(14); } } });
modules/tinymce/src/core/main/ts/html/ProtectedFilter.ts+45 −0 added@@ -0,0 +1,45 @@ +import { Arr } from '@ephox/katamari'; + +import type Editor from '../api/Editor'; + +const setupInputFiltering = (editor: Editor, protect: RegExp[]): void => { + editor.on('BeforeSetContent', (e) => { + Arr.each(protect, (pattern) => { + e.content = e.content.replace(pattern, (str) => { + return '<!--mce:protected ' + escape(str) + '-->'; + }); + }); + }); +}; + +const setupOutputFiltering = (editor: Editor, protect: RegExp[]): void => { + editor.serializer.addNodeFilter('#comment', (nodes) => { + let i = nodes.length; + while (i--) { + const node = nodes[i]; + const value = node.value; + + if (value?.indexOf('mce:protected ') === 0) { + const protectedHtml = unescape(value).substr(14); + const valid = Arr.exists(protect, (pattern) => { + const matches = protectedHtml.match(pattern); + return matches !== null && matches[0].length === protectedHtml.length; + }); + + if (valid) { + node.name = '#text'; + node.type = 3; + node.raw = true; + node.value = protectedHtml; + } else { + node.remove(); + } + } + } + }); +}; + +export const registerProtectedHtmlFilters = (editor: Editor, protect: RegExp[]): void => { + setupInputFiltering(editor, protect); + setupOutputFiltering(editor, protect); +};
modules/tinymce/src/core/main/ts/init/InitContentBody.ts+2 −8 modified@@ -26,6 +26,7 @@ import * as NodeType from '../dom/NodeType'; import * as TouchEvents from '../events/TouchEvents'; import * as ForceBlocks from '../ForceBlocks'; import * as NonEditableFilter from '../html/NonEditableFilter'; +import * as ProtectedFilter from '../html/ProtectedFilter'; import * as KeyboardOverrides from '../keyboard/KeyboardOverrides'; import * as Disabled from '../mode/Disabled'; import { NodeChange } from '../NodeChange'; @@ -40,7 +41,6 @@ import Quirks from '../util/Quirks'; import * as ContentCss from './ContentCss'; import * as LicenseKeyValidation from './LicenseKeyValidation'; -declare const escape: any; declare let tinymce: TinyMCE; const DOM = DOMUtils.DOM; @@ -362,13 +362,7 @@ const preInit = (editor: Editor) => { const protect = Options.getProtect(editor); if (protect) { - editor.on('BeforeSetContent', (e) => { - Tools.each(protect, (pattern) => { - e.content = e.content.replace(pattern, (str) => { - return '<!--mce:protected ' + escape(str) + '-->'; - }); - }); - }); + ProtectedFilter.registerProtectedHtmlFilters(editor, protect); } editor.on('SetContent', () => {
modules/tinymce/src/core/test/ts/browser/content/EditorContentProtectOptionTest.ts+78 −0 added@@ -0,0 +1,78 @@ +import { context, describe, it } from '@ephox/bedrock-client'; +import { TinyAssertions, TinyHooks } from '@ephox/wrap-mcagar'; + +import type Editor from 'tinymce/core/api/Editor'; + +describe('browser.tinymce.core.content.EditorContentProtectOptionTest', () => { + const hook = TinyHooks.bddSetupLight<Editor>({ + base_url: '/project/tinymce/js/tinymce', + protect: [ + /\<\/?(if|endif)\>/g, + /\<xsl\:[^>]+\>/g, + /<\?php.*?\?>/g + ], + indent: false + }); + + context('Valid protect content', () => { + const testValidProtect = (testCase: { input: string; expected: string }) => { + const editor = hook.editor(); + + editor.setContent(testCase.input); + + TinyAssertions.assertContent(editor, testCase.expected); + }; + + it('TINY-14353: Protect tags', () => testValidProtect({ + input: '<p>A</p><if><p>B</p></if><p>C</p>', + expected: '<p>A</p><if><p>B</p></if><p>C</p>' + })); + + it('TINY-14353: Protect xsl', () => testValidProtect({ + input: '<p>A</p><xsl:foo><p>B</p><p>C</p>', + expected: '<p>A</p><xsl:foo><p>B</p><p>C</p>' + })); + + it('TINY-14353: Protect php', () => testValidProtect({ + input: '<p>A</p><?php echo "B"; ?><p>C</p>', + expected: '<p>A</p><?php echo "B"; ?><p>C</p>' + })); + + it('TINY-14353: Protect multiple tags', () => testValidProtect({ + input: '<p>A</p><if><p>B</p></if><xsl:foo><p>C</p><?php echo "D"; ?><p>E</p>', + expected: '<p>A</p><if><p>B</p></if><xsl:foo><p>C</p><?php echo "D"; ?><p>E</p>' + })); + }); + + context('Raw comment injection', () => { + const testInnerContent = (testCase: { innerContent: string; expected: string }) => { + const editor = hook.editor(); + + editor.setContent(`<!--mce:protected ${escape(testCase.innerContent)}-->`); + TinyAssertions.assertContent(editor, testCase.expected); + + editor.setContent(`<!--mce:protected ${escape(testCase.innerContent)}-->`, { format: 'raw' }); + TinyAssertions.assertContent(editor, testCase.expected); + }; + + it('TINY-14353: Script payload should not be allowed', () => testInnerContent({ + innerContent: '<script>alert(1)</script>', + expected: '' + })); + + it('TINY-14353: Img with attribute payload should not be allowed', () => testInnerContent({ + innerContent: '<img src="#" onerror="alert(1)">', + expected: '' + })); + + it('TINY-14353: Script payload with partial matching protect should not be allowed', () => testInnerContent({ + innerContent: '<if><script>alert(1)</script>', + expected: '' + })); + + it('TINY-14353: Valid content', () => testInnerContent({ + innerContent: '<if>', + expected: '<if>' + })); + }); +});
modules/tinymce/src/core/test/ts/browser/dom/SerializerTest.ts+0 −18 modified@@ -9,8 +9,6 @@ import * as Zwsp from 'tinymce/core/text/Zwsp'; import * as ViewBlock from '../../module/test/ViewBlock'; -declare const escape: any; - describe('browser.tinymce.core.dom.SerializerTest', () => { const DOM = DOMUtils.DOM; const viewBlock = ViewBlock.bddSetup(); @@ -655,21 +653,6 @@ describe('browser.tinymce.core.dom.SerializerTest', () => { assert.equal(ser.serialize(getTestElement()), '<pre> </pre>'); }); - it('Protected blocks', () => { - const ser = DomSerializer({ fix_list_elements: true }); - - ser.setRules('noscript[test]'); - - setTestHtml('<!--mce:protected ' + escape('<noscript test="test"><br></noscript>') + '-->'); - assert.equal(ser.serialize(getTestElement()), '<noscript test="test"><br></noscript>'); - - setTestHtml('<!--mce:protected ' + escape('<noscript><br></noscript>') + '-->'); - assert.equal(ser.serialize(getTestElement()), '<noscript><br></noscript>'); - - setTestHtml('<!--mce:protected ' + escape('<noscript><!-- text --><br></noscript>') + '-->'); - assert.equal(ser.serialize(getTestElement()), '<noscript><!-- text --><br></noscript>'); - }); - it('CDATA', () => { const ser = DomSerializer({ fix_list_elements: true, preserve_cdata: true }); ser.setRules('span'); @@ -936,4 +919,3 @@ describe('browser.tinymce.core.dom.SerializerTest', () => { 'Should remove br'); }); }); -
3507b58a55faTINY-14353: Fixed stored XSS vulnerability through `mce:protected` comments (#21)
6 files changed · +132 −31
.changes/unreleased/tinymce-TINY-14353-2026-04-29.yaml+6 −0 added@@ -0,0 +1,6 @@ +project: tinymce +kind: Security +body: Fixed stored XSS vulnerability through `mce:protected` comments. +time: 2026-04-29T09:58:39.925795+02:00 +custom: + Issue: TINY-14353
modules/tinymce/src/core/main/ts/dom/DomSerializerFilters.ts+1 −6 modified@@ -119,7 +119,7 @@ const register = (htmlParser: DomParser, settings: DomSerializerSettings, dom: D } }); - // Convert comments to cdata and handle protected comments + // Convert comments to cdata htmlParser.addNodeFilter('#comment', (nodes) => { let i = nodes.length; while (i--) { @@ -130,11 +130,6 @@ const register = (htmlParser: DomParser, settings: DomSerializerSettings, dom: D node.name = '#cdata'; node.type = 4; node.value = dom.decode(value.replace(/^\[CDATA\[|\]\]$/g, '')); - } else if (value?.indexOf('mce:protected ') === 0) { - node.name = '#text'; - node.type = 3; - node.raw = true; - node.value = unescape(value).substr(14); } } });
modules/tinymce/src/core/main/ts/html/ProtectedFilter.ts+45 −0 added@@ -0,0 +1,45 @@ +import { Arr } from '@ephox/katamari'; + +import type Editor from '../api/Editor'; + +const setupInputFiltering = (editor: Editor, protect: RegExp[]): void => { + editor.on('BeforeSetContent', (e) => { + Arr.each(protect, (pattern) => { + e.content = e.content.replace(pattern, (str) => { + return '<!--mce:protected ' + escape(str) + '-->'; + }); + }); + }); +}; + +const setupOutputFiltering = (editor: Editor, protect: RegExp[]): void => { + editor.serializer.addNodeFilter('#comment', (nodes) => { + let i = nodes.length; + while (i--) { + const node = nodes[i]; + const value = node.value; + + if (value?.indexOf('mce:protected ') === 0) { + const protectedHtml = unescape(value).substr(14); + const valid = Arr.exists(protect, (pattern) => { + const matches = protectedHtml.match(pattern); + return matches !== null && matches[0].length === protectedHtml.length; + }); + + if (valid) { + node.name = '#text'; + node.type = 3; + node.raw = true; + node.value = protectedHtml; + } else { + node.remove(); + } + } + } + }); +}; + +export const registerProtectedHtmlFilters = (editor: Editor, protect: RegExp[]): void => { + setupInputFiltering(editor, protect); + setupOutputFiltering(editor, protect); +};
modules/tinymce/src/core/main/ts/init/InitContentBody.ts+2 −8 modified@@ -26,6 +26,7 @@ import * as NodeType from '../dom/NodeType'; import * as TouchEvents from '../events/TouchEvents'; import * as ForceBlocks from '../ForceBlocks'; import * as NonEditableFilter from '../html/NonEditableFilter'; +import * as ProtectedFilter from '../html/ProtectedFilter'; import * as KeyboardOverrides from '../keyboard/KeyboardOverrides'; import * as Lists from '../lists/Lists'; import * as Disabled from '../mode/Disabled'; @@ -41,7 +42,6 @@ import Quirks from '../util/Quirks'; import * as InitComponents from './InitComponents'; -declare const escape: any; declare let tinymce: TinyMCE; const DOM = DOMUtils.DOM; @@ -369,13 +369,7 @@ const preInit = (editor: Editor) => { const protect = Options.getProtect(editor); if (protect) { - editor.on('BeforeSetContent', (e) => { - Tools.each(protect, (pattern) => { - e.content = e.content.replace(pattern, (str) => { - return '<!--mce:protected ' + escape(str) + '-->'; - }); - }); - }); + ProtectedFilter.registerProtectedHtmlFilters(editor, protect); } editor.on('SetContent', () => {
modules/tinymce/src/core/test/ts/browser/content/EditorContentProtectOptionTest.ts+78 −0 added@@ -0,0 +1,78 @@ +import { context, describe, it } from '@ephox/bedrock-client'; +import { TinyAssertions, TinyHooks } from '@ephox/wrap-mcagar'; + +import type Editor from 'tinymce/core/api/Editor'; + +describe('browser.tinymce.core.content.EditorContentProtectOptionTest', () => { + const hook = TinyHooks.bddSetupLight<Editor>({ + base_url: '/project/tinymce/js/tinymce', + protect: [ + /\<\/?(if|endif)\>/g, + /\<xsl\:[^>]+\>/g, + /<\?php.*?\?>/g + ], + indent: false + }); + + context('Valid protect content', () => { + const testValidProtect = (testCase: { input: string; expected: string }) => { + const editor = hook.editor(); + + editor.setContent(testCase.input); + + TinyAssertions.assertContent(editor, testCase.expected); + }; + + it('TINY-14353: Protect tags', () => testValidProtect({ + input: '<p>A</p><if><p>B</p></if><p>C</p>', + expected: '<p>A</p><if><p>B</p></if><p>C</p>' + })); + + it('TINY-14353: Protect xsl', () => testValidProtect({ + input: '<p>A</p><xsl:foo><p>B</p><p>C</p>', + expected: '<p>A</p><xsl:foo><p>B</p><p>C</p>' + })); + + it('TINY-14353: Protect php', () => testValidProtect({ + input: '<p>A</p><?php echo "B"; ?><p>C</p>', + expected: '<p>A</p><?php echo "B"; ?><p>C</p>' + })); + + it('TINY-14353: Protect multiple tags', () => testValidProtect({ + input: '<p>A</p><if><p>B</p></if><xsl:foo><p>C</p><?php echo "D"; ?><p>E</p>', + expected: '<p>A</p><if><p>B</p></if><xsl:foo><p>C</p><?php echo "D"; ?><p>E</p>' + })); + }); + + context('Raw comment injection', () => { + const testInnerContent = (testCase: { innerContent: string; expected: string }) => { + const editor = hook.editor(); + + editor.setContent(`<!--mce:protected ${escape(testCase.innerContent)}-->`); + TinyAssertions.assertContent(editor, testCase.expected); + + editor.setContent(`<!--mce:protected ${escape(testCase.innerContent)}-->`, { format: 'raw' }); + TinyAssertions.assertContent(editor, testCase.expected); + }; + + it('TINY-14353: Script payload should not be allowed', () => testInnerContent({ + innerContent: '<script>alert(1)</script>', + expected: '' + })); + + it('TINY-14353: Img with attribute payload should not be allowed', () => testInnerContent({ + innerContent: '<img src="#" onerror="alert(1)">', + expected: '' + })); + + it('TINY-14353: Script payload with partial matching protect should not be allowed', () => testInnerContent({ + innerContent: '<if><script>alert(1)</script>', + expected: '' + })); + + it('TINY-14353: Valid content', () => testInnerContent({ + innerContent: '<if>', + expected: '<if>' + })); + }); +});
modules/tinymce/src/core/test/ts/browser/dom/SerializerTest.ts+0 −17 modified@@ -9,8 +9,6 @@ import * as Zwsp from 'tinymce/core/text/Zwsp'; import * as ViewBlock from '../../module/test/ViewBlock'; -declare const escape: any; - describe('browser.tinymce.core.dom.SerializerTest', () => { const DOM = DOMUtils.DOM; const viewBlock = ViewBlock.bddSetup(); @@ -608,21 +606,6 @@ describe('browser.tinymce.core.dom.SerializerTest', () => { assert.equal(ser.serialize(getTestElement()), '<script>/* <!-- */\nvar hi = \"hello\";\n/*-->*/</script>'); }); - it('Protected blocks', () => { - const ser = DomSerializer({ fix_list_elements: true }); - - ser.setRules('noscript[test]'); - - setTestHtml('<!--mce:protected ' + escape('<noscript test="test"><br></noscript>') + '-->'); - assert.equal(ser.serialize(getTestElement()), '<noscript test="test"><br></noscript>'); - - setTestHtml('<!--mce:protected ' + escape('<noscript><br></noscript>') + '-->'); - assert.equal(ser.serialize(getTestElement()), '<noscript><br></noscript>'); - - setTestHtml('<!--mce:protected ' + escape('<noscript><!-- text --><br></noscript>') + '-->'); - assert.equal(ser.serialize(getTestElement()), '<noscript><!-- text --><br></noscript>'); - }); - it('Style with whitespace at beginning with element_format: xhtml', () => { const ser = DomSerializer({ fix_list_elements: true, valid_children: '+body[style]', element_format: 'xhtml' }); ser.setRules('style');
19f56a74f53aTINY-14333: Fixed stored XSS vulnerability through `data-mce-` prefixed `src`, `href`, `style` attributes (#22)
4 files changed · +102 −0
.changes/unreleased/tinymce-TINY-14333-2026-04-29.yaml+6 −0 added@@ -0,0 +1,6 @@ +project: tinymce +kind: Security +body: Fixed stored XSS vulnerability through `data-mce-` prefixed `src`, `href`, `style` attributes. +time: 2026-04-29T11:07:42.702682+02:00 +custom: + Issue: TINY-14333
modules/tinymce/src/core/main/ts/init/InitContentBody.ts+6 −0 modified@@ -143,6 +143,12 @@ const mkSerializerSettings = (editor: Editor): DomSerializerSettings => { const createParser = (editor: Editor): DomParser => { const parser = DomParser(mkParserSettings(editor), editor.schema); + parser.addAttributeFilter('data-mce-src,data-mce-href,data-mce-style', (nodes, name) => { + for (let i = 0; i < nodes.length; i++) { + nodes[i].attr(name, null); + } + }); + // Convert src and href into data-mce-src, data-mce-href and data-mce-style parser.addAttributeFilter('src,href,style,tabindex', (nodes, name) => { const dom = editor.dom;
modules/tinymce/src/core/test/ts/browser/content/EditorContentRetainAttributesTest.ts+41 −0 added@@ -0,0 +1,41 @@ +import { describe, it } from '@ephox/bedrock-client'; +import { TinyAssertions, TinyHooks } from '@ephox/wrap-mcagar'; + +import type Editor from 'tinymce/core/api/Editor'; + +describe('browser.tinymce.core.content.EditorContentRetailAttributesTest', () => { + const hook = TinyHooks.bddSetupLight<Editor>({ + base_url: '/project/tinymce/js/tinymce' + }, []); + + const testContentRetainAttributes = (testCase: { input: string; expected: string }) => { + const editor = hook.editor(); + + editor.setContent(testCase.input); + TinyAssertions.assertContent(editor, testCase.expected); + + editor.setContent(''); + editor.insertContent(testCase.input); + TinyAssertions.assertContent(editor, testCase.expected); + }; + + it('TINY-14333: Should support valid style, src and href', () => testContentRetainAttributes({ + input: '<p><span style="color: red;">Red</span> <a href="#">link</a> <img src="about:blank"></p>', + expected: '<p><span style="color: red;">Red</span> <a href="#">link</a> <img src="about:blank"></p>' + })); + + it('TINY-14333: data-mce-style attribute in input should be ignored', () => testContentRetainAttributes({ + input: `<p><span data-mce-style="color: url('javascript:alert(1)');" style="color: red;">Red</span></p>`, + expected: '<p><span style="color: red;">Red</span></p>' + })); + + it('TINY-14333: data-mce-src attribute in input should be ignored', () => testContentRetainAttributes({ + input: '<p><img data-mce-src="javascript:alert(1)" src="about:blank"></p>', + expected: '<p><img src="about:blank"></p>' + })); + + it('TINY-14333: data-mce-href attribute in input should be ignored', () => testContentRetainAttributes({ + input: '<p><a data-mce-href="javascript:alert(1)" href="about:blank">link</a></p>', + expected: '<p><a href="about:blank">link</a></p>' + })); +});
modules/tinymce/src/core/test/ts/browser/paste/PasteRetainAttributesTest.ts+49 −0 added@@ -0,0 +1,49 @@ +import { Clipboard } from '@ephox/agar'; +import { beforeEach, context, describe, it } from '@ephox/bedrock-client'; +import { TinyAssertions, TinyDom, TinyHooks } from '@ephox/wrap-mcagar'; + +import type Editor from 'tinymce/core/api/Editor'; + +describe('browser.tinymce.core.paste.PasteRetainAttributesTest', () => { + const hook = TinyHooks.bddSetupLight<Editor>({ + base_url: '/project/tinymce/js/tinymce', + indent: false, + paste_webkit_styles: 'all' + }, []); + + context('XSS paste tests', () => { + const payload = [ + '<p>', + '<a data-mce-href="javascript:alert(1)" href="#">Click here</a>', + '<img data-mce-src="javascript:alert(1)" src="about:blank">', + `<span data-mce-style="background: url('javascript:alert(1)');" style="color: red;">Red</span>`, + '</p>' + ].join(''); + const expectedOutput = [ + '<p>', + '<a href="#">Click here</a>', + '<img src="about:blank">', + `<span style="color: red;">Red</span>`, + '</p>' + ].join(''); + + beforeEach(() => { + const editor = hook.editor(); + editor.setContent(''); + }); + + it('TINY-14333: Paste XSS payload using mceInsertClipboardContent command', () => { + const editor = hook.editor(); + + editor.execCommand('mceInsertClipboardContent', false, { html: payload }); + TinyAssertions.assertContent(editor, expectedOutput); + }); + + it('TINY-14333: Paste XSS payload using paste event', () => { + const editor = hook.editor(); + + Clipboard.pasteItems(TinyDom.body(editor), { 'text/html': payload }); + TinyAssertions.assertContent(editor, expectedOutput); + }); + }); +});
Vulnerability mechanics
No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.
References
3- github.com/tinymce/tinymce/security/advisories/GHSA-vg35-5wq7-3x7wnvdPatchVendor Advisory
- www.tiny.cloud/docs/tinymce/7/7.9.3-release-notes/nvdRelease Notes
- www.tiny.cloud/docs/tinymce/8/8.5.1-release-notes/nvdRelease Notes
News mentions
0No linked articles in our index yet.