VYPR
High severity8.7NVD Advisory· Published May 28, 2026· Updated May 28, 2026

CVE-2026-47762

CVE-2026-47762

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 forged mce:protected comments. Allows attackers to bypass sanitization and inject scripts that execute when content is restored. Impacts users who utilize the protect option. 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.

Stored XSS in TinyMCE via forged mce:protected comments allows script injection when content is restored, affecting protect option users.

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 mce:protected comments. When the protect option is configured, attackers can forge these comments to bypass sanitization, leading to script execution upon content restoration [1][2][3].

Exploitation

An attacker with the ability to input content into the editor (e.g., via a comment field or form submission) can craft a malicious mce:protected comment containing JavaScript. No authentication is required if the editor is publicly accessible. The injected script executes when the content is later restored or rendered [3].

Impact

Successful exploitation results in stored XSS, allowing the attacker to execute arbitrary JavaScript in the context of the viewing user. This can lead to data theft, session hijacking, or content defacement. The vulnerability only affects configurations that utilize the protect option [1][2][3].

Mitigation

The vulnerability is fixed in TinyMCE 5.11.1, 7.9.3, and 8.5.1. Users should upgrade to these versions or later. No official workaround is available [3]. The fix validates decoded mce:protected content against configured protect regex rules before restoring [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

2
  • Tinymce/Tinymceinferred2 versions
    <5.11.1 || >=7.0,<7.9.3 || >=8.0,<8.5.1+ 1 more
    • (no CPE)range: <5.11.1 || >=7.0,<7.9.3 || >=8.0,<8.5.1
    • (no CPE)range: <5.11.1 & <7.9.3 & <8.5.1

Patches

6
a7d77018dd27

TINY-14353: Fixed stored XSS vulnerability through `mce:protected` comments (#24)

https://github.com/tinymce/tinymcespockeMay 5, 2026Fixed in 7.9.3via llm-release-walk
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');
       });
     });
    -
    
3507b58a55fa

TINY-14353: Fixed stored XSS vulnerability through `mce:protected` comments (#21)

https://github.com/tinymce/tinymcespockeMay 5, 2026Fixed in 8.5.1via llm-release-walk
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');
    
19f56a74f53a

TINY-14333: Fixed stored XSS vulnerability through `data-mce-` prefixed `src`, `href`, `style` attributes (#22)

https://github.com/tinymce/tinymcespockeMay 5, 2026Fixed in 8.5.1via llm-release-walk
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);
    +    });
    +  });
    +});
    
8da3e85238b0

TINY-14357: Fixed data-mce-object injection (#18)

https://github.com/tinymce/tinymcespockeMay 5, 2026Fixed in 8.5.1via llm-release-walk
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>'
    +  }));
    +});
    
1d4c86b9c516

TINY-14357: Fixed data-mce-object injection (#19)

https://github.com/tinymce/tinymcespockeMay 5, 2026Fixed in 7.9.3via llm-release-walk
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>'
    +  }));
    +});
    
0029dada0303

TINY-14333: Fixed stored XSS vulnerability through `data-mce-` prefixed `src`, `href`, `style` attributes (#23)

https://github.com/tinymce/tinymcespockeMay 5, 2026Fixed in 7.9.3via llm-release-walk
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);
    +    });
    +  });
    +});
    

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

News mentions

0

No linked articles in our index yet.