diff --git a/demo/vite.config.ts b/demo/vite.config.ts index 5a33944a..26b73ff5 100644 --- a/demo/vite.config.ts +++ b/demo/vite.config.ts @@ -1,7 +1,28 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// Keep in sync with package/utils/is-allowed-embed-src.ts +const EMBED_FRAME_SRC = [ + "'self'", + 'https://www.youtube.com', + 'https://youtube.com', + 'https://www.youtube-nocookie.com', + 'https://youtube-nocookie.com', + 'https://player.vimeo.com', + 'https://w.soundcloud.com', +].join(' '); // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], -}) + server: { + headers: { + 'Content-Security-Policy': `frame-src ${EMBED_FRAME_SRC};`, + }, + }, + preview: { + headers: { + 'Content-Security-Policy': `frame-src ${EMBED_FRAME_SRC};`, + }, + }, +}); diff --git a/index.ts b/index.ts index e5cbbc48..46efd9b6 100644 --- a/index.ts +++ b/index.ts @@ -19,6 +19,11 @@ export type { CssDiagnostic, CssValidationResult, } from './package/utils/sanitize-css'; +export { + isAllowedEmbedSrc, + recommendedEmbedFrameSrcCsp, + ALLOWED_EMBED_FRAME_ORIGINS, +} from './package/utils/is-allowed-embed-src'; export { buildVersionDiffSnapshot } from './package/components/tabs/utils/version-diff-snapshot'; export type { DdocExportModalProps, diff --git a/package/extensions/d-block/dblock-media-plugin.ts b/package/extensions/d-block/dblock-media-plugin.ts index 8a4bb47b..5209611c 100644 --- a/package/extensions/d-block/dblock-media-plugin.ts +++ b/package/extensions/d-block/dblock-media-plugin.ts @@ -2,6 +2,7 @@ import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import { Plugin, PluginKey } from '@tiptap/pm/state'; import type { DBlockRuntimeState } from './dblock-runtime'; import { getDBlockRuntimeState } from './dblock-runtime'; +import { isAllowedEmbedSrc } from '../../utils/is-allowed-embed-src'; const DBLOCK_MEDIA_CONVERSION_META = 'dblock-media-conversion'; @@ -194,6 +195,13 @@ export const createDBlockMediaConversionPlugin = ( candidates .sort((a, b) => b.from - a.from) .forEach((candidate) => { + if ( + candidate.type === 'iframe' && + !isAllowedEmbedSrc(candidate.src) + ) { + return; + } + const node = candidate.type === 'img' ? view.state.schema.nodes.resizableMedia?.create({ diff --git a/package/extensions/iframe/iframe.ts b/package/extensions/iframe/iframe.ts index 6d1082c2..21473738 100644 --- a/package/extensions/iframe/iframe.ts +++ b/package/extensions/iframe/iframe.ts @@ -116,9 +116,21 @@ export const Iframe = Node.create({ }, renderHTML({ HTMLAttributes }) { + if (!isAllowedEmbedSrc(HTMLAttributes.src)) { + return [ + 'div', + { class: 'iframe-blocked' }, + 'Embed blocked: unsupported or disallowed source', + ]; + } + return [ 'iframe', - mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + sandbox: + 'allow-scripts allow-same-origin allow-presentation allow-popups', + referrerpolicy: 'no-referrer', + }), ]; }, diff --git a/package/extensions/resizable-media/resizable-media-node-view.tsx b/package/extensions/resizable-media/resizable-media-node-view.tsx index e3d3fd5c..47e14f71 100644 --- a/package/extensions/resizable-media/resizable-media-node-view.tsx +++ b/package/extensions/resizable-media/resizable-media-node-view.tsx @@ -9,6 +9,8 @@ import ToolbarButton from '../../common/toolbar-button'; import { SecureImage } from '../../components/secure-image.tsx'; import { SecureImageV2 } from '../../components/secure-image-v2.tsx'; import { IpfsImageFetchPayload } from '../../types.ts'; +import { isAllowedEmbedSrc } from '../../utils/is-allowed-embed-src'; + interface WidthAndHeight { width: number; height: number; @@ -36,8 +38,11 @@ export const getResizableMediaNodeView = const isImageType = mediaType === 'img' || mediaType === 'secure-img'; + const isSafeIframe = + mediaType === 'iframe' && isAllowedEmbedSrc(node.attrs.src); + const isSoundcloudIframe = - mediaType === 'iframe' && + isSafeIframe && URL.canParse(node.attrs.src) && new URL(node.attrs.src).hostname === 'w.soundcloud.com'; @@ -523,19 +528,31 @@ export const getResizableMediaNodeView = )} - {mediaType === 'iframe' && ( - <> -