mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
fix: a security issue related to open external (#13864)
This commit is contained in:
@@ -21,6 +21,7 @@ import type { HelperToMain, MainToHelper } from '../shared/type';
|
||||
import { MessageEventChannel } from '../shared/utils';
|
||||
import { beforeAppQuit } from './cleanup';
|
||||
import { logger } from './logger';
|
||||
import { openExternalSafely } from './security/open-external';
|
||||
|
||||
const HELPER_PROCESS_PATH = path.join(__dirname, './helper.js');
|
||||
|
||||
@@ -105,10 +106,10 @@ class HelperProcessManager {
|
||||
return dialog.showSaveDialog(window, opts);
|
||||
},
|
||||
};
|
||||
const shellMethods = pickAndBind(shell, [
|
||||
'openExternal',
|
||||
'showItemInFolder',
|
||||
]);
|
||||
const shellMethods = {
|
||||
openExternal: openExternalSafely as typeof shell.openExternal,
|
||||
showItemInFolder: shell.showItemInFolder.bind(shell),
|
||||
};
|
||||
const appMethods = pickAndBind(app, ['getPath']);
|
||||
|
||||
const mainToHelperServer: MainToHelper = {
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from 'node:path';
|
||||
import { shell } from 'electron';
|
||||
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import { openExternalSafely } from '../security/open-external';
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import {
|
||||
askForMeetingPermission,
|
||||
@@ -87,7 +88,9 @@ export const recordingHandlers = {
|
||||
microphone: 'Privacy_Microphone',
|
||||
};
|
||||
const url = `x-apple.systempreferences:com.apple.preference.security?${urlMap[type]}`;
|
||||
return shell.openExternal(url);
|
||||
return openExternalSafely(url, {
|
||||
additionalProtocols: ['x-apple.systempreferences:'],
|
||||
});
|
||||
}
|
||||
// this only available on MacOS
|
||||
return false;
|
||||
|
||||
@@ -1,4 +1,35 @@
|
||||
import { app, shell } from 'electron';
|
||||
import { app } from 'electron';
|
||||
|
||||
import { openExternalSafely } from './security/open-external';
|
||||
|
||||
const extractRedirectTarget = (rawUrl: string) => {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const redirectUri = parsed.searchParams.get('redirect_uri');
|
||||
if (redirectUri) {
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
if (parsed.hash) {
|
||||
const hash = parsed.hash.startsWith('#')
|
||||
? parsed.hash.slice(1)
|
||||
: parsed.hash;
|
||||
|
||||
const queryIndex = hash.indexOf('?');
|
||||
if (queryIndex !== -1) {
|
||||
const hashParams = new URLSearchParams(hash.slice(queryIndex + 1));
|
||||
const hashRedirect = hashParams.get('redirect_uri');
|
||||
if (hashRedirect) {
|
||||
return hashRedirect;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
app.on('web-contents-created', (_, contents) => {
|
||||
const isInternalUrl = (url: string) => {
|
||||
@@ -18,7 +49,9 @@ app.on('web-contents-created', (_, contents) => {
|
||||
}
|
||||
// Prevent navigation
|
||||
event.preventDefault();
|
||||
shell.openExternal(url).catch(console.error);
|
||||
openExternalSafely(url).catch(error => {
|
||||
console.error('[security] Failed to open external URL:', error);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -32,9 +65,22 @@ app.on('web-contents-created', (_, contents) => {
|
||||
* @see https://www.electronjs.org/docs/latest/tutorial/security#15-do-not-use-openexternal-with-untrusted-content
|
||||
*/
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
if (!isInternalUrl(url) || url.includes('/redirect-proxy')) {
|
||||
// Open default browser
|
||||
shell.openExternal(url).catch(console.error);
|
||||
if (!isInternalUrl(url)) {
|
||||
openExternalSafely(url).catch(error => {
|
||||
console.error('[security] Failed to open external URL:', error);
|
||||
});
|
||||
} else if (url.includes('/redirect-proxy')) {
|
||||
const redirectTarget = extractRedirectTarget(url);
|
||||
if (redirectTarget) {
|
||||
openExternalSafely(redirectTarget).catch(error => {
|
||||
console.error('[security] Failed to open external URL:', error);
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
'[security] Blocked redirect proxy with missing redirect target:',
|
||||
url
|
||||
);
|
||||
}
|
||||
}
|
||||
// Prevent creating new window in application
|
||||
return { action: 'deny' };
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { shell } from 'electron';
|
||||
|
||||
const DEFAULT_ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
|
||||
|
||||
export interface OpenExternalOptions {
|
||||
additionalProtocols?: string[];
|
||||
}
|
||||
|
||||
export const isAllowedExternalUrl = (
|
||||
rawUrl: string,
|
||||
additionalProtocols: Iterable<string> = []
|
||||
) => {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const protocol = parsed.protocol.toLowerCase();
|
||||
if (DEFAULT_ALLOWED_PROTOCOLS.has(protocol)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const extra of additionalProtocols) {
|
||||
if (protocol === extra.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.warn('[security] Failed to parse external URL', rawUrl, error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const openExternalSafely = async (
|
||||
rawUrl: string,
|
||||
options: OpenExternalOptions = {}
|
||||
) => {
|
||||
const { additionalProtocols = [] } = options;
|
||||
|
||||
if (!isAllowedExternalUrl(rawUrl, additionalProtocols)) {
|
||||
console.warn('[security] Blocked attempt to open external URL:', rawUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await shell.openExternal(rawUrl);
|
||||
} catch (error) {
|
||||
console.error('[security] Failed to open external URL:', rawUrl, error);
|
||||
}
|
||||
};
|
||||
export const ALLOWED_EXTERNAL_PROTOCOLS: ReadonlySet<string> = new Set(
|
||||
DEFAULT_ALLOWED_PROTOCOLS
|
||||
);
|
||||
@@ -1,9 +1,10 @@
|
||||
import { app, clipboard, nativeImage, nativeTheme, shell } from 'electron';
|
||||
import { app, clipboard, nativeImage, nativeTheme } from 'electron';
|
||||
import { getLinkPreview } from 'link-preview-js';
|
||||
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import { persistentConfig } from '../config-storage/persist';
|
||||
import { logger } from '../logger';
|
||||
import { openExternalSafely } from '../security/open-external';
|
||||
import type { WorkbenchViewMeta } from '../shared-state-schema';
|
||||
import type { NamespaceHandlers } from '../type';
|
||||
import {
|
||||
@@ -151,7 +152,7 @@ export const uiHandlers = {
|
||||
}
|
||||
},
|
||||
openExternal(_, url: string) {
|
||||
return shell.openExternal(url);
|
||||
return openExternalSafely(url);
|
||||
},
|
||||
|
||||
// tab handlers
|
||||
|
||||
@@ -14,6 +14,7 @@ const trustedDomain = [
|
||||
];
|
||||
|
||||
const logger = new DebugLogger('redirect_proxy');
|
||||
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
|
||||
|
||||
/**
|
||||
* /redirect-proxy page
|
||||
@@ -32,6 +33,11 @@ export const loader: LoaderFunction = async ({ request }) => {
|
||||
try {
|
||||
const target = new URL(redirectUri);
|
||||
|
||||
if (!ALLOWED_PROTOCOLS.has(target.protocol)) {
|
||||
logger.warn('Blocked redirect with disallowed protocol', target.protocol);
|
||||
return { allow: false };
|
||||
}
|
||||
|
||||
if (
|
||||
target.hostname === window.location.hostname ||
|
||||
trustedDomain.some(domain =>
|
||||
@@ -46,7 +52,8 @@ export const loader: LoaderFunction = async ({ request }) => {
|
||||
return { allow: false };
|
||||
}
|
||||
|
||||
return { allow: true };
|
||||
logger.warn('Blocked redirect to untrusted domain', redirectUri);
|
||||
return { allow: false };
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
|
||||
@@ -39,6 +39,16 @@ export class UrlService extends Service {
|
||||
* @param url only full url with http/https protocol is supported
|
||||
*/
|
||||
openExternal(url: string) {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid external URL: ${url}`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error('only http/https URLs are supported');
|
||||
}
|
||||
if (BUILD_CONFIG.isWeb || BUILD_CONFIG.isMobileWeb) {
|
||||
location.href = url;
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user