mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-25 06:18:45 +08:00
feat(server): native safe fetch (#14931)
This commit is contained in:
+22
-4
@@ -35,7 +35,7 @@ const extractBvid = (url: string) => {
|
||||
|
||||
const buildBiliPlayerEmbedUrl = (url: string) => {
|
||||
// If the user pasted the embed URL directly, keep it
|
||||
if (validateEmbedIframeUrl(url, biliPlayerValidationOptions)) {
|
||||
if (isValidBiliPlayerUrl(url)) {
|
||||
return url;
|
||||
}
|
||||
const avid = extractAvid(url);
|
||||
@@ -57,13 +57,31 @@ const buildBiliPlayerEmbedUrl = (url: string) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const bilibiliConfig = {
|
||||
function isValidBiliPlayerUrl(url: string) {
|
||||
try {
|
||||
if (!validateEmbedIframeUrl(url, biliPlayerValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
const parsedUrl = new URL(url);
|
||||
return (
|
||||
parsedUrl.pathname === '/player.html' &&
|
||||
(!!parsedUrl.searchParams.get('aid') ||
|
||||
!!parsedUrl.searchParams.get('bvid'))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const bilibiliConfig = {
|
||||
name: 'bilibili',
|
||||
match: (url: string) =>
|
||||
validateEmbedIframeUrl(url, bilibiliValidationOptions) &&
|
||||
(!!extractAvid(url) || !!extractBvid(url)),
|
||||
isValidBiliPlayerUrl(url) ||
|
||||
(validateEmbedIframeUrl(url, bilibiliValidationOptions) &&
|
||||
(!!extractAvid(url) || !!extractBvid(url))),
|
||||
buildOEmbedUrl: buildBiliPlayerEmbedUrl,
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) => isValidBiliPlayerUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: BILIBILI_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: BILIBILI_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
+3
-1
@@ -15,7 +15,7 @@ const excalidrawUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
hostnames: ['excalidraw.com'],
|
||||
};
|
||||
|
||||
const excalidrawConfig = {
|
||||
export const excalidrawConfig = {
|
||||
name: 'excalidraw',
|
||||
match: (url: string) =>
|
||||
validateEmbedIframeUrl(url, excalidrawUrlValidationOptions),
|
||||
@@ -27,6 +27,8 @@ const excalidrawConfig = {
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) =>
|
||||
validateEmbedIframeUrl(iframeUrl, excalidrawUrlValidationOptions),
|
||||
options: {
|
||||
widthInSurface: EXCALIDRAW_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: EXCALIDRAW_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
import {
|
||||
type EmbedIframeUrlValidationOptions,
|
||||
validateEmbedIframeUrl,
|
||||
} from '../../utils';
|
||||
|
||||
const GENERIC_DEFAULT_WIDTH_IN_SURFACE = 800;
|
||||
const GENERIC_DEFAULT_HEIGHT_IN_SURFACE = 600;
|
||||
const GENERIC_DEFAULT_WIDTH_PERCENT = 100;
|
||||
@@ -17,6 +22,11 @@ const AFFINE_DOMAINS = [
|
||||
'apple.getaffineapp.com', // Cloud domain for Apple app
|
||||
];
|
||||
|
||||
const genericUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates if a URL is suitable for generic iframe embedding
|
||||
* Allows HTTPS URLs but excludes AFFiNE domains
|
||||
@@ -27,8 +37,12 @@ function isValidGenericEmbedUrl(url: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// Only allow HTTPS for security
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
if (
|
||||
!validateEmbedIframeUrl(url, {
|
||||
...genericUrlValidationOptions,
|
||||
hostnames: [parsedUrl.hostname],
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -49,7 +63,7 @@ function isValidGenericEmbedUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const genericConfig = {
|
||||
export const genericConfig = {
|
||||
name: 'generic',
|
||||
match: (url: string) => isValidGenericEmbedUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
@@ -59,6 +73,7 @@ const genericConfig = {
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) => isValidGenericEmbedUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: GENERIC_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GENERIC_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
+2
-1
@@ -57,7 +57,7 @@ function isValidGoogleDocsUrl(url: string, strictMode = true): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const googleDocsConfig = {
|
||||
export const googleDocsConfig = {
|
||||
name: 'google-docs',
|
||||
match: (url: string) => isValidGoogleDocsUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
@@ -67,6 +67,7 @@ const googleDocsConfig = {
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) => isValidGoogleDocsUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: GOOGLE_DOCS_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GOOGLE_DOCS_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
+26
-1
@@ -113,6 +113,29 @@ function isValidGoogleDriveUrl(url: string, strictMode = true): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidGoogleDriveIframeUrl(url: string): boolean {
|
||||
try {
|
||||
if (!validateEmbedIframeUrl(url, googleDriveUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (isValidGoogleDriveFileUrl(parsedUrl)) {
|
||||
return pathSegments[3] === 'preview';
|
||||
}
|
||||
|
||||
return (
|
||||
parsedUrl.pathname === '/embeddedfolderview' &&
|
||||
!!parsedUrl.searchParams.get('id')
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Invalid Google Drive iframe URL:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build embed URL for Google Drive files
|
||||
* @param fileId File ID
|
||||
@@ -171,7 +194,7 @@ function buildGoogleDriveEmbedUrl(url: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
const googleDriveConfig = {
|
||||
export const googleDriveConfig = {
|
||||
name: 'google-drive',
|
||||
match: (url: string) => isValidGoogleDriveUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
@@ -183,6 +206,8 @@ const googleDriveConfig = {
|
||||
return buildGoogleDriveEmbedUrl(url);
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
validateIframeUrl: (iframeUrl: string) =>
|
||||
isValidGoogleDriveIframeUrl(iframeUrl),
|
||||
options: {
|
||||
widthInSurface: GOOGLE_DRIVE_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
@@ -18,7 +18,7 @@ const miroUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
hostnames: ['miro.com'],
|
||||
};
|
||||
|
||||
const miroConfig = {
|
||||
export const miroConfig = {
|
||||
name: 'miro',
|
||||
match: (url: string) => validateEmbedIframeUrl(url, miroUrlValidationOptions),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
@@ -31,6 +31,12 @@ const miroConfig = {
|
||||
return oEmbedUrl;
|
||||
},
|
||||
useOEmbedUrlDirectly: false,
|
||||
validateIframeUrl: (iframeUrl: string) => {
|
||||
if (!validateEmbedIframeUrl(iframeUrl, miroUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
return new URL(iframeUrl).pathname.startsWith('/app/live-embed/');
|
||||
},
|
||||
options: {
|
||||
widthInSurface: MIRO_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: MIRO_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
@@ -18,7 +18,12 @@ const spotifyUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
hostnames: ['open.spotify.com', 'spotify.link'],
|
||||
};
|
||||
|
||||
const spotifyConfig = {
|
||||
const spotifyIframeUrlValidationOptions: EmbedIframeUrlValidationOptions = {
|
||||
protocols: ['https:'],
|
||||
hostnames: ['open.spotify.com'],
|
||||
};
|
||||
|
||||
export const spotifyConfig = {
|
||||
name: 'spotify',
|
||||
match: (url: string) =>
|
||||
validateEmbedIframeUrl(url, spotifyUrlValidationOptions),
|
||||
@@ -32,6 +37,13 @@ const spotifyConfig = {
|
||||
return oEmbedUrl;
|
||||
},
|
||||
useOEmbedUrlDirectly: false,
|
||||
validateIframeUrl: (iframeUrl: string) => {
|
||||
if (!validateEmbedIframeUrl(iframeUrl, spotifyIframeUrlValidationOptions)) {
|
||||
return false;
|
||||
}
|
||||
const parsedUrl = new URL(iframeUrl);
|
||||
return parsedUrl.pathname.split('/').find(Boolean) === 'embed';
|
||||
},
|
||||
options: {
|
||||
widthInSurface: SPOTIFY_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: SPOTIFY_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
|
||||
@@ -141,7 +141,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.open(link, '_blank');
|
||||
window.open(link, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
refreshData = async () => {
|
||||
@@ -183,6 +183,12 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
|
||||
// update model
|
||||
const iframeUrl = this._getIframeUrl(embedData) ?? currentIframeUrl;
|
||||
if (!this._validateIframeUrl(url, iframeUrl)) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'Invalid embed iframe url'
|
||||
);
|
||||
}
|
||||
this.store.updateBlock(this.model, {
|
||||
iframeUrl,
|
||||
title: embedData?.title || previewData?.title,
|
||||
@@ -291,6 +297,19 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _validateIframeUrl = (url: string, iframeUrl?: string) => {
|
||||
if (!iframeUrl) {
|
||||
return false;
|
||||
}
|
||||
const config = this.embedIframeService?.getConfig(url);
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
return config.validateIframeUrl
|
||||
? config.validateIframeUrl(iframeUrl, url)
|
||||
: config.match(iframeUrl);
|
||||
};
|
||||
|
||||
private readonly _handleDoubleClick = () => {
|
||||
this.open();
|
||||
};
|
||||
@@ -329,6 +348,16 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
|
||||
private readonly _renderIframe = () => {
|
||||
const { iframeUrl } = this.model.props;
|
||||
if (!iframeUrl || !this._isIframeUrlAllowed(iframeUrl)) {
|
||||
return html`<embed-iframe-error-card
|
||||
.error=${new Error('Invalid iframe URL')}
|
||||
.model=${this.model}
|
||||
.onRetry=${this._handleRetry}
|
||||
.std=${this.std}
|
||||
.inSurface=${this.inSurface}
|
||||
.options=${this._statusCardOptions}
|
||||
></embed-iframe-error-card>`;
|
||||
}
|
||||
const {
|
||||
widthPercent,
|
||||
heightInNote,
|
||||
@@ -368,6 +397,10 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
: nothing}`;
|
||||
};
|
||||
|
||||
private readonly _isIframeUrlAllowed = (iframeUrl: string) => {
|
||||
return this._validateIframeUrl(this.model.props.url, iframeUrl);
|
||||
};
|
||||
|
||||
private readonly _getSourceHost = () => {
|
||||
const url = this.model.props.url ?? this.model.props.iframeUrl;
|
||||
if (!url) return null;
|
||||
@@ -437,7 +470,12 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
||||
} else {
|
||||
// update iframe options, to ensure the iframe is rendered with the correct options
|
||||
this._updateIframeOptions(this.model.props.url);
|
||||
this.status$.value = 'success';
|
||||
this.status$.value = this._validateIframeUrl(
|
||||
this.model.props.url,
|
||||
this.model.props.iframeUrl
|
||||
)
|
||||
? 'success'
|
||||
: 'error';
|
||||
}
|
||||
|
||||
// refresh data when original url changes
|
||||
|
||||
@@ -9,6 +9,25 @@ export interface EmbedIframeUrlValidationOptions {
|
||||
hostnames: string[]; // Allowed hostnames, e.g. ['docs.google.com']
|
||||
}
|
||||
|
||||
function isLocalOrIpHostname(hostname: string): boolean {
|
||||
const lower = hostname.toLowerCase();
|
||||
if (
|
||||
lower === 'localhost' ||
|
||||
lower.endsWith('.localhost') ||
|
||||
lower === '0.0.0.0' ||
|
||||
lower === '::' ||
|
||||
lower === '::1'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return lower.startsWith('[') && lower.endsWith(']');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the url is allowed to embed in the iframe
|
||||
* @param url URL to validate
|
||||
@@ -23,6 +42,15 @@ export function validateEmbedIframeUrl(
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
const { protocols, hostnames } = options;
|
||||
if (
|
||||
parsedUrl.username ||
|
||||
parsedUrl.password ||
|
||||
parsedUrl.port ||
|
||||
isLocalOrIpHostname(parsedUrl.hostname)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
protocols.includes(parsedUrl.protocol) &&
|
||||
hostnames.includes(parsedUrl.hostname)
|
||||
|
||||
Reference in New Issue
Block a user