CVE-2024-29203: Insufficient Information (NVD-CWE-noinfo)
value: TinyMCE is an open source rich text editor. A cross-site scripting (XSS) vulnerability was discovered in TinyMCE’s content insertion code.  This allowed `iframe` elements containing malicious code to execute when inserted into the editor.  These `iframe` elements are restricted in their permissions by same-origin browser protections, but could still trigger operations such as downloading of malicious assets. This vulnerability is fixed in 6.8.1.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-03-26T14:15Z
Repository: https://github.com/tinymce/tinymce
Language: TypeScript
Files changed: modules/tinymce/CHANGELOG.md, modules/tinymce/src/core/main/ts/api/OptionTypes.ts, modules/tinymce/src/core/main/ts/api/Options.ts, modules/tinymce/src/core/main/ts/api/html/DomParser.ts, modules/tinymce/src/core/main/ts/content/PrePostProcess.ts, modules/tinymce/src/core/main/ts/html/ParserFilters.ts, modules/tinymce/src/core/main/ts/init/InitContentBody.ts, modules/tinymce/src/core/main/ts/paste/ProcessFilters.ts, modules/tinymce/src/core/test/ts/browser/content/EditorContentTest.ts, modules/tinymce/src/core/test/ts/browser/html/DomParserTest.ts, modules/tinymce/src/core/test/ts/browser/paste/PasteTest.ts, modules/tinymce/src/plugins/media/main/ts/core/Nodes.ts, modules/tinymce/src/plugins/media/test/ts/browser/ContentFormatsTest.ts, modules/tinymce/src/plugins/media/test/ts/browser/LiveEmbedNodeTest.ts, modules/tinymce/src/plugins/media/test/ts/browser/PlaceholderTest.ts, modules/tinymce/src/plugins/quickbars/test/ts/browser/SelectionToolbarTest.ts
Commit date: 2023-11-21 12:17:05 +1000
Commit message: 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
Diff: diff --git a/modules/tinymce/CHANGELOG.md b/modules/tinymce/CHANGELOG.md
index 1c7bfd2d1f..ab5a04e498 100644
--- a/modules/tinymce/CHANGELOG.md
+++ b/modules/tinymce/CHANGELOG.md
@@ -12,10 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Added new `Resource.get` API that can be used to get a resource if it exists. #TINY-10352
 - Added new `StylesheetLoader.loadRawCss` API that can be used to load CSS into a style element. #TINY-10352
 - 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
 - Colorpicker now includes the Brightness/Saturation selector and hue slider in the keyboard navigable items. #TINY-9287
 - Improved the tooltips of picker buttons for the urlinput components in the "Insert/Edit Image" and "Insert/Edit Link" dialogs. #TINY-10155
