Moderate severityNVD Advisory· Published Mar 26, 2024· Updated Aug 2, 2024
TinyMCE Cross-Site Scripting (XSS) vulnerability in handling external SVG files through Object or Embed elements
CVE-2024-29881
Description
TinyMCE is an open source rich text editor. A cross-site scripting (XSS) vulnerability was discovered in TinyMCE’s content loading and content inserting code. A SVG image could be loaded though an object or embed element and that image could potentially contain a XSS payload. This vulnerability is fixed in 6.8.1 and 7.0.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
tinymce/tinymcePackagist | < 7.0.0 | 7.0.0 |
tinymcenpm | < 7.0.0 | 7.0.0 |
TinyMCENuGet | < 7.0.0 | 7.0.0 |
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-5359-pvf2-pw78ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-29881ghsaADVISORY
- github.com/tinymce/tinymce/commit/bcdea2ad14e3c2cea40743fb48c63bba067ae6d1ghsax_refsource_MISCWEB
- github.com/tinymce/tinymce/security/advisories/GHSA-5359-pvf2-pw78ghsax_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.