Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 40 additions & 28 deletions ui/desktop/src/components/MCPUIResourceRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down Expand Up @@ -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),
},
};
}
};

Expand Down
51 changes: 50 additions & 1 deletion ui/desktop/src/components/MarkdownContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>, React.HTMLAttributes<HTMLElement> {
inline?: boolean;
Expand Down Expand Up @@ -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 = '',
Expand Down Expand Up @@ -179,6 +195,7 @@ const MarkdownContent = memo(function MarkdownContent({
prose-li:m-0 prose-li:font-sans ${className}`}
>
<ReactMarkdown
urlTransform={customUrlTransform}
remarkPlugins={[remarkGfm, remarkBreaks, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[
[
Expand All @@ -191,7 +208,39 @@ const MarkdownContent = memo(function MarkdownContent({
],
]}
components={{
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
a: (props) => {
return (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
onClick={async (e) => {
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,
}}
>
Expand Down
33 changes: 32 additions & 1 deletion ui/desktop/src/components/McpApps/McpAppRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
46 changes: 30 additions & 16 deletions ui/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -659,21 +660,34 @@ 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' };

shell.openExternal(url);
return { action: 'deny' };
});

// Handle new-window events (alternative approach for external links)
// Use type assertion for non-standard Electron event
// 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);
});

Expand Down Expand Up @@ -1166,15 +1180,15 @@ ipcMain.on('react-ready', (event) => {
log.info('React ready - window is prepared for deep links');
});

// Handle external URL opening
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);

if (BLOCKED_PROTOCOLS.includes(parsedUrl.protocol)) {
console.warn(`[Main] Blocked dangerous protocol: ${parsedUrl.protocol}`);
return;
}

await shell.openExternal(url);
});

ipcMain.handle('directory-chooser', async () => {
Expand Down Expand Up @@ -2150,8 +2164,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.');
}

Expand Down Expand Up @@ -2189,8 +2203,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;
}
Expand Down
Loading
Loading