diff --git a/packages/frontend/apps/electron/src/main/helper-process.ts b/packages/frontend/apps/electron/src/main/helper-process.ts index 13a00e2d0..d395d05fa 100644 --- a/packages/frontend/apps/electron/src/main/helper-process.ts +++ b/packages/frontend/apps/electron/src/main/helper-process.ts @@ -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 = { diff --git a/packages/frontend/apps/electron/src/main/recording/index.ts b/packages/frontend/apps/electron/src/main/recording/index.ts index 20dc99de1..6e836a884 100644 --- a/packages/frontend/apps/electron/src/main/recording/index.ts +++ b/packages/frontend/apps/electron/src/main/recording/index.ts @@ -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; diff --git a/packages/frontend/apps/electron/src/main/security-restrictions.ts b/packages/frontend/apps/electron/src/main/security-restrictions.ts index 4d14daf7e..ad0ce46ee 100644 --- a/packages/frontend/apps/electron/src/main/security-restrictions.ts +++ b/packages/frontend/apps/electron/src/main/security-restrictions.ts @@ -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' }; diff --git a/packages/frontend/apps/electron/src/main/security/open-external.ts b/packages/frontend/apps/electron/src/main/security/open-external.ts new file mode 100644 index 000000000..1bd55b28e --- /dev/null +++ b/packages/frontend/apps/electron/src/main/security/open-external.ts @@ -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 = [] +) => { + 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 = new Set( + DEFAULT_ALLOWED_PROTOCOLS +); diff --git a/packages/frontend/apps/electron/src/main/ui/handlers.ts b/packages/frontend/apps/electron/src/main/ui/handlers.ts index e1251eb46..ef4ce03ba 100644 --- a/packages/frontend/apps/electron/src/main/ui/handlers.ts +++ b/packages/frontend/apps/electron/src/main/ui/handlers.ts @@ -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 diff --git a/packages/frontend/core/src/desktop/pages/redirect/index.tsx b/packages/frontend/core/src/desktop/pages/redirect/index.tsx index 3405d237d..c5c5e5d8e 100644 --- a/packages/frontend/core/src/desktop/pages/redirect/index.tsx +++ b/packages/frontend/core/src/desktop/pages/redirect/index.tsx @@ -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 = () => { diff --git a/packages/frontend/core/src/modules/url/services/url.ts b/packages/frontend/core/src/modules/url/services/url.ts index b764f3ac1..be9731f35 100644 --- a/packages/frontend/core/src/modules/url/services/url.ts +++ b/packages/frontend/core/src/modules/url/services/url.ts @@ -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 {