diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index 4d3d9fd91fef4b50f3897a75d14365a30ba0be3b..4627b3e58f0dc773cf6a21fcac46eafe005f49c1 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -3,6 +3,7 @@ import highlightCurrentUser from './highlight_current_user'; import { renderKroki } from './render_kroki'; import renderMath from './render_math'; import renderSandboxedMermaid from './render_sandboxed_mermaid'; +import renderSandboxedIframe from './render_iframe'; import { renderGlql } from './render_glql'; import { renderJSONTable, renderJSONTableHTML } from './render_json_table'; import { addAriaLabels } from './accessibility'; @@ -17,43 +18,35 @@ function initPopovers(elements) { .catch(() => {}); } -// Render GitLab flavored Markdown +// Render GitLab Flavored Markdown export function renderGFM(element) { if (!element) { return; } - const [ - highlightEls, - krokiEls, - mathEls, - mermaidEls, - tableEls, - tableHTMLEls, - glqlEls, - userEls, - popoverEls, - taskListCheckboxEls, - imageEls, - ] = [ - '.js-syntax-highlight', - '.js-render-kroki[hidden]', - '.js-render-math', - '.js-render-mermaid', - '[data-canonical-lang="json"][data-lang-params~="table"]', - 'table[data-table-fields]', - '[data-canonical-lang="glql"]', - '.gfm-project_member', - '.gfm-issue, .gfm-work_item, .gfm-merge_request, .gfm-epic, .gfm-milestone', - '.task-list-item-checkbox', - // eslint-disable-next-line @gitlab/require-i18n-strings - 'a>img', - ].map((selector) => Array.from(element.querySelectorAll(selector))); + function arrayFromAll(selector) { + return Array.from(element.querySelectorAll(selector)); + } + + const highlightEls = arrayFromAll('.js-syntax-highlight'); + const krokiEls = arrayFromAll('.js-render-kroki[hidden]'); + const mathEls = arrayFromAll('.js-render-math'); + const mermaidEls = arrayFromAll('.js-render-mermaid'); + const iframeEls = arrayFromAll('.js-render-iframe'); + const tableEls = arrayFromAll('[data-canonical-lang="json"][data-lang-params~="table"]'); + const tableHTMLEls = arrayFromAll('table[data-table-fields]'); + const glqlEls = arrayFromAll('[data-canonical-lang="glql"]'); + const userEls = arrayFromAll('.gfm-project_member'); + const popoverEls = arrayFromAll('.gfm-issue, .gfm-work_item, .gfm-merge_request, .gfm-epic, .gfm-milestone'); + const taskListCheckboxEls = arrayFromAll('.task-list-item-checkbox'); + // eslint-disable-next-line @gitlab/require-i18n-strings + const imageEls = arrayFromAll('a>img'); syntaxHighlight(highlightEls); renderKroki(krokiEls); renderMath(mathEls); renderSandboxedMermaid(mermaidEls); + renderSandboxedIframe(iframeEls); renderJSONTable(tableEls.map((e) => e.parentNode)); renderJSONTableHTML(tableHTMLEls); highlightCurrentUser(userEls); diff --git a/app/assets/javascripts/behaviors/markdown/render_iframe.js b/app/assets/javascripts/behaviors/markdown/render_iframe.js new file mode 100644 index 0000000000000000000000000000000000000000..dc1d26c9623b68addf6d53b535b512700373c57d --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/render_iframe.js @@ -0,0 +1,43 @@ +import { setAttributes } from '~/lib/utils/dom_utils'; + +// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#sandbox +const IFRAME_SANDBOX_RESTRICTIONS = 'allow-scripts allow-popups allow-same-origin'; + +const elsProcessingMap = new WeakMap(); + +function renderIframeEl(el) { + const iframeEl = document.createElement('iframe'); + setAttributes(iframeEl, { + src: el.src, + sandbox: IFRAME_SANDBOX_RESTRICTIONS, + frameBorder: 0, + class: 'gl-inset-0 gl-h-full gl-w-full', + allowfullscreen: 'true', + referrerpolicy: 'strict-origin-when-cross-origin', + }); + + const wrapper = document.createElement('div'); + wrapper.appendChild(iframeEl); + + const container = el.closest('.media-container'); + while (container.firstChild) { + container.removeChild(container.firstChild); + } + container.appendChild(wrapper); +} + +export default function renderIframes(els) { + if (!els.length) return; + + els.forEach((el) => { + if (elsProcessingMap.has(el)) { + return; + } + + const requestId = window.requestIdleCallback(() => { + renderIframeEl(el); + }); + + elsProcessingMap.set(el, requestId); + }); +} diff --git a/lib/banzai/filter/iframe_link_filter.rb b/lib/banzai/filter/iframe_link_filter.rb index 5c355c5b502d6de0a5d060f1eb39d79f46ec46c3..574b2970f77d37ffb204973e9c6e85d87ca1105b 100644 --- a/lib/banzai/filter/iframe_link_filter.rb +++ b/lib/banzai/filter/iframe_link_filter.rb @@ -19,10 +19,15 @@ def media_type 'img' end + # Note that every value _must_ contain at least one forward slash `/` in it + # separating the host from the path, and _should_ contain a final `/` or `?` + # if possible. Otherwise, e.g. if just "www.youtube.com" was given, a user + # could embed "https://www.youtube.com.my.malicious.domain.info/anything/". def safe_media_ext # TODO: will change to use the administrator defined allow list # Gitlab::CurrentSettings.iframe_src_allowlist - ['www.youtube.com/embed'] + + ['www.youtube.com/embed/'] end override :has_allowed_media? @@ -34,7 +39,7 @@ def has_allowed_media?(element) return unless src.present? - src.start_with?('https://') && safe_media_ext.any? { |domain| src.start_with?("https://#{domain}") } + src.start_with?('https://') && safe_media_ext.any? { |url_prefix| src.start_with?("https://#{url_prefix}") } end def extra_element_attrs(element) diff --git a/lib/gitlab/content_security_policy/config_loader.rb b/lib/gitlab/content_security_policy/config_loader.rb index 26c844cc1fe8a0cd2543824779e0cb3bc352fea3..a59b27bb4716f7f71679e1b48f33ff817822010c 100644 --- a/lib/gitlab/content_security_policy/config_loader.rb +++ b/lib/gitlab/content_security_policy/config_loader.rb @@ -28,6 +28,7 @@ def default_directives allow_sentry(directives) allow_framed_gitlab_paths(directives) allow_customersdot(directives) + allow_iframes(directives) csp_level_3_backport(directives) directives @@ -168,6 +169,13 @@ def allow_customersdot(directives) append_to_directive(directives, 'frame_src', customersdot_host) end + def allow_iframes(directives) + # return unless Gitlab::CurrentSettings.allow_iframes_in_markdown? + + # append_to_directive(directives, 'frame_src', Gitlab::CurrentSettings.iframe_src_allowlist.join(' ')) + append_to_directive(directives, 'frame_src', 'https://www.youtube.com/ https://www.figma.com/') + end + # The follow contains workarounds to patch Safari's lack of support for CSP Level 3 def csp_level_3_backport(directives) # See https://gitlab.com/gitlab-org/gitlab/-/issues/343579