From da63b2ef52005b1c2ff0b87477625ae91feaea60 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 2 Feb 2026 16:38:15 -0800 Subject: [PATCH 1/3] fix custom deeplinks not working --- .../src/components/MCPUIResourceRenderer.tsx | 68 +++++++----- ui/desktop/src/components/MarkdownContent.tsx | 51 ++++++++- .../src/components/McpApps/McpAppRenderer.tsx | 33 +++++- .../settings/app/ExternalBackendSection.tsx | 3 +- ui/desktop/src/main.ts | 49 ++++++--- ui/desktop/src/utils/urlSecurity.ts | 101 ++++++++++++++++++ 6 files changed, 258 insertions(+), 47 deletions(-) create mode 100644 ui/desktop/src/utils/urlSecurity.ts diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index 348a682914f7..8bb66a2425d3 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -13,6 +13,7 @@ import { toast } from 'react-toastify'; import { EmbeddedResource } from '../api'; import { useTheme } from '../contexts/ThemeContext'; import { errorMessage } from '../utils/conversionUtils'; +import { isProtocolSafe, getProtocol } from '../utils/urlSecurity'; interface MCPUIResourceRendererProps { content: EmbeddedResource & { type: 'resource' }; @@ -177,52 +178,63 @@ export default function MCPUIResourceRenderer({ const { url } = actionEvent.payload; try { - const urlObj = new URL(url); - if (!['http:', 'https:'].includes(urlObj.protocol)) { + // Safe protocols open directly, unknown protocols require user confirmation + // Dangerous protocols are blocked by main.ts in the open-external handler + if (isProtocolSafe(url)) { + await window.electron.openExternal(url); return { - status: 'error' as const, - error: { - code: UIActionErrorCode.NAVIGATION_FAILED, - message: `Blocked potentially unsafe URL protocol: ${urlObj.protocol}`, - details: { url, protocol: urlObj.protocol }, - }, + status: 'success' as const, + message: `Opened ${url} in default application`, }; } - await window.electron.openExternal(url); - return { - status: 'success' as const, - message: `Opened ${url} in default browser`, - }; - } catch (error) { - if (error instanceof TypeError && error.message.includes('Invalid URL')) { + // Unknown protocols require user confirmation + const protocol = getProtocol(url); + if (!protocol) { return { status: 'error' as const, error: { code: UIActionErrorCode.INVALID_PARAMS, message: `Invalid URL format: ${url}`, - details: { url, error: error.message }, + details: { url }, }, }; - } else if (error instanceof Error && error.message.includes('Failed to open')) { - return { - status: 'error' as const, - error: { - code: UIActionErrorCode.NAVIGATION_FAILED, - message: `Failed to open URL in default browser`, - details: { url, error: error.message }, - }, - }; - } else { + } + + const result = await window.electron.showMessageBox({ + type: 'question', + buttons: ['Cancel', 'Open'], + defaultId: 0, + title: 'Open External Link', + message: `Open ${protocol} link?`, + detail: `This will open: ${url}`, + }); + + if (result.response !== 1) { return { status: 'error' as const, error: { code: UIActionErrorCode.NAVIGATION_FAILED, - message: `Unexpected error opening URL: ${url}`, - details: errorMessage(error), + message: 'User cancelled', + details: { url }, }, }; } + + await window.electron.openExternal(url); + return { + status: 'success' as const, + message: `Opened ${url} in default application`, + }; + } catch (error) { + return { + status: 'error' as const, + error: { + code: UIActionErrorCode.NAVIGATION_FAILED, + message: `Failed to open URL: ${url}`, + details: errorMessage(error), + }, + }; } }; diff --git a/ui/desktop/src/components/MarkdownContent.tsx b/ui/desktop/src/components/MarkdownContent.tsx index d0070d52029d..eea331ab7840 100644 --- a/ui/desktop/src/components/MarkdownContent.tsx +++ b/ui/desktop/src/components/MarkdownContent.tsx @@ -28,6 +28,7 @@ const customOneDarkTheme = { import { Check, Copy } from './icons'; import { wrapHTMLInCodeBlock } from '../utils/htmlSecurity'; +import { isProtocolSafe, getProtocol, BLOCKED_PROTOCOLS } from '../utils/urlSecurity'; interface CodeProps extends React.ClassAttributes, React.HTMLAttributes { inline?: boolean; @@ -143,6 +144,21 @@ const MarkdownCode = memo( }) ); +// Custom URL transform to preserve deep link URLs (spotify:, vscode:, slack:, etc.) +// React-markdown's default only allows http/https/mailto and strips all other protocols +// We allow all protocols except dangerous ones (javascript:, data:, file:, etc.) +const customUrlTransform = (url: string): string => { + try { + const protocol = new URL(url).protocol; + if (BLOCKED_PROTOCOLS.includes(protocol)) { + return ''; + } + } catch { + // Not a valid URL, allow it (could be relative path) + } + return url; +}; + const MarkdownContent = memo(function MarkdownContent({ content, className = '', @@ -179,6 +195,7 @@ const MarkdownContent = memo(function MarkdownContent({ prose-li:m-0 prose-li:font-sans ${className}`} > , + a: (props) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + if (!props.href) return; + + if (isProtocolSafe(props.href)) { + window.electron.openExternal(props.href); + } else { + const protocol = getProtocol(props.href); + if (!protocol) return; + + const result = await window.electron.showMessageBox({ + type: 'question', + buttons: ['Cancel', 'Open'], + defaultId: 0, + title: 'Open External Link', + message: `Open ${protocol} link?`, + detail: `This will open: ${props.href}`, + }); + if (result.response === 1) { + window.electron.openExternal(props.href); + } + } + }} + /> + ); + }, code: MarkdownCode, }} > diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx index 379ac592f69b..5607895b2c88 100644 --- a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx +++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx @@ -22,6 +22,7 @@ import { cn } from '../../utils'; import { DEFAULT_IFRAME_HEIGHT } from './utils'; import { readResource, callTool } from '../../api'; import { errorMessage } from '../../utils/conversionUtils'; +import { isProtocolSafe, getProtocol } from '../../utils/urlSecurity'; interface McpAppRendererProps { resourceUri: string; @@ -119,7 +120,37 @@ export default function McpAppRenderer({ switch (method) { case 'ui/open-link': { const { url } = params as McpMethodParams['ui/open-link']; - await window.electron.openExternal(url); + + // Safe protocols open directly, unknown protocols require confirmation + // Dangerous protocols are blocked by main.ts in the open-external handler + if (isProtocolSafe(url)) { + await window.electron.openExternal(url); + } else { + const protocol = getProtocol(url); + if (!protocol) { + return { + status: 'error', + message: 'Invalid URL', + } as McpMethodResponse['ui/open-link']; + } + + const result = await window.electron.showMessageBox({ + type: 'question', + buttons: ['Cancel', 'Open'], + defaultId: 0, + title: 'Open External Link', + message: `Open ${protocol} link?`, + detail: `This will open: ${url}`, + }); + if (result.response !== 1) { + return { + status: 'error', + message: 'User cancelled', + } as McpMethodResponse['ui/open-link']; + } + await window.electron.openExternal(url); + } + return { status: 'success', message: 'Link opened successfully', diff --git a/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx b/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx index fb09f8aec8cf..2e485cadbcde 100644 --- a/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx +++ b/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx @@ -4,6 +4,7 @@ import { Input } from '../../ui/input'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import { AlertCircle } from 'lucide-react'; import { ExternalGoosedConfig } from '../../../utils/settings'; +import { WEB_PROTOCOLS } from '../../../utils/urlSecurity'; const DEFAULT_CONFIG: ExternalGoosedConfig = { enabled: false, @@ -40,7 +41,7 @@ export default function ExternalBackendSection() { } try { const parsed = new URL(value); - if (!['http:', 'https:'].includes(parsed.protocol)) { + if (!WEB_PROTOCOLS.includes(parsed.protocol)) { setUrlError('URL must use http or https protocol'); return false; } diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 344b7957e6c5..70d0454c7cfa 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -46,6 +46,7 @@ import './utils/recipeHash'; import { Client, createClient, createConfig } from './api/client'; import { GooseApp } from './api'; import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; +import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity'; function shouldSetupUpdater(): boolean { // Setup updater if either the flag is enabled OR dev updates are enabled @@ -659,14 +660,20 @@ const createChat = async ( } }); - // Handle new window creation for links + // Handle new window creation for links (fallback for any links not handled by onClick) mainWindow.webContents.setWindowOpenHandler(({ url }) => { - // Open all links in external browser - if (url.startsWith('http:') || url.startsWith('https:')) { - shell.openExternal(url); + try { + const protocol = new URL(url).protocol; + if (BLOCKED_PROTOCOLS.includes(protocol)) { + return { action: 'deny' }; + } + } catch { return { action: 'deny' }; } - return { action: 'allow' }; + + // Open URL in system default handler + shell.openExternal(url); + return { action: 'deny' }; }); // Handle new-window events (alternative approach for external links) @@ -674,6 +681,14 @@ const createChat = async ( // eslint-disable-next-line @typescript-eslint/no-explicit-any mainWindow.webContents.on('new-window' as any, function (event: any, url: string) { event.preventDefault(); + try { + const protocol = new URL(url).protocol; + if (BLOCKED_PROTOCOLS.includes(protocol)) { + return; + } + } catch { + return; + } shell.openExternal(url); }); @@ -1166,15 +1181,17 @@ ipcMain.on('react-ready', (event) => { log.info('React ready - window is prepared for deep links'); }); -// Handle external URL opening +// Handle external URL opening with security checks ipcMain.handle('open-external', async (_event, url: string) => { - try { - await shell.openExternal(url); - return true; - } catch (error) { - console.error('Error opening external URL:', error); - throw error; + const parsedUrl = new URL(url); + + // Block dangerous protocols + if (BLOCKED_PROTOCOLS.includes(parsedUrl.protocol)) { + console.warn(`[Main] Blocked dangerous protocol: ${parsedUrl.protocol}`); + return; } + + await shell.openExternal(url); }); ipcMain.handle('directory-chooser', async () => { @@ -2150,8 +2167,8 @@ async function appMain() { // Validate URL const parsedUrl = new URL(url); - // Only allow http and https protocols - if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + // Only allow http and https protocols for fetching web content + if (!WEB_PROTOCOLS.includes(parsedUrl.protocol)) { throw new Error('Invalid URL protocol. Only HTTP and HTTPS are allowed.'); } @@ -2189,8 +2206,8 @@ async function appMain() { // Validate URL const parsedUrl = new URL(url); - // Only allow http and https protocols - if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + // Only allow http and https protocols for browser URLs + if (!WEB_PROTOCOLS.includes(parsedUrl.protocol)) { console.error('Invalid URL protocol. Only HTTP and HTTPS are allowed.'); return; } diff --git a/ui/desktop/src/utils/urlSecurity.ts b/ui/desktop/src/utils/urlSecurity.ts new file mode 100644 index 000000000000..7a0487274c4d --- /dev/null +++ b/ui/desktop/src/utils/urlSecurity.ts @@ -0,0 +1,101 @@ +// URL protocol constants and security utilities + +// Protocols for web content only (HTTP requests, browser URLs, server connections) +export const WEB_PROTOCOLS = ['http:', 'https:']; + +// Protocols that should never be opened (security risk) +export const BLOCKED_PROTOCOLS = [ + 'file:', + 'javascript:', + 'data:', + 'vbscript:', + 'blob:', + 'about:', + 'chrome:', + 'chrome-extension:', +]; + +// Protocols that are safe to open without confirmation +export const SAFE_PROTOCOLS = [ + // Web + 'http:', + 'https:', + // Communication + 'mailto:', + 'tel:', + 'sms:', + 'facetime:', + 'facetime-audio:', + // Messaging apps + 'slack:', + 'discord:', + 'tg:', // Telegram + 'telegram:', + 'whatsapp:', + 'skype:', + 'msteams:', // Microsoft Teams + // Development tools + 'vscode:', + 'vscode-insiders:', + 'vscodium:', + 'jetbrains:', + 'sublime:', + 'atom:', + 'github-mac:', + 'github-windows:', + 'sourcetree:', + 'cursor:', + // Media + 'spotify:', + 'music:', // Apple Music + 'itmss:', // iTunes + 'vlc:', + // Video conferencing + 'zoommtg:', + 'zoomus:', + 'webex:', + 'meet:', // Google Meet + // Productivity + 'notion:', + 'obsidian:', + 'bear:', + 'things:', + 'omnifocus:', + 'todoist:', + 'evernote:', + 'onenote:', + // Cloud storage + 'dropbox:', + 'googledrive:', + 'onedrive:', + // Browsers + 'googlechrome:', + 'firefox:', + 'safari:', + // Goose + 'goose:', +]; + +/** + * Check if a URL uses a protocol that is safe to open without user confirmation. + * Dangerous protocols are blocked centrally in main.ts open-external handler. + */ +export const isProtocolSafe = (url: string): boolean => { + try { + const parsed = new URL(url); + return SAFE_PROTOCOLS.includes(parsed.protocol); + } catch { + return false; + } +}; + +/** + * Extract the protocol from a URL string. + */ +export const getProtocol = (url: string): string | null => { + try { + return new URL(url).protocol; + } catch { + return null; + } +}; From 94c2718315119fc6b9da80792a688b98cd0b18d0 Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 2 Feb 2026 16:40:11 -0800 Subject: [PATCH 2/3] cleanup --- ui/desktop/src/main.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 70d0454c7cfa..5cd77fb8dc67 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -671,7 +671,6 @@ const createChat = async ( return { action: 'deny' }; } - // Open URL in system default handler shell.openExternal(url); return { action: 'deny' }; }); @@ -1181,11 +1180,9 @@ ipcMain.on('react-ready', (event) => { log.info('React ready - window is prepared for deep links'); }); -// Handle external URL opening with security checks ipcMain.handle('open-external', async (_event, url: string) => { const parsedUrl = new URL(url); - // Block dangerous protocols if (BLOCKED_PROTOCOLS.includes(parsedUrl.protocol)) { console.warn(`[Main] Blocked dangerous protocol: ${parsedUrl.protocol}`); return; From 430bcce6f34e822eb482dcb6971d7fcc17dbe32e Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Mon, 2 Feb 2026 16:41:16 -0800 Subject: [PATCH 3/3] cleanup --- ui/desktop/src/utils/urlSecurity.ts | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/ui/desktop/src/utils/urlSecurity.ts b/ui/desktop/src/utils/urlSecurity.ts index 7a0487274c4d..08fbb35f439c 100644 --- a/ui/desktop/src/utils/urlSecurity.ts +++ b/ui/desktop/src/utils/urlSecurity.ts @@ -17,24 +17,20 @@ export const BLOCKED_PROTOCOLS = [ // Protocols that are safe to open without confirmation export const SAFE_PROTOCOLS = [ - // Web 'http:', 'https:', - // Communication 'mailto:', 'tel:', 'sms:', 'facetime:', 'facetime-audio:', - // Messaging apps 'slack:', 'discord:', - 'tg:', // Telegram + 'tg:', 'telegram:', 'whatsapp:', 'skype:', - 'msteams:', // Microsoft Teams - // Development tools + 'msteams:', 'vscode:', 'vscode-insiders:', 'vscodium:', @@ -45,17 +41,14 @@ export const SAFE_PROTOCOLS = [ 'github-windows:', 'sourcetree:', 'cursor:', - // Media 'spotify:', - 'music:', // Apple Music - 'itmss:', // iTunes + 'music:', + 'itmss:', 'vlc:', - // Video conferencing 'zoommtg:', 'zoomus:', 'webex:', - 'meet:', // Google Meet - // Productivity + 'meet:', 'notion:', 'obsidian:', 'bear:', @@ -64,15 +57,12 @@ export const SAFE_PROTOCOLS = [ 'todoist:', 'evernote:', 'onenote:', - // Cloud storage 'dropbox:', 'googledrive:', 'onedrive:', - // Browsers 'googlechrome:', 'firefox:', 'safari:', - // Goose 'goose:', ];