feat(server): native safe fetch (#14931)

This commit is contained in:
DarkSky
2026-05-09 02:40:25 +08:00
committed by GitHub
parent 32a94d68dc
commit bcbde16c04
30 changed files with 1370 additions and 526 deletions
@@ -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,
@@ -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,
@@ -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,
@@ -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)