VYPR
Moderate severityNVD Advisory· Published Mar 26, 2024· Updated Aug 2, 2024

TinyMCE Cross-Site Scripting (XSS) vulnerability in handling external SVG files through Object or Embed elements

CVE-2024-29881

Description

TinyMCE is an open source rich text editor. A cross-site scripting (XSS) vulnerability was discovered in TinyMCE’s content loading and content inserting code. A SVG image could be loaded though an object or embed element and that image could potentially contain a XSS payload. This vulnerability is fixed in 6.8.1 and 7.0.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
tinymce/tinymcePackagist
< 7.0.07.0.0
tinymcenpm
< 7.0.07.0.0
TinyMCENuGet
< 7.0.07.0.0

Affected products

1

Patches

1
bcdea2ad14e3

TINY-10348 & TINY-10349: Add sandbox_iframes and convert_unsafe_embeds options (#9172)

https://github.com/tinymce/tinymcemkzhxNov 21, 2023via ghsa
16 files changed · +483 60
  • modules/tinymce/CHANGELOG.md+2 0 modified
    @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
     - Added new `StylesheetLoader.unloadRawCss` API that can be used to unload CSS that was loaded into a style element. #TINY-10352
     - Added `force_hex_color` editor option. Option `'always'` converts all RGB & RGBA colours to hex, `'rgb_only'` will only convert RGB and *not* RGBA colours to hex, `'off'` won't convert any colours to hex. #TINY-9819
     - Added `default_font_stack` editor option that makes it possible to define what is considered a system font stack. #TINY-10290
    +- New `sandbox_iframes` option that controls whether iframe elements will be added a `sandbox=""` attribute to mitigate malicious intent. #TINY-10348
    +- New `convert_unsafe_embeds` option that controls whether `<object>` and `<embed>` elements will be converted to more restrictive alternatives, namely `<img>` for image MIME types, `<video>` for video MIME types, `<audio>` audio MIME types, or `<iframe>` for other or unspecified MIME types. #TINY-10349
     
     
     ### Improved
    
  • modules/tinymce/src/core/main/ts/api/html/DomParser.ts+2 0 modified
    @@ -57,6 +57,7 @@ export interface DomParserSettings {
       allow_unsafe_link_target?: boolean;
       blob_cache?: BlobCache;
       convert_fonts_to_spans?: boolean;
    +  convert_unsafe_embeds?: boolean;
       document?: Document;
       fix_list_elements?: boolean;
       font_size_legacy_values?: string;
    @@ -69,6 +70,7 @@ export interface DomParserSettings {
        * @deprecated Remove trailing <br> tags functionality has been added to tinymce.dom.Serializer and option will be removed in the next major release */
       remove_trailing_brs?: boolean;
       root_name?: string;
    +  sandbox_iframes?: boolean;
       sanitize?: boolean;
       validate?: boolean;
     }
    
  • modules/tinymce/src/core/main/ts/api/Options.ts+20 19 modified
    @@ -836,6 +836,16 @@ const register = (editor: Editor): void => {
         default: 'off',
       });
     
    +  registerOption('sandbox_iframes', {
    +    processor: 'boolean',
    +    default: false
    +  });
    +
    +  registerOption('convert_unsafe_embeds', {
    +    processor: 'boolean',
    +    default: false
    +  });
    +
       // These options must be registered later in the init sequence due to their default values
       editor.on('ScriptsLoaded', () => {
         registerOption('directionality', {
    @@ -944,28 +954,17 @@ const shouldPreserveCData = option('preserve_cdata');
     const shouldHighlightOnFocus = option('highlight_on_focus');
     const shouldSanitizeXss = option('xss_sanitization');
     const shouldUseDocumentWrite = option('init_content_sync');
    -
    -const hasTextPatternsLookup = (editor: Editor): boolean =>
    -  editor.options.isSet('text_patterns_lookup');
    -
    -const getFontStyleValues = (editor: Editor): string[] =>
    -  Tools.explode(editor.options.get('font_size_style_values'));
    -
    -const getFontSizeClasses = (editor: Editor): string[] =>
    -  Tools.explode(editor.options.get('font_size_classes'));
    -
    -const isEncodingXml = (editor: Editor): boolean =>
    -  editor.options.get('encoding') === 'xml';
    -
    -const getAllowedImageFileTypes = (editor: Editor): string[] =>
    -  Tools.explode(editor.options.get('images_file_types'));
    -
    +const hasTextPatternsLookup = (editor: Editor): boolean => editor.options.isSet('text_patterns_lookup');
    +const getFontStyleValues = (editor: Editor): string[] => Tools.explode(editor.options.get('font_size_style_values'));
    +const getFontSizeClasses = (editor: Editor): string[] => Tools.explode(editor.options.get('font_size_classes'));
    +const isEncodingXml = (editor: Editor): boolean => editor.options.get('encoding') === 'xml';
    +const getAllowedImageFileTypes = (editor: Editor): string[] => Tools.explode(editor.options.get('images_file_types'));
     const hasTableTabNavigation = option('table_tab_navigation');
    -
     const getDetailsInitialState = option('details_initial_state');
     const getDetailsSerializedState = option('details_serialized_state');
    -
     const shouldForceHexColor = option('force_hex_color');
    +const shouldSandboxIframes = option('sandbox_iframes');
    +const shouldConvertUnsafeEmbeds = option('convert_unsafe_embeds');
     
     export {
       register,
    @@ -1071,5 +1070,7 @@ export {
       getDetailsInitialState,
       getDetailsSerializedState,
       shouldUseDocumentWrite,
    -  shouldForceHexColor
    +  shouldForceHexColor,
    +  shouldSandboxIframes,
    +  shouldConvertUnsafeEmbeds
     };
    
  • modules/tinymce/src/core/main/ts/api/OptionTypes.ts+4 0 modified
    @@ -86,6 +86,7 @@ interface BaseEditorOptions {
       contextmenu?: string | string[] | false;
       contextmenu_never_use_native?: boolean;
       convert_fonts_to_spans?: boolean;
    +  convert_unsafe_embeds?: boolean;
       convert_urls?: boolean;
       custom_colors?: boolean;
       custom_elements?: string;
    @@ -197,6 +198,7 @@ interface BaseEditorOptions {
       resize?: boolean | 'both';
       resize_img_proportional?: boolean;
       root_name?: string;
    +  sandbox_iframes?: boolean;
       schema?: SchemaType;
       selector?: string;
       setup?: SetupCallback;
    @@ -289,6 +291,7 @@ export interface EditorOptions extends NormalizedEditorOptions {
       color_default_foreground: string;
       content_css: string[];
       contextmenu: string[];
    +  convert_unsafe_embeds: boolean;
       custom_colors: boolean;
       default_font_stack: string[];
       document_base_url: string;
    @@ -336,6 +339,7 @@ export interface EditorOptions extends NormalizedEditorOptions {
       promotion: boolean;
       readonly: boolean;
       removed_menuitems: string;
    +  sandbox_iframes: boolean;
       toolbar: boolean | string | string[] | Array<ToolbarGroup>;
       toolbar_groups: Record<string, Toolbar.GroupToolbarButtonSpec>;
       toolbar_location: ToolbarLocation;
    
  • modules/tinymce/src/core/main/ts/content/PrePostProcess.ts+5 5 modified
    @@ -2,7 +2,7 @@ import { Result } from '@ephox/katamari';
     
     import Editor from '../api/Editor';
     import * as Events from '../api/Events';
    -import DomParser from '../api/html/DomParser';
    +import DomParser, { DomParserSettings } from '../api/html/DomParser';
     import HtmlSerializer from '../api/html/Serializer';
     import * as Options from '../api/Options';
     import { EditorEvent } from '../api/util/EventDispatcher';
    @@ -11,7 +11,7 @@ import { Content, GetContentArgs, isTreeNode, SetContentArgs } from './ContentTy
     const serializeContent = (content: Content): string =>
       isTreeNode(content) ? HtmlSerializer({ validate: false }).serialize(content) : content;
     
    -const withSerializedContent = <R extends EditorEvent<{ content: string }>>(content: Content, fireEvent: (content: string) => R, sanitize: boolean): R & { content: Content } => {
    +const withSerializedContent = <R extends EditorEvent<{ content: string }>>(content: Content, fireEvent: (content: string) => R, parserSettings: DomParserSettings): R & { content: Content } => {
       const serializedContent = serializeContent(content);
       const eventArgs = fireEvent(serializedContent);
       if (eventArgs.isDefaultPrevented()) {
    @@ -20,7 +20,7 @@ const withSerializedContent = <R extends EditorEvent<{ content: string }>>(conte
         // Restore the content type back to being an AstNode. If the content has changed we need to
         // re-parse the new content, otherwise we can return the input.
         if (eventArgs.content !== serializedContent) {
    -      const rootNode = DomParser({ validate: false, forced_root_block: false, sanitize }).parse(eventArgs.content, { context: content.name });
    +      const rootNode = DomParser({ validate: false, forced_root_block: false, ...parserSettings }).parse(eventArgs.content, { context: content.name });
           return { ...eventArgs, content: rootNode };
         } else {
           return { ...eventArgs, content };
    @@ -47,7 +47,7 @@ const postProcessGetContent = <T extends GetContentArgs>(editor: Editor, content
       if (args.no_events) {
         return content;
       } else {
    -    const processedEventArgs = withSerializedContent(content, (content) => Events.fireGetContent(editor, { ...args, content }), Options.shouldSanitizeXss(editor));
    +    const processedEventArgs = withSerializedContent(content, (content) => Events.fireGetContent(editor, { ...args, content }), { sanitize: Options.shouldSanitizeXss(editor), sandbox_iframes: Options.shouldSandboxIframes(editor) });
         return processedEventArgs.content;
       }
     };
    @@ -56,7 +56,7 @@ const preProcessSetContent = <T extends SetContentArgs>(editor: Editor, args: T)
       if (args.no_events) {
         return Result.value(args);
       } else {
    -    const processedEventArgs = withSerializedContent(args.content, (content) => Events.fireBeforeSetContent(editor, { ...args, content }), Options.shouldSanitizeXss(editor));
    +    const processedEventArgs = withSerializedContent(args.content, (content) => Events.fireBeforeSetContent(editor, { ...args, content }), { sanitize: Options.shouldSanitizeXss(editor), sandbox_iframes: Options.shouldSandboxIframes(editor) });
         if (processedEventArgs.isDefaultPrevented()) {
           Events.fireSetContent(editor, processedEventArgs);
           return Result.error(undefined);
    
  • modules/tinymce/src/core/main/ts/html/ParserFilters.ts+48 1 modified
    @@ -1,4 +1,4 @@
    -import { Arr, Type } from '@ephox/katamari';
    +import { Arr, Strings, Type } from '@ephox/katamari';
     
     import Env from '../api/Env';
     import DomParser, { DomParserSettings } from '../api/html/DomParser';
    @@ -32,6 +32,36 @@ const registerBase64ImageFilter = (parser: DomParser, settings: DomParserSetting
       }
     };
     
    +const isMimeType = (mime: string, type: 'image' | 'video' | 'audio'): boolean => Strings.startsWith(mime, `${type}/`);
    +
    +const createSafeEmbed = (mime?: string, src?: string, width?: string, height?: string, sandboxIframes?: boolean): AstNode => {
    +  let name: 'iframe' | 'img' | 'video' | 'audio';
    +  if (Type.isUndefined(mime)) {
    +    name = 'iframe';
    +  } else if (isMimeType(mime, 'image')) {
    +    name = 'img';
    +  } else if (isMimeType(mime, 'video')) {
    +    name = 'video';
    +  } else if (isMimeType(mime, 'audio')) {
    +    name = 'audio';
    +  } else {
    +    name = 'iframe';
    +  }
    +
    +  const embed = new AstNode(name, 1);
    +  embed.attr(name === 'audio' ? { src } : { src, width, height });
    +
    +  // TINY-10349: Show controls for audio and video so the replaced embed is visible in editor.
    +  if (name === 'audio' || name === 'video') {
    +    embed.attr('controls', '');
    +  }
    +
    +  if (name === 'iframe' && sandboxIframes) {
    +    embed.attr('sandbox', '');
    +  }
    +  return embed;
    +};
    +
     const register = (parser: DomParser, settings: DomParserSettings): void => {
       const schema = parser.schema;
     
    @@ -153,6 +183,23 @@ const register = (parser: DomParser, settings: DomParserSettings): void => {
       }
     
       registerBase64ImageFilter(parser, settings);
    +
    +  if (settings.convert_unsafe_embeds) {
    +    parser.addNodeFilter('object,embed', (nodes) => Arr.each(nodes, (node) => {
    +      node.replace(
    +        createSafeEmbed(
    +          node.attr('type'),
    +          node.name === 'object' ? node.attr('data') : node.attr('src'),
    +          node.attr('width'),
    +          node.attr('height'),
    +          settings.sandbox_iframes
    +        ));
    +    }));
    +  }
    +
    +  if (settings.sandbox_iframes) {
    +    parser.addNodeFilter('iframe', (nodes) => Arr.each(nodes, (node) => node.attr('sandbox', '')));
    +  }
     };
     
     export {
    
  • modules/tinymce/src/core/main/ts/init/InitContentBody.ts+2 0 modified
    @@ -73,6 +73,7 @@ const mkParserSettings = (editor: Editor): DomParserSettings => {
         allow_html_in_named_anchor: getOption('allow_html_in_named_anchor'),
         allow_script_urls: getOption('allow_script_urls'),
         allow_unsafe_link_target: getOption('allow_unsafe_link_target'),
    +    convert_unsafe_embeds: getOption('convert_unsafe_embeds'),
         convert_fonts_to_spans: getOption('convert_fonts_to_spans'),
         fix_list_elements: getOption('fix_list_elements'),
         font_size_legacy_values: getOption('font_size_legacy_values'),
    @@ -81,6 +82,7 @@ const mkParserSettings = (editor: Editor): DomParserSettings => {
         preserve_cdata: getOption('preserve_cdata'),
         inline_styles: getOption('inline_styles'),
         root_name: getRootName(editor),
    +    sandbox_iframes: getOption('sandbox_iframes'),
         sanitize: getOption('xss_sanitization'),
         validate: true,
         blob_cache: blobCache,
    
  • modules/tinymce/src/core/main/ts/paste/ProcessFilters.ts+1 1 modified
    @@ -11,7 +11,7 @@ interface ProcessResult {
     }
     
     const preProcess = (editor: Editor, html: string): string => {
    -  const parser = DomParser({ sanitize: Options.shouldSanitizeXss(editor) }, editor.schema);
    +  const parser = DomParser({ sanitize: Options.shouldSanitizeXss(editor), sandbox_iframes: Options.shouldSandboxIframes(editor) }, editor.schema);
     
       // Strip meta elements
       parser.addNodeFilter('meta', (nodes) => {
    
  • modules/tinymce/src/core/test/ts/browser/content/EditorContentTest.ts+123 1 modified
    @@ -350,6 +350,128 @@ describe('browser.tinymce.core.content.EditorContentTest', () => {
                 assert.equal(content, final, 'ZWNBSP should be stripped');
               });
             });
    +
    +        context('iframe sandboxing', () => {
    +          context('sandbox_iframes default', () => {
    +            const hook = TinyHooks.bddSetupLight<Editor>({
    +              ...options,
    +              base_url: '/project/tinymce/js/tinymce'
    +            }, []);
    +
    +            it('TINY-10348: Iframe should not be sandboxed by default', () => {
    +              const editor = hook.editor();
    +              editor.setContent('<iframe src="about:blank"></iframe>');
    +              TinyAssertions.assertContent(editor, '<p><iframe src="about:blank"></iframe></p>');
    +            });
    +          });
    +
    +          context('sandbox_iframes: false', () => {
    +            const hook = TinyHooks.bddSetupLight<Editor>({
    +              ...options,
    +              base_url: '/project/tinymce/js/tinymce',
    +              sandbox_iframes: false
    +            }, []);
    +
    +            it('TINY-10348: Iframe should not be sandboxed when sandbox_iframes: false', () => {
    +              const editor = hook.editor();
    +              editor.setContent('<iframe src="about:blank"></iframe>');
    +              TinyAssertions.assertContent(editor, '<p><iframe src="about:blank"></iframe></p>');
    +            });
    +
    +            it('TINY-10348: Iframe with sandbox attribute should not be modified when sandbox_iframes: false', () => {
    +              const editor = hook.editor();
    +              editor.setContent('<iframe src="about:blank" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>');
    +              TinyAssertions.assertContent(editor, '<p><iframe src="about:blank" sandbox="allow-scripts allow-same-origin allow-forms"></iframe></p>');
    +            });
    +          });
    +
    +          context('sandbox_iframes: true', () => {
    +            const hook = TinyHooks.bddSetupLight<Editor>({
    +              ...options,
    +              base_url: '/project/tinymce/js/tinymce',
    +              sandbox_iframes: true
    +            }, []);
    +
    +            it('TINY-10348: Iframe should be sandboxed when sandbox_iframes: true', () => {
    +              const editor = hook.editor();
    +              editor.setContent('<iframe src="about:blank"></iframe>');
    +              TinyAssertions.assertContent(editor, '<p><iframe src="about:blank" sandbox=""></iframe></p>');
    +            });
    +
    +            it('TINY-10348: Iframe with sandbox attribute should be sandboxed when sandbox_iframes: true', () => {
    +              const editor = hook.editor();
    +              editor.setContent('<iframe src="about:blank" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>');
    +              TinyAssertions.assertContent(editor, '<p><iframe src="about:blank" sandbox=""></iframe></p>');
    +            });
    +          });
    +        });
    +
    +        context('Convert unsafe embeds', () => {
    +          const setAndAssertEmbed = (editor: Editor, embedHtml: string, expectedHtml: string) => {
    +            editor.setContent(embedHtml);
    +            TinyAssertions.assertContent(editor, `<p>${expectedHtml}</p>`);
    +          };
    +
    +          const testNoConversion = (hook: TinyHooks.Hook<Editor>, embedHtml: string) => () => {
    +            const editor = hook.editor();
    +            setAndAssertEmbed(editor, embedHtml, embedHtml);
    +          };
    +
    +          const testConversion = (hook: TinyHooks.Hook<Editor>, embedHtml: string, expectedHtml: string) => () => {
    +            const editor = hook.editor();
    +            setAndAssertEmbed(editor, embedHtml, expectedHtml);
    +          };
    +
    +          context('convert_unsafe_embeds default', () => {
    +            const hook = TinyHooks.bddSetupLight<Editor>({
    +              ...options,
    +              base_url: '/project/tinymce/js/tinymce'
    +            }, []);
    +
    +            it('TINY-10349: Object elements should not be converted', testNoConversion(hook, '<object data="about:blank"></object>'));
    +            it('TINY-10349: Embed elements should not be converted', testNoConversion(hook, '<embed src="about:blank">'));
    +          });
    +
    +          context('convert_unsafe_embeds: false', () => {
    +            const hook = TinyHooks.bddSetupLight<Editor>({
    +              ...options,
    +              base_url: '/project/tinymce/js/tinymce',
    +              convert_unsafe_embeds: false
    +            }, []);
    +
    +            it('TINY-10349: Object elements should not be converted', testNoConversion(hook, '<object data="about:blank"></object>'));
    +            it('TINY-10349: Embed elements should not be converted', testNoConversion(hook, '<embed src="about:blank">'));
    +          });
    +
    +          context('convert_unsafe_embeds: true', () => {
    +            const hook = TinyHooks.bddSetupLight<Editor>({
    +              ...options,
    +              base_url: '/project/tinymce/js/tinymce',
    +              convert_unsafe_embeds: true
    +            }, []);
    +
    +            it('TINY-10349: Object elements should be converted to iframe',
    +              testConversion(hook, '<object data="about:blank"></object>', '<iframe src="about:blank"></iframe>'));
    +
    +            it('TINY-10349: Embed elements should be converted to iframe',
    +              testConversion(hook, '<embed src="about:blank">', '<iframe src="about:blank"></iframe>'));
    +          });
    +
    +          context('convert_unsafe_embeds: true, sandbox_iframes: true', () => {
    +            const hook = TinyHooks.bddSetupLight<Editor>({
    +              ...options,
    +              base_url: '/project/tinymce/js/tinymce',
    +              convert_unsafe_embeds: true,
    +              sandbox_iframes: true
    +            }, []);
    +
    +            it('TINY-10349: Object elements should be converted to sandboxed iframe',
    +              testConversion(hook, '<object data="about:blank"></object>', '<iframe src="about:blank" sandbox=""></iframe>'));
    +
    +            it('TINY-10349: Embed elements should be converted to sandboxed iframe',
    +              testConversion(hook, '<embed src="about:blank">', '<iframe src="about:blank" sandbox=""></iframe>'));
    +          });
    +        });
           });
         }
       );
    @@ -433,7 +555,7 @@ describe('browser.tinymce.core.content.EditorContentTest', () => {
               editor.setContent('<p><iframe><p>test</p></iframe></p>');
               const content = editor.getContent();
               assert.equal(content,
    -            // TINY-9624: Investigate Safari-specific HTML output
    +            // TINY-9624: Safari seems to encode the contents of iframes
                 isSafari
                   ? '<p><iframe>&lt;p&gt;test&lt;/p&gt;</iframe></p>'
                   : '<p><iframe><p>test</p></iframe></p>',
    
  • modules/tinymce/src/core/test/ts/browser/html/DomParserTest.ts+101 2 modified
    @@ -822,8 +822,8 @@ describe('browser.tinymce.core.html.DomParserTest', () => {
             );
           });
     
    -      // TODO: TINY-9624 - the iframe innerHTML on safari is `&lt;textarea&gt;` whereas on other browsers
    -      //       is `<textarea>`. This causes the mXSS cleaner in DOMPurify to run and causes the different assertions below
    +      // TINY-9624: Safari encodes the iframe innerHTML is `&lt;textarea&gt;`. On Chrome and Firefox, the innerHTML is `<textarea>`, causing
    +      // the mXSS cleaner in DOMPurify to run and remove the iframe.
           it('parse iframe XSS', () => {
             const serializer = HtmlSerializer();
     
    @@ -1531,6 +1531,105 @@ describe('browser.tinymce.core.html.DomParserTest', () => {
               }));
             });
           });
    +
    +      context('Sandboxing iframes', () => {
    +        const serializeIframeHtml = (sandbox: boolean): string => {
    +          const parser = DomParser({ ...scenario.settings, sandbox_iframes: sandbox });
    +          return serializer.serialize(parser.parse('<iframe src="about:blank"></iframe>'));
    +        };
    +
    +        it('TINY-10348: iframes should be sandboxed when sandbox_iframes: false', () =>
    +          assert.equal(serializeIframeHtml(false), '<iframe src="about:blank"></iframe>'));
    +
    +        it('TINY-10348: iframes should be sandboxed when sandbox_iframes: true', () =>
    +          assert.equal(serializeIframeHtml(true), '<iframe src="about:blank" sandbox=""></iframe>'));
    +      });
    +
    +      context('Convert unsafe embeds', () => {
    +        const serializeEmbedHtml = (embedHtml: string, convert: boolean): string => {
    +          const parser = DomParser({ ...scenario.settings, convert_unsafe_embeds: convert });
    +          return serializer.serialize(parser.parse(embedHtml));
    +        };
    +
    +        const testConversion = (embedHtml: string, expectedHtml: string) => () => {
    +          const serializedHtml = serializeEmbedHtml(embedHtml, true);
    +          assert.equal(serializedHtml, expectedHtml);
    +        };
    +
    +        context('convert_unsafe_embeds: false', () => {
    +          const testNoConversion = (embedHtml: string) => () => {
    +            const serializedHtml = serializeEmbedHtml(embedHtml, false);
    +            assert.equal(serializedHtml, embedHtml);
    +          };
    +
    +          it('TINY-10349: Object elements should not be converted', testNoConversion('<object data="about:blank"></object>'));
    +          it('TINY-10349: Object elements with a mime type should not be converted', testNoConversion('<object data="about:blank" type="image/png"></object>'));
    +          it('TINY-10349: Embed elements should notr be converted', testNoConversion('<embed src="about:blank">'));
    +          it('TINY-10349: Embed elements with a mime type should not be converted', testNoConversion('<embed src="about:blank" type="image/png">'));
    +        });
    +
    +        context('convert_unsafe_embeds: true', () => {
    +          it('TINY-10349: Object elements without a mime type should be converted to iframe',
    +            testConversion('<object data="about:blank"></object>', '<iframe src="about:blank"></iframe>'));
    +          it('TINY-10349: Object elements with an image mime type should be converted to img',
    +            testConversion('<object data="about:blank" type="image/png"></object>', '<img src="about:blank">'));
    +          it('TINY-10349: Object elements with a video mime type should be converted to video',
    +            testConversion('<object data="about:blank" type="video/mp4"></object>', '<video src="about:blank" controls=""></video>'));
    +          it('TINY-10349: Object elements with an audio mime type should be converted to audio',
    +            testConversion('<object data="about:blank" type="audio/mpeg"></object>', '<audio src="about:blank" controls=""></audio>'));
    +          it('TINY-10349: Object elements with other mime type should be converted to iframe',
    +            testConversion('<object data="about:blank" type="application/pdf"></object>', '<iframe src="about:blank"></iframe>'));
    +
    +          it('TINY-10349: Embed elements without a mime type should be converted to iframe',
    +            testConversion('<embed src="about:blank">', '<iframe src="about:blank"></iframe>'));
    +          it('TINY-10349: Embed elements with an image mime type should be converted to img',
    +            testConversion('<embed src="about:blank" type="image/png">', '<img src="about:blank">'));
    +          it('TINY-10349: Embed elements with a video mime type should be converted to video',
    +            testConversion('<embed src="about:blank" type="video/mp4">', '<video src="about:blank" controls=""></video>'));
    +          it('TINY-10349: Embed elements with an audio mime type should be converted to audio',
    +            testConversion('<embed src="about:blank" type="audio/mpeg">', '<audio src="about:blank" controls=""></audio>'));
    +          it('TINY-10349: Embed elements with other mime type should be converted to iframe',
    +            testConversion('<embed src="about:blank" type="application/pdf">', '<iframe src="about:blank"></iframe>'));
    +        });
    +
    +        context('convert_unsafe_embeds: true, sandbox_iframes: true', () => {
    +          const testSandboxedConversion = (embedHtml: string, expectedHtml: string) => () => {
    +            const parser = DomParser({ ...scenario.settings, convert_unsafe_embeds: true, sandbox_iframes: true });
    +            const serializedHtml = serializer.serialize(parser.parse(embedHtml));
    +            assert.equal(serializedHtml, expectedHtml);
    +          };
    +
    +          it('TINY-10349: Object elements without a mime type should be converted to sandboxed iframe',
    +            testSandboxedConversion('<object data="about:blank"></object>', '<iframe src="about:blank" sandbox=""></iframe>'));
    +
    +          it('TINY-10349: Embed elements without a mime type should be converted to sandboxed iframe',
    +            testSandboxedConversion('<embed src="about:blank">', '<iframe src="about:blank" sandbox=""></iframe>'));
    +        });
    +
    +        context('convert_unsafe_embeds: true, attribute preservation', () => {
    +          it('TINY-10349: Object elements should perserve width and height attributes only',
    +            testConversion('<object data="about:blank" width="100" height="100" style="color: red;"></object>', '<iframe src="about:blank" width="100" height="100"></iframe>'));
    +          it('TINY-10349: Object elements with an image mime type should perserve width and height attributes only',
    +            testConversion('<object data="about:blank" type="image/png" width="100" height="100" style="color: red;"></object>', '<img src="about:blank" width="100" height="100">'));
    +          it('TINY-10349: Object elements with a video mime type should perserve width and height attributes only',
    +            testConversion('<object data="about:blank" type="video/mp4" width="100" height="100" style="color: red;"></object>', '<video src="about:blank" width="100" height="100" controls=""></video>'));
    +          it('TINY-10349: Object elements with an audio mime type should not perserve other attributes only',
    +            testConversion('<object data="about:blank" type="audio/mpeg" width="100" height="100" style="color: red;"></object>', '<audio src="about:blank" controls=""></audio>'));
    +          it('TINY-10349: Object elements with other mime type should perserve width and height attributes only',
    +            testConversion('<object data="about:blank" type="application/pdf" width="100" height="100" style="color: red;"></object>', '<iframe src="about:blank" width="100" height="100"></iframe>'));
    +
    +          it('TINY-10349: Embed elements should preserve width and heigth attributes only',
    +            testConversion('<embed src="about:blank" width="100" height="100" style="color: red;">', '<iframe src="about:blank" width="100" height="100"></iframe>'));
    +          it('TINY-10349: Embed elements with an image mime type should preserve width and height attributes only',
    +            testConversion('<embed src="about:blank" type="image/png" width="100" height="100" style="color: red;">', '<img src="about:blank" width="100" height="100">'));
    +          it('TINY-10349: Embed elements with a video mime type should preserve width and height attributes only',
    +            testConversion('<embed src="about:blank" type="video/mp4" width="100" height="100" style="color: red;">', '<video src="about:blank" width="100" height="100" controls=""></video>'));
    +          it('TINY-10349: Embed elements with an audio mime type should not preserve other attributes',
    +            testConversion('<embed src="about:blank" type="audio/mpeg" width="100" height="100" style="color: red;">', '<audio src="about:blank" controls=""></audio>'));
    +          it('TINY-10349: Embed elements with other mime type should preserve width and height attributes only',
    +            testConversion('<embed src="about:blank" type="application/pdf" width="100" height="100" style="color: red;">', '<iframe src="about:blank" width="100" height="100"></iframe>'));
    +        });
    +      });
         });
       });
     
    
  • modules/tinymce/src/core/test/ts/browser/paste/PasteTest.ts+71 0 modified
    @@ -19,6 +19,13 @@ describe('browser.tinymce.core.paste.PasteTest', () => {
         base_url: '/project/tinymce/js/tinymce'
       }, [], true);
     
    +  const testPasteHtml = (hook: TinyHooks.Hook<Editor>, html: string, expected: string) => () => {
    +    const editor = hook.editor();
    +    editor.setContent('');
    +    editor.execCommand('mceInsertClipboardContent', false, { html });
    +    TinyAssertions.assertContent(editor, expected);
    +  };
    +
       beforeEach(() => {
         const editor = hook.editor();
         editor.setContent('');
    @@ -505,5 +512,69 @@ describe('browser.tinymce.core.paste.PasteTest', () => {
           editor.off('beforeinput', setBeforeInputEvent);
           editor.off('input', setInputEvent);
         });
    +
    +    context('iframe sandboxing', () => {
    +      context('sandbox_iframes: false', () => {
    +        const hook = TinyHooks.bddSetupLight<Editor>({
    +          base_url: '/project/tinymce/js/tinymce',
    +          sandbox_iframes: false
    +        });
    +
    +        it('TINY-10348: Pasted iframe should not be sandboxed',
    +          testPasteHtml(hook, '<iframe src="about:blank"></iframe>', '<p><iframe src="about:blank"></iframe></p>'));
    +      });
    +
    +      context('sandbox_iframes: true', () => {
    +        const hook = TinyHooks.bddSetupLight<Editor>({
    +          base_url: '/project/tinymce/js/tinymce',
    +          sandbox_iframes: true
    +        });
    +
    +        it('TINY-10348: Pasted iframe should be sandboxed',
    +          testPasteHtml(hook, '<iframe src="about:blank"></iframe>', '<p><iframe src="about:blank" sandbox=""></iframe></p>'));
    +      });
    +    });
    +
    +    context('Convert unsafe embeds', () => {
    +      context('convert_unsafe_embeds: false', () => {
    +        const hook = TinyHooks.bddSetupLight<Editor>({
    +          base_url: '/project/tinymce/js/tinymce',
    +          convert_unsafe_embeds: false
    +        });
    +
    +        it('TINY-10349: Pasted object element should not be converted',
    +          testPasteHtml(hook, '<object data="about:blank"></object>', '<p><object data="about:blank"></object></p>'));
    +
    +        it('TINY-10349: Pasted embed element should not be converted',
    +          testPasteHtml(hook, '<embed src="about:blank">', '<p><embed src="about:blank"></p>'));
    +      });
    +
    +      context('convert_unsafe_embeds: true', () => {
    +        const hook = TinyHooks.bddSetupLight<Editor>({
    +          base_url: '/project/tinymce/js/tinymce',
    +          convert_unsafe_embeds: true
    +        });
    +
    +        it('TINY-10349: Pasted object element should be converted to iframe',
    +          testPasteHtml(hook, '<object data="about:blank">', '<p><iframe src="about:blank"></iframe></p>'));
    +
    +        it('TINY-10349: Pasted embed element should be converted to iframe',
    +          testPasteHtml(hook, '<embed src="about:blank">', '<p><iframe src="about:blank"></iframe></p>'));
    +      });
    +
    +      context('convert_unsafe_embeds: true, sandbox_iframes: true', () => {
    +        const hook = TinyHooks.bddSetupLight<Editor>({
    +          base_url: '/project/tinymce/js/tinymce',
    +          convert_unsafe_embeds: true,
    +          sandbox_iframes: true
    +        });
    +
    +        it('TINY-10349: Pasted object element should be converted to sandboxed iframe',
    +          testPasteHtml(hook, '<object data="about:blank"></object>', '<p><iframe src="about:blank" sandbox=""></iframe></p>'));
    +
    +        it('TINY-10349: Pasted embed element should be converted to sandboxed iframe',
    +          testPasteHtml(hook, '<embed src="about:blank">', '<p><iframe src="about:blank" sandbox=""></iframe></p>'));
    +      });
    +    });
       });
     });
    
  • modules/tinymce/src/plugins/media/main/ts/core/Nodes.ts+2 1 modified
    @@ -91,7 +91,8 @@ const createPreviewNode = (editor: Editor, node: AstNode): AstNode => {
       if (name === 'iframe') {
         previewNode.attr({
           allowfullscreen: node.attr('allowfullscreen'),
    -      frameborder: '0'
    +      frameborder: '0',
    +      sandbox: node.attr('sandbox')
         });
       } else {
         // Exclude autoplay as we don't want video/audio to play by default
    
  • modules/tinymce/src/plugins/media/test/ts/browser/ContentFormatsTest.ts+18 0 modified
    @@ -57,6 +57,24 @@ describe('browser.tinymce.plugins.media.ContentFormatsTest', () => {
         );
       });
     
    +  it('TINY-10348: Iframe retained as is with sandbox_iframes: false', async () => {
    +    const editor = await McEditor.pFromSettings({ ...settings, sandbox_iframes: false });
    +    editor.setContent('<iframe src="320x240.ogg" allowfullscreen></iframe>');
    +    TinyAssertions.assertContent(editor,
    +      '<p><iframe src="320x240.ogg" width="300" height="150" allowfullscreen="allowfullscreen"></iframe></p>'
    +    );
    +    McEditor.remove(editor);
    +  });
    +
    +  it('TINY-10348: Iframe retained as is with sandbox_iframes: true', async () => {
    +    const editor = await McEditor.pFromSettings({ ...settings, sandbox_iframes: true });
    +    editor.setContent('<iframe src="320x240.ogg" allowfullscreen></iframe>');
    +    TinyAssertions.assertContent(editor,
    +      '<p><iframe src="320x240.ogg" width="300" height="150" sandbox="" allowfullscreen="allowfullscreen"></iframe></p>'
    +    );
    +    McEditor.remove(editor);
    +  });
    +
       it('TBA: Iframe with innerHTML retained as is with xss_sanitization: false', async () => {
         // TINY-8363: Iframe with innerHTML is removed by DOMPurify, so disable sanitization for this test
         const editor = await McEditor.pFromSettings<Editor>({
    
  • modules/tinymce/src/plugins/media/test/ts/browser/LiveEmbedNodeTest.ts+26 7 modified
    @@ -1,23 +1,26 @@
     import { ApproxStructure, Assertions, StructAssert, UiFinder } from '@ephox/agar';
    -import { describe, it } from '@ephox/bedrock-client';
    -import { Arr, Fun, Obj } from '@ephox/katamari';
    -import { TinyDom, TinyHooks } from '@ephox/wrap-mcagar';
    +import { context, describe, it } from '@ephox/bedrock-client';
    +import { Arr, Fun, Obj, Type } from '@ephox/katamari';
    +import { McEditor, TinyDom, TinyHooks } from '@ephox/wrap-mcagar';
     
     import Editor from 'tinymce/core/api/Editor';
     import Plugin from 'tinymce/plugins/media/Plugin';
     
     describe('browser.tinymce.plugins.media.core.LiveEmbedNodeTest', () => {
    -  const hook = TinyHooks.bddSetupLight<Editor>({
    +  const baseSettings = {
         plugins: [ 'media' ],
    -    toolbar: 'media',
    +    toolbar: 'media'
    +  };
    +  const hook = TinyHooks.bddSetupLight<Editor>({
    +    ...baseSettings,
         base_url: '/project/tinymce/js/tinymce'
       }, [ Plugin ]);
     
       const assertStructure = (
         editor: Editor,
         tag: string,
         classes: string[],
    -    attrs: Record<string, string>,
    +    attrs: Record<string, string | null>,
         styles: Record<string, string>,
         getChildren: ApproxStructure.Builder<StructAssert[]> = Fun.constant([])
       ) => {
    @@ -36,7 +39,7 @@ describe('browser.tinymce.plugins.media.core.LiveEmbedNodeTest', () => {
               attrs: {
                 height: str.none('should not have height'),
                 width: str.none('should not have width'),
    -            ...Obj.map(attrs, (value) => str.is(value))
    +            ...Obj.map(attrs, (value) => Type.isNull(value) ? str.none() : str.is(value))
               },
               styles: {
                 height: str.none('should not have height style'),
    @@ -131,4 +134,20 @@ describe('browser.tinymce.plugins.media.core.LiveEmbedNodeTest', () => {
           })
         ]);
       });
    +
    +  context('Sandboxing iframes', () => {
    +    const initialIframeHtml = '<iframe src="about:blank"></iframe>';
    +
    +    it('TINY-10348: sandbox_iframes: false should not have sandbox attribute in live embed', async () => {
    +      const editor = await McEditor.pFromSettings<Editor>({ ...baseSettings, sandbox_iframes: false });
    +      editor.setContent(initialIframeHtml);
    +      assertStructure(editor, 'iframe', [ ], { sandbox: null }, { });
    +    });
    +
    +    it('TINY-10348: sandbox_iframes: true should havce sandbox attribute in live embed', async () => {
    +      const editor = await McEditor.pFromSettings<Editor>({ ...baseSettings, sandbox_iframes: true });
    +      editor.setContent(initialIframeHtml);
    +      assertStructure(editor, 'iframe', [ ], { sandbox: '' }, { });
    +    });
    +  });
     });
    
  • modules/tinymce/src/plugins/media/test/ts/browser/PlaceholderTest.ts+56 22 modified
    @@ -1,6 +1,6 @@
     import { ApproxStructure, StructAssert, Waiter } from '@ephox/agar';
     import { before, context, describe, it } from '@ephox/bedrock-client';
    -import { TinyAssertions, TinyHooks, TinyUiActions } from '@ephox/wrap-mcagar';
    +import { McEditor, TinyAssertions, TinyHooks, TinyUiActions } from '@ephox/wrap-mcagar';
     
     import Editor from 'tinymce/core/api/Editor';
     import Env from 'tinymce/core/api/Env';
    @@ -9,10 +9,14 @@ import Plugin from 'tinymce/plugins/media/Plugin';
     import * as Utils from '../module/test/Utils';
     
     describe('browser.tinymce.plugins.media.core.PlaceholderTest', () => {
    -  const hook = TinyHooks.bddSetupLight<Editor>({
    +  const baseSettings = {
         plugins: [ 'media' ],
         toolbar: 'media',
    -    extended_valid_elements: 'script[src|type]',
    +    extended_valid_elements: 'script[src|type]'
    +  };
    +
    +  const hook = TinyHooks.bddSetupLight<Editor>({
    +    ...baseSettings,
         base_url: '/project/tinymce/js/tinymce'
       }, [ Plugin ]);
     
    @@ -42,24 +46,6 @@ describe('browser.tinymce.plugins.media.core.PlaceholderTest', () => {
         });
       });
     
    -  const iframeStructure = ApproxStructure.build((s) => {
    -    return s.element('body', {
    -      children: [
    -        s.element('p', {
    -          children: [
    -            s.element('span', {
    -              children: [
    -                s.element('iframe', {}),
    -                s.element('span', {})
    -              ]
    -            })
    -          ]
    -        }),
    -        s.theRest()
    -      ]
    -    });
    -  });
    -
       context('media_live_embeds=false', () => {
         before(() => {
           const editor = hook.editor();
    @@ -88,6 +74,28 @@ describe('browser.tinymce.plugins.media.core.PlaceholderTest', () => {
       });
     
       context('media_live_embeds=true', () => {
    +    const createIframeStructure = (sandbox: boolean) => ApproxStructure.build((s, str) => {
    +      return s.element('body', {
    +        children: [
    +          s.element('p', {
    +            children: [
    +              s.element('span', {
    +                children: [
    +                  s.element('iframe', {
    +                    attrs: {
    +                      sandbox: sandbox ? str.is('') : str.none()
    +                    }
    +                  }),
    +                  s.element('span', {})
    +                ]
    +              })
    +            ]
    +          }),
    +          s.theRest()
    +        ]
    +      });
    +    });
    +
         before(() => {
           const editor = hook.editor();
           editor.options.set('media_live_embeds', true);
    @@ -97,7 +105,33 @@ describe('browser.tinymce.plugins.media.core.PlaceholderTest', () => {
           'https://www.youtube.com/watch?v=P_205ZY52pY',
           '<p><iframe src="https://www.youtube.com/embed/P_205ZY52pY" width="560" ' +
           'height="314" allowfullscreen="allowfullscreen"></iframe></p>',
    -      iframeStructure
    +      createIframeStructure(false)
         ));
    +
    +    it('TINY-10348: Live iframe embed structure when sandbox_iframes: false', async () => {
    +      const editor = await McEditor.pFromSettings<Editor>({
    +        ...baseSettings,
    +        sandbox_iframes: false
    +      });
    +      pTestPlaceholder(editor,
    +        'https://www.youtube.com/watch?v=P_205ZY52pY',
    +        '<p><iframe src="https://www.youtube.com/embed/P_205ZY52pY" width="560" ' +
    +        'height="314" allowfullscreen="allowfullscreen"></iframe></p>',
    +        createIframeStructure(false)
    +      );
    +    });
    +
    +    it('TINY-10348: Live iframe embed structure when sandbox_iframes: true', async () => {
    +      const editor = await McEditor.pFromSettings<Editor>({
    +        ...baseSettings,
    +        sandbox_iframes: true
    +      });
    +      pTestPlaceholder(editor,
    +        'https://www.youtube.com/watch?v=P_205ZY52pY',
    +        '<p><iframe src="https://www.youtube.com/embed/P_205ZY52pY" width="560" ' +
    +        'height="314" sandbox="" allowfullscreen="allowfullscreen"></iframe></p>',
    +        createIframeStructure(true)
    +      );
    +    });
       });
     });
    
  • modules/tinymce/src/plugins/quickbars/test/ts/browser/SelectionToolbarTest.ts+2 1 modified
    @@ -5,6 +5,7 @@ import { TinyHooks, TinySelections } from '@ephox/wrap-mcagar';
     
     import Editor from 'tinymce/core/api/Editor';
     import LinkPlugin from 'tinymce/plugins/link/Plugin';
    +import PageBreakPlugin from 'tinymce/plugins/pagebreak/Plugin';
     import QuickbarsPlugin from 'tinymce/plugins/quickbars/Plugin';
     
     enum Alignment {
    @@ -20,7 +21,7 @@ describe('browser.tinymce.plugins.quickbars.SelectionToolbarTest', () => {
         toolbar: false,
         menubar: false,
         base_url: '/project/tinymce/js/tinymce'
    -  }, [ LinkPlugin, QuickbarsPlugin ], true);
    +  }, [ LinkPlugin, QuickbarsPlugin, PageBreakPlugin ], true);
     
       const imgSrc = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
     
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.