diff --git a/modules/tinymce/src/core/main/ts/api/OptionTypes.ts b/modules/tinymce/src/core/main/ts/api/OptionTypes.ts
index c454992798..773d9e16b4 100644
--- a/modules/tinymce/src/core/main/ts/api/OptionTypes.ts
+++ b/modules/tinymce/src/core/main/ts/api/OptionTypes.ts
@@ -84,10 +84,11 @@ interface BaseEditorOptions {
   content_style?: string;
   content_langs?: ContentLanguage[];
   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;
   custom_ui_selector?: string;
   custom_undo_redo_levels?: number;
@@ -195,10 +196,11 @@ interface BaseEditorOptions {
   remove_trailing_brs?: boolean;
   removed_menuitems?: string;
   resize?: boolean | 'both';
   resize_img_proportional?: boolean;
   root_name?: string;
+  sandbox_iframes?: boolean;
   schema?: SchemaType;
   selector?: string;
   setup?: SetupCallback;
   sidebar_show?: string;
   skin?: boolean | string;
@@ -287,10 +289,11 @@ export interface EditorOptions extends NormalizedEditorOptions {
   color_cols_background: number;
   color_default_background: string;
   color_default_foreground: string;
   content_css: string[];
   contextmenu: string[];
+  convert_unsafe_embeds: boolean;
   custom_colors: boolean;
   default_font_stack: string[];
   document_base_url: string;
   init_content_sync: boolean;
   draggable_modal: boolean;
@@ -334,10 +337,11 @@ export interface EditorOptions extends NormalizedEditorOptions {
   paste_as_text: boolean;
   preview_styles: string;
   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;
   toolbar_mode: ToolbarMode;
   toolbar_persist: boolean;
diff --git a/modules/tinymce/src/core/main/ts/api/Options.ts b/modules/tinymce/src/core/main/ts/api/Options.ts
index ac28fbcf01..87e338037a 100644
--- a/modules/tinymce/src/core/main/ts/api/Options.ts
+++ b/modules/tinymce/src/core/main/ts/api/Options.ts
@@ -834,10 +834,20 @@ const register = (editor: Editor): void => {
       return valid ? { value, valid } : { valid: false, message: `Must be one of: ${options.join(', ')}.` };
     },
     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', {
       processor: 'string',
       default: I18n.isRtl() ? 'rtl' : undefined
@@ -942,32 +952,21 @@ const getEditableClass = option('editable_class');
 const getNonEditableRegExps = option('noneditable_regexp');
 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,
 
   getIframeAttrs,
@@ -1069,7 +1068,9 @@ export {
   shouldHighlightOnFocus,
   shouldSanitizeXss,
   getDetailsInitialState,
   getDetailsSerializedState,
   shouldUseDocumentWrite,
-  shouldForceHexColor
+  shouldForceHexColor,
+  shouldSandboxIframes,
+  shouldConvertUnsafeEmbeds
 };
diff --git a/modules/tinymce/src/core/main/ts/api/html/DomParser.ts b/modules/tinymce/src/core/main/ts/api/html/DomParser.ts
index 7e13c1d936..a19ec58e7a 100644
--- a/modules/tinymce/src/core/main/ts/api/html/DomParser.ts
+++ b/modules/tinymce/src/core/main/ts/api/html/DomParser.ts
@@ -55,10 +55,11 @@ export interface DomParserSettings {
   allow_html_in_named_anchor?: boolean;
   allow_script_urls?: boolean;
   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;
   forced_root_block?: boolean | string;
   forced_root_block_attrs?: Record<string, string>;
@@ -67,10 +68,11 @@ export interface DomParserSettings {
   preserve_cdata?: boolean;
   /**
    * @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;
 }
 
 interface DomParser {
diff --git a/modules/tinymce/src/core/main/ts/content/PrePostProcess.ts b/modules/tinymce/src/core/main/ts/content/PrePostProcess.ts
index a1c321f219..1fe05e6a21 100644
--- a/modules/tinymce/src/core/main/ts/content/PrePostProcess.ts
+++ b/modules/tinymce/src/core/main/ts/content/PrePostProcess.ts
@@ -1,28 +1,28 @@
 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';
 import { Content, GetContentArgs, isTreeNode, SetContentArgs } from './ContentTypes';
 
 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()) {
     return eventArgs;
   } else if (isTreeNode(content)) {
     // 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 };
     }
   } else {
@@ -45,20 +45,20 @@ const preProcessGetContent = <T extends GetContentArgs>(editor: Editor, args: T)
 
 const postProcessGetContent = <T extends GetContentArgs>(editor: Editor, content: Content, args: T): 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;
   }
 };
 
 const preProcessSetContent = <T extends SetContentArgs>(editor: Editor, args: T): Result<T, undefined> => {
   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);
     } else {
       return Result.value(processedEventArgs);
diff --git a/modules/tinymce/src/core/main/ts/html/ParserFilters.ts b/modules/tinymce/src/core/main/ts/html/ParserFilters.ts
index 2be2aefbc9..2a2ce76409 100644
--- a/modules/tinymce/src/core/main/ts/html/ParserFilters.ts
+++ b/modules/tinymce/src/core/main/ts/html/ParserFilters.ts
@@ -1,6 +1,6 @@
-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';
 import AstNode from '../api/html/Node';
 import Tools from '../api/util/Tools';
@@ -30,10 +30,40 @@ const registerBase64ImageFilter = (parser: DomParser, settings: DomParserSetting
 
     parser.addAttributeFilter('src', (nodes) => Arr.each(nodes, processImage));
   }
 };
 
+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;
 
   if (settings.remove_trailing_brs) {
     RemoveTrailingBr.addNodeFilter(settings, parser, schema);
@@ -151,10 +181,27 @@ 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 {
   register
 };
diff --git a/modules/tinymce/src/core/main/ts/init/InitContentBody.ts b/modules/tinymce/src/core/main/ts/init/InitContentBody.ts
index aae0416c35..bec736bb1a 100644
--- a/modules/tinymce/src/core/main/ts/init/InitContentBody.ts
+++ b/modules/tinymce/src/core/main/ts/init/InitContentBody.ts
@@ -71,18 +71,20 @@ const mkParserSettings = (editor: Editor): DomParserSettings => {
     allow_html_data_urls: getOption('allow_html_data_urls'),
     allow_svg_data_urls: getOption('allow_svg_data_urls'),
     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'),
     forced_root_block: getOption('forced_root_block'),
     forced_root_block_attrs: getOption('forced_root_block_attrs'),
     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,
     document: editor.getDoc()
   });
diff --git a/modules/tinymce/src/core/main/ts/paste/ProcessFilters.ts b/modules/tinymce/src/core/main/ts/paste/ProcessFilters.ts
index bc6b14a563..d05f9445f0 100644
--- a/modules/tinymce/src/core/main/ts/paste/ProcessFilters.ts
+++ b/modules/tinymce/src/core/main/ts/paste/ProcessFilters.ts
@@ -9,11 +9,11 @@ interface ProcessResult {
   readonly content: string;
   readonly cancelled: boolean;
 }
 
 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) => {
     Tools.each(nodes, (node) => {
       node.remove();
diff --git a/modules/tinymce/src/core/test/ts/browser/content/EditorContentTest.ts b/modules/tinymce/src/core/test/ts/browser/content/EditorContentTest.ts
index 9999ee4b70..522e4d9461 100644
--- a/modules/tinymce/src/core/test/ts/browser/content/EditorContentTest.ts
+++ b/modules/tinymce/src/core/test/ts/browser/content/EditorContentTest.ts
@@ -348,10 +348,132 @@ describe('browser.tinymce.core.content.EditorContentTest', () => {
             TinyApis(editor).setRawContent(initial);
             const content = editor.getContent();
             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>'));
+          });
+        });
       });
     }
   );
 
   Arr.each([
@@ -431,11 +553,11 @@ describe('browser.tinymce.core.content.EditorContentTest', () => {
         it('getContent html with iframe with child node should get the content as expected and not error', () => {
           const editor = hook.editor();
           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>',
             'getContent should not error when there is iframes with child nodes in content');
         });
diff --git a/modules/tinymce/src/core/test/ts/browser/html/DomParserTest.ts b/modules/tinymce/src/core/test/ts/browser/html/DomParserTest.ts
index 5d35a479e6..bb4c468a32 100644
--- a/modules/tinymce/src/core/test/ts/browser/html/DomParserTest.ts
+++ b/modules/tinymce/src/core/test/ts/browser/html/DomParserTest.ts
@@ -820,12 +820,12 @@ describe('browser.tinymce.core.html.DomParserTest', () => {
           '<b data-mce-type="test" id="x" style="color: red"></b>',
           'Removes disallowed elements'
         );
       });
 
-      // 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();
 
         assert.equal(
           serializer.serialize(DomParser(scenario.settings).parse('<iframe><textarea></iframe><img src="a" onerror="alert(document.domain)" />')),
@@ -1529,10 +1529,109 @@ describe('browser.tinymce.core.html.DomParserTest', () => {
               forced_root_block: 'p'
             }
           }));
         });
       });
+
+      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>'));
+        });
+      });
     });
   });
 
   context('TINY-9600: sanitize: false with unsafe input', () => {
     const getNoSanitizeParser = (settings: DomParserSettings, schema: Schema): DomParser =>
diff --git a/modules/tinymce/src/core/test/ts/browser/paste/PasteTest.ts b/modules/tinymce/src/core/test/ts/browser/paste/PasteTest.ts
index bdea29cf11..f1beb43a41 100644
--- a/modules/tinymce/src/core/test/ts/browser/paste/PasteTest.ts
+++ b/modules/tinymce/src/core/test/ts/browser/paste/PasteTest.ts
@@ -17,10 +17,17 @@ describe('browser.tinymce.core.paste.PasteTest', () => {
     add_unload_trigger: false,
     indent: false,
     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('');
   });
 
@@ -503,7 +510,71 @@ describe('browser.tinymce.core.paste.PasteTest', () => {
       TinyAssertions.assertContent(editor, html);
 
       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>'));
+      });
+    });
   });
 });
diff --git a/modules/tinymce/src/plugins/media/main/ts/core/Nodes.ts b/modules/tinymce/src/plugins/media/main/ts/core/Nodes.ts
index 48da7ab72a..2b40888dbf 100644
--- a/modules/tinymce/src/plugins/media/main/ts/core/Nodes.ts
+++ b/modules/tinymce/src/plugins/media/main/ts/core/Nodes.ts
@@ -89,11 +89,12 @@ 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
     const attrs = [ 'controls', 'crossorigin', 'currentTime', 'loop', 'muted', 'poster', 'preload' ];
     Arr.each(attrs, (attrName) => {
diff --git a/modules/tinymce/src/plugins/media/test/ts/browser/ContentFormatsTest.ts b/modules/tinymce/src/plugins/media/test/ts/browser/ContentFormatsTest.ts
index 2dd5ae9058..883e70284a 100644
--- a/modules/tinymce/src/plugins/media/test/ts/browser/ContentFormatsTest.ts
+++ b/modules/tinymce/src/plugins/media/test/ts/browser/ContentFormatsTest.ts
@@ -55,10 +55,28 @@ describe('browser.tinymce.plugins.media.ContentFormatsTest', () => {
     TinyAssertions.assertContent(editor,
       '<p><video src="320x240.ogg" autoplay="autoplay" loop="loop" controls="controls" width="300" height="150">text<a href="#">link</a></video></p>'
     );
   });
 
+  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>({
       ...settings,
       xss_sanitization: false
diff --git a/modules/tinymce/src/plugins/media/test/ts/browser/LiveEmbedNodeTest.ts b/modules/tinymce/src/plugins/media/test/ts/browser/LiveEmbedNodeTest.ts
index 8547550fe5..8cd70e9946 100644
--- a/modules/tinymce/src/plugins/media/test/ts/browser/LiveEmbedNodeTest.ts
+++ b/modules/tinymce/src/plugins/media/test/ts/browser/LiveEmbedNodeTest.ts
@@ -1,25 +1,28 @@
 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([])
   ) => {
     const object = UiFinder.findIn(TinyDom.body(editor), 'span.mce-preview-object').getOrDie();
     Assertions.assertStructure('should have all attributes', ApproxStructure.build((s, str, arr) => s.element('span', {
@@ -34,11 +37,11 @@ describe('browser.tinymce.plugins.media.core.LiveEmbedNodeTest', () => {
         s.element(tag, {
           classes: Arr.map(classes, arr.has),
           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'),
             width: str.none('should not have width style'),
             ...Obj.map(styles, (value) => str.is(value))
@@ -129,6 +132,22 @@ describe('browser.tinymce.plugins.media.core.LiveEmbedNodeTest', () => {
           type: str.is('audio/mp3')
         }
       })
     ]);
   });
+
+  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: '' }, { });
+    });
+  });
 });
diff --git a/modules/tinymce/src/plugins/media/test/ts/browser/PlaceholderTest.ts b/modules/tinymce/src/plugins/media/test/ts/browser/PlaceholderTest.ts
index 855b80e994..34ae02ffce 100644
--- a/modules/tinymce/src/plugins/media/test/ts/browser/PlaceholderTest.ts
+++ b/modules/tinymce/src/plugins/media/test/ts/browser/PlaceholderTest.ts
@@ -1,20 +1,24 @@
 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';
 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 ]);
 
   const pTestPlaceholder = async (editor: Editor, url: string, expected: string, struct: StructAssert) => {
     await Utils.pOpenDialog(editor);
@@ -40,28 +44,10 @@ describe('browser.tinymce.plugins.media.core.PlaceholderTest', () => {
         s.theRest()
       ]
     });
   });
 
-  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();
       editor.options.set('media_live_embeds', false);
     });
@@ -86,18 +72,66 @@ describe('browser.tinymce.plugins.media.core.PlaceholderTest', () => {
       placeholderStructure
     ));
   });
 
   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);
     });
 
     it('TBA: Set and assert live iframe embed structure', () => pTestPlaceholder(hook.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>',
-      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)
+      );
+    });
   });
 });
diff --git a/modules/tinymce/src/plugins/quickbars/test/ts/browser/SelectionToolbarTest.ts b/modules/tinymce/src/plugins/quickbars/test/ts/browser/SelectionToolbarTest.ts
index 7ad8dcf8f7..6343af0880 100644
--- a/modules/tinymce/src/plugins/quickbars/test/ts/browser/SelectionToolbarTest.ts
+++ b/modules/tinymce/src/plugins/quickbars/test/ts/browser/SelectionToolbarTest.ts
@@ -3,10 +3,11 @@ import { describe, it } from '@ephox/bedrock-client';
 import { SugarBody } from '@ephox/sugar';
 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 {
   Left = 'left',
   Right = 'right',
@@ -18,11 +19,11 @@ describe('browser.tinymce.plugins.quickbars.SelectionToolbarTest', () => {
     plugins: 'quickbars link pagebreak',
     inline: true,
     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';
 
   const pAssertButtonToggledState = (name: string, state: boolean) =>
     Waiter.pTryUntil('Wait for toolbar button state', () => {

Vulnerable code: 
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'));



  shouldForceHexColor
import DomParser from '../api/html/DomParser';
const withSerializedContent = <R extends EditorEvent<{ content: string }>>(content: Content, fireEvent: (content: string) => R, sanitize: boolean): R & { content: Content } => {
      const rootNode = DomParser({ validate: false, forced_root_block: false, sanitize }).parse(eventArgs.content, { context: content.name });
    const processedEventArgs = withSerializedContent(content, (content) => Events.fireGetContent(editor, { ...args, content }), Options.shouldSanitizeXss(editor));
    const processedEventArgs = withSerializedContent(args.content, (content) => Events.fireBeforeSetContent(editor, { ...args, content }), Options.shouldSanitizeXss(editor));
import { Arr, Type } from '@ephox/katamari';
  const parser = DomParser({ sanitize: Options.shouldSanitizeXss(editor) }, editor.schema);
            // TINY-9624: Investigate Safari-specific HTML output
      // 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
      frameborder: '0'
import { describe, it } from '@ephox/bedrock-client';
import { Arr, Fun, Obj } from '@ephox/katamari';
import { TinyDom, TinyHooks } from '@ephox/wrap-mcagar';
  const hook = TinyHooks.bddSetupLight<Editor>({
    toolbar: 'media',
    attrs: Record<string, string>,
            ...Obj.map(attrs, (value) => str.is(value))
import { TinyAssertions, TinyHooks, TinyUiActions } from '@ephox/wrap-mcagar';
  const hook = TinyHooks.bddSetupLight<Editor>({
    extended_valid_elements: 'script[src|type]',
  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()
      ]
    });
  });

      iframeStructure
  }, [ LinkPlugin, QuickbarsPlugin ], true);
Fixed code: - 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
  convert_unsafe_embeds?: boolean;
  sandbox_iframes?: boolean;
  convert_unsafe_embeds: boolean;
  sandbox_iframes: boolean;
  registerOption('sandbox_iframes', {
    processor: 'boolean',
    default: false
  });

  registerOption('convert_unsafe_embeds', {
    processor: 'boolean',
    default: false
  });

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 shouldSandboxIframes = option('sandbox_iframes');
const shouldConvertUnsafeEmbeds = option('convert_unsafe_embeds');
  shouldForceHexColor,
  shouldSandboxIframes,
  shouldConvertUnsafeEmbeds
  convert_unsafe_embeds?: boolean;
  sandbox_iframes?: boolean;
import DomParser, { DomParserSettings } from '../api/html/DomParser';
const withSerializedContent = <R extends EditorEvent<{ content: string }>>(content: Content, fireEvent: (content: string) => R, parserSettings: DomParserSettings): R & { content: Content } => {
      const rootNode = DomParser({ validate: false, forced_root_block: false, ...parserSettings }).parse(eventArgs.content, { context: content.name });
    const processedEventArgs = withSerializedContent(content, (content) => Events.fireGetContent(editor, { ...args, content }), { sanitize: Options.shouldSanitizeXss(editor), sandbox_iframes: Options.shouldSandboxIframes(editor) });
    const processedEventArgs = withSerializedContent(args.content, (content) => Events.fireBeforeSetContent(editor, { ...args, content }), { sanitize: Options.shouldSanitizeXss(editor), sandbox_iframes: Options.shouldSandboxIframes(editor) });
import { Arr, Strings, Type } from '@ephox/katamari';
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;
};


  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', '')));
  }
    convert_unsafe_embeds: getOption('convert_unsafe_embeds'),
    sandbox_iframes: getOption('sandbox_iframes'),
  const parser = DomParser({ sanitize: Options.shouldSanitizeXss(editor), sandbox_iframes: Options.shouldSandboxIframes(editor) }, editor.schema);

        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>'));
          });
        });
            // TINY-9624: Safari seems to encode the contents of iframes
      // 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.

      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>'));
        });
      });
  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);
  };


    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>'));
      });
    });
      frameborder: '0',
      sandbox: node.attr('sandbox')
  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);
  });

import { context, describe, it } from '@ephox/bedrock-client';
import { Arr, Fun, Obj, Type } from '@ephox/katamari';
import { McEditor, TinyDom, TinyHooks } from '@ephox/wrap-mcagar';
  const baseSettings = {
    toolbar: 'media'
  };
  const hook = TinyHooks.bddSetupLight<Editor>({
    ...baseSettings,
    attrs: Record<string, string | null>,
            ...Obj.map(attrs, (value) => Type.isNull(value) ? str.none() : str.is(value))

  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: '' }, { });
    });
  });
import { McEditor, TinyAssertions, TinyHooks, TinyUiActions } from '@ephox/wrap-mcagar';
  const baseSettings = {
    extended_valid_elements: 'script[src|type]'
  };

  const hook = TinyHooks.bddSetupLight<Editor>({
    ...baseSettings,
    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()
        ]
      });
    });

      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)
      );
    });
import PageBreakPlugin from 'tinymce/plugins/pagebreak/Plugin';
  }, [ LinkPlugin, QuickbarsPlugin, PageBreakPlugin ], true);