CVE-2026-47759
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 via unsanitized data-mce-* attributes (data-mce-href, data-mce-src, data-mce-style). Allows attackers to inject malicious values that override safe attributes during serialization, bypassing validation. 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.
TinyMCE stored XSS via unsanitized data-mce-* attributes allows attackers to inject malicious values that override safe attributes during serialization, bypassing validation. Fixed in 5.11.1, 7.9.3, 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 handling of data-mce-* attributes, specifically data-mce-href, data-mce-src, and data-mce-style. During serialization, malicious values in these attributes can override safe attributes, bypassing validation and allowing injection of arbitrary scripts. The vulnerability is present in the core editor and does not require a specific plugin to be enabled [1][2][3].
Exploitation
An attacker with the ability to input or edit content in a TinyMCE instance (e.g., via a comment field, forum post, or CMS) can craft a payload containing malicious data-mce-* attributes. When the content is saved and later rendered by another user, the injected script executes in the context of the victim's browser. No special network position or authentication beyond normal content editing privileges is required [3].
Impact
Successful exploitation leads to stored XSS, allowing the attacker to execute arbitrary JavaScript in the browser of any user viewing the affected content. This can result in session hijacking, data theft, defacement, or other actions on behalf of the victim. The attack does not require user interaction beyond viewing the content [1][2][3].
Mitigation
The vulnerability is fixed in TinyMCE 5.11.1 LTS (commercial), 7.9.3, and 8.5.1. Users should upgrade to these versions or later. No official workaround is available [3]. The fix strips unsafe data-mce-* attributes during parsing [1][2].
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
78da3e85238b0TINY-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); + }); + }); +});
9dac5bb3c875TINY-14334: Fix failing test for 7.9.3 patch release
1 file changed · +5 −3
modules/tinymce/src/core/test/ts/browser/content/EditorContentProtectOptionTest.ts+5 −3 modified@@ -1,4 +1,5 @@ import { context, describe, it } from '@ephox/bedrock-client'; +import { PlatformDetection } from '@ephox/sand'; import { TinyAssertions, TinyHooks } from '@ephox/wrap-mcagar'; import type Editor from 'tinymce/core/api/Editor'; @@ -45,13 +46,14 @@ describe('browser.tinymce.core.content.EditorContentProtectOptionTest', () => { }); context('Raw comment injection', () => { + const platform = PlatformDetection.detect(); const testInnerContent = (testCase: { innerContent: string; expected: string }) => { const editor = hook.editor(); - editor.setContent(`<!--mce:protected ${escape(testCase.innerContent)}-->`); + editor.setContent(`<!--mce:protected ${encodeURIComponent(testCase.innerContent)}-->`); TinyAssertions.assertContent(editor, testCase.expected); - editor.setContent(`<!--mce:protected ${escape(testCase.innerContent)}-->`, { format: 'raw' }); + editor.setContent(`<!--mce:protected ${encodeURIComponent(testCase.innerContent)}-->`, { format: 'raw' }); TinyAssertions.assertContent(editor, testCase.expected); }; @@ -72,7 +74,7 @@ describe('browser.tinymce.core.content.EditorContentProtectOptionTest', () => { it('TINY-14353: Valid content', () => testInnerContent({ innerContent: '<if>', - expected: '<if>' + expected: platform.browser.isFirefox() ? '<p> </p><if>' : '<if>' })); }); });
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-q742-qvgc-gc2fnvdPatchVendor 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.