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