CVE-2024-29881 (published 2024-03-26T14:15Z)

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.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/tinymce/tinymce
commit_details: repo_url: https://github.com/tinymce/tinymce
author: mkzhx
author_date: 2023-11-21 12:17:05+10:00
msg: TINY-10348 & TINY-10349: Add sandbox_iframes and convert_unsafe_embeds options (#9172)

* TINY-10348: Add sandbox_iframes option

* TINY-10349: Add convert_unsafe_embeds option

* TINY-10348 & TINY-10349: Add paste tests

* TINY-10348 & TINY-10349: Add changelog entries

* TINY-10348: Add missing PageBreakPlugin dependency in SelectionToolbarTest

* TINY-10349: Add attribute preservation tests for convert_unsafe_embeds
repository: repo_url: https://github.com/tinymce/tinymce
repo_name: tinymce/tinymce
description: The world's #1 JavaScript library for rich text editing. Available for React, Vue and Angular
repo_language: TypeScript
owner: tinymce
file_changes: filename: CHANGELOG.md
change_type: ModificationType.MODIFY
diff: @@ -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

programming_language: Markdown
filename: OptionTypes.ts
change_type: ModificationType.MODIFY
diff: @@ -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;

programming_language: TypeScript
filename: Options.ts
change_type: ModificationType.MODIFY
diff: @@ -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
 };

programming_language: TypeScript
filename: DomParser.ts
change_type: ModificationType.MODIFY
diff: @@ -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;
 }

programming_language: TypeScript
filename: PrePostProcess.ts
change_type: ModificationType.MODIFY
diff: @@ -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);

programming_language: TypeScript
filename: ParserFilters.ts
change_type: ModificationType.MODIFY
diff: @@ -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 {

programming_language: TypeScript
filename: InitContentBody.ts
change_type: ModificationType.MODIFY
diff: @@ -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,

programming_language: TypeScript
filename: ProcessFilters.ts
change_type: ModificationType.MODIFY
diff: @@ -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) => {

programming_language: TypeScript
filename: EditorContentTest.ts
change_type: ModificationType.MODIFY
diff: @@ -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>',

programming_language: TypeScript
filename: DomParserTest.ts
change_type: ModificationType.MODIFY
diff: @@ -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>'));
+        });
+      });
     });
   });
 

programming_language: TypeScript
filename: PasteTest.ts
change_type: ModificationType.MODIFY
diff: @@ -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>'));
+      });
+    });
   });
 });

programming_language: TypeScript
filename: Nodes.ts
change_type: ModificationType.MODIFY
diff: @@ -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

programming_language: TypeScript
filename: ContentFormatsTest.ts
change_type: ModificationType.MODIFY
diff: @@ -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>({

programming_language: TypeScript
filename: LiveEmbedNodeTest.ts
change_type: ModificationType.MODIFY
diff: @@ -1,15 +1,18 @@
 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 ]);
 
@@ -17,7 +20,7 @@ describe('browser.tinymce.plugins.media.core.LiveEmbedNodeTest', () => {
     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: '' }, { });
+    });
+  });
 });

programming_language: TypeScript
filename: PlaceholderTest.ts
change_type: ModificationType.MODIFY
diff: @@ -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)
+      );
+    });
   });
 });

programming_language: TypeScript
filename: SelectionToolbarTest.ts
change_type: ModificationType.MODIFY
diff: @@ -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';
 

programming_language: TypeScript