Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions demo/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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};`,
},
},
});
5 changes: 5 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions package/extensions/d-block/dblock-media-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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({
Expand Down
14 changes: 13 additions & 1 deletion package/extensions/iframe/iframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,21 @@ export const Iframe = Node.create<IframeOptions>({
},

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

Expand Down
45 changes: 31 additions & 14 deletions package/extensions/resizable-media/resizable-media-node-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -523,19 +528,31 @@ export const getResizableMediaNodeView =
</video>
)}

{mediaType === 'iframe' && (
<>
<iframe
ref={resizableImgRef as LegacyRef<HTMLIFrameElement>}
className={cn(
'rounded-lg max-w-full',
isMouseDown && 'pointer-events-none',
)}
src={node.attrs.src}
width={node.attrs.width}
height={node.attrs.height}
/>
</>
{mediaType === 'iframe' && isSafeIframe && (
<iframe
ref={resizableImgRef as LegacyRef<HTMLIFrameElement>}
className={cn(
'rounded-lg max-w-full',
isMouseDown && 'pointer-events-none',
)}
src={node.attrs.src}
width={node.attrs.width}
height={node.attrs.height}
sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"
referrerPolicy="no-referrer"
/>
)}

{mediaType === 'iframe' && !isSafeIframe && (
<div
className="rounded-lg border color-border-default color-bg-secondary color-text-secondary text-sm p-4 max-w-full"
style={{
width: node.attrs.width || 640,
minHeight: node.attrs.height || 120,
}}
>
Embed blocked: unsupported or disallowed source
</div>
)}

{!isPreviewMode && !isImageType && (
Expand Down
65 changes: 65 additions & 0 deletions package/utils/is-allowed-embed-src.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
isAllowedEmbedSrc,
recommendedEmbedFrameSrcCsp,
} from './is-allowed-embed-src';

describe('isAllowedEmbedSrc', () => {
it('accepts allowlisted https embed hosts/paths', () => {
expect(
isAllowedEmbedSrc('https://www.youtube.com/embed/dQw4w9WgXcQ'),
).toBe(true);
expect(
isAllowedEmbedSrc('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ'),
).toBe(true);
expect(isAllowedEmbedSrc('https://player.vimeo.com/video/123456')).toBe(
true,
);
expect(
isAllowedEmbedSrc(
'https://w.soundcloud.com/player/?url=https%3A//api.soundcloud.com/tracks/1',
),
).toBe(true);
});

it('rejects arbitrary third-party frames', () => {
expect(isAllowedEmbedSrc('https://evil.example/payload')).toBe(false);
expect(isAllowedEmbedSrc('https://evil.example/embed/x')).toBe(false);
expect(isAllowedEmbedSrc('javascript:alert(1)')).toBe(false);
expect(isAllowedEmbedSrc('data:text/html,<script>alert(1)</script>')).toBe(
false,
);
expect(isAllowedEmbedSrc('https://www.youtube.com/watch?v=x')).toBe(false);
expect(isAllowedEmbedSrc('https://youtube.com/')).toBe(false);
});

it('rejects http even for allowlisted hosts', () => {
expect(isAllowedEmbedSrc('http://www.youtube.com/embed/dQw4w9WgXcQ')).toBe(
false,
);
expect(isAllowedEmbedSrc('http://player.vimeo.com/video/123')).toBe(false);
});

it('rejects non-strings and invalid URLs', () => {
expect(isAllowedEmbedSrc(null)).toBe(false);
expect(isAllowedEmbedSrc(undefined)).toBe(false);
expect(isAllowedEmbedSrc('')).toBe(false);
expect(isAllowedEmbedSrc('not-a-url')).toBe(false);
});
});

describe('recommendedEmbedFrameSrcCsp', () => {
it('includes self and allowlisted https origins', () => {
const value = recommendedEmbedFrameSrcCsp();
expect(value).toContain("'self'");
expect(value).toContain('https://www.youtube.com');
expect(value).toContain('https://player.vimeo.com');
expect(value).toContain('https://w.soundcloud.com');
expect(value).not.toContain('http://');
});

it('merges extra origins', () => {
const value = recommendedEmbedFrameSrcCsp(['https://cdn.example']);
expect(value).toContain('https://cdn.example');
});
});
29 changes: 25 additions & 4 deletions package/utils/is-allowed-embed-src.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Allowlist for iframe `src` URLs. Only hosts/paths produced by the
// in-app embed flows (YouTube, Vimeo, SoundCloud) are accepted. Everything
// else is rejected to prevent loading arbitrary third-party frames.
// Allowlist for iframe `src` URLs (HTTPS YouTube / Vimeo / SoundCloud only).
// Host apps should also set CSP frame-src via recommendedEmbedFrameSrcCsp().

const ALLOWED_EMBEDS: { host: string; pathPrefix: string }[] = [
{ host: 'www.youtube.com', pathPrefix: '/embed/' },
{ host: 'youtube.com', pathPrefix: '/embed/' },
Expand All @@ -10,10 +10,31 @@ const ALLOWED_EMBEDS: { host: string; pathPrefix: string }[] = [
{ host: 'w.soundcloud.com', pathPrefix: '/player' },
];

export const ALLOWED_EMBED_FRAME_ORIGINS = [
'https://www.youtube.com',
'https://youtube.com',
'https://www.youtube-nocookie.com',
'https://youtube-nocookie.com',
'https://player.vimeo.com',
'https://w.soundcloud.com',
] as const;

/** CSP `frame-src` value for hosts embedding DdocEditor. */
export function recommendedEmbedFrameSrcCsp(
extraOrigins: readonly string[] = [],
): string {
const origins = new Set<string>([
"'self'",
...ALLOWED_EMBED_FRAME_ORIGINS,
...extraOrigins,
]);
return Array.from(origins).join(' ');
}

export function isAllowedEmbedSrc(src: unknown): src is string {
if (typeof src !== 'string' || !URL.canParse(src)) return false;
const url = new URL(src);
if (url.protocol !== 'https:' && url.protocol !== 'http:') return false;
if (url.protocol !== 'https:') return false;
return ALLOWED_EMBEDS.some(
({ host, pathPrefix }) =>
url.hostname === host && url.pathname.startsWith(pathPrefix),
Expand Down