fix deep links not working in markdown (#6907)

This commit is contained in:
Zane
2026-02-03 07:51:42 -08:00
committed by GitHub
parent a3f93ad532
commit 5c259e09a4
6 changed files with 250 additions and 52 deletions
@@ -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,14 +178,45 @@ 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: 'success' as const,
message: `Opened ${url} in default application`,
};
}
// 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 },
},
};
}
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: `Blocked potentially unsafe URL protocol: ${urlObj.protocol}`,
details: { url, protocol: urlObj.protocol },
message: 'User cancelled',
details: { url },
},
};
}
@@ -192,37 +224,17 @@ export default function MCPUIResourceRenderer({
await window.electron.openExternal(url);
return {
status: 'success' as const,
message: `Opened ${url} in default browser`,
message: `Opened ${url} in default application`,
};
} catch (error) {
if (error instanceof TypeError && error.message.includes('Invalid URL')) {
return {
status: 'error' as const,
error: {
code: UIActionErrorCode.INVALID_PARAMS,
message: `Invalid URL format: ${url}`,
details: { url, error: error.message },
},
};
} 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 {
return {
status: 'error' as const,
error: {
code: UIActionErrorCode.NAVIGATION_FAILED,
message: `Unexpected error opening URL: ${url}`,
details: errorMessage(error),
},
};
}
return {
status: 'error' as const,
error: {
code: UIActionErrorCode.NAVIGATION_FAILED,
message: `Failed to open URL: ${url}`,
details: errorMessage(error),
},
};
}
};
+50 -1
View File
@@ -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;
@@ -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}`}
>
<ReactMarkdown
urlTransform={customUrlTransform}
remarkPlugins={[remarkGfm, remarkBreaks, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[
[
@@ -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,
}}
>
@@ -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',
@@ -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;
}
+30 -16
View File
@@ -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,19 @@ 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)
@@ -674,6 +680,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 +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 () => {
@@ -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.');
}
@@ -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;
}
+91
View File
@@ -0,0 +1,91 @@
// 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 = [
'http:',
'https:',
'mailto:',
'tel:',
'sms:',
'facetime:',
'facetime-audio:',
'slack:',
'discord:',
'tg:',
'telegram:',
'whatsapp:',
'skype:',
'msteams:',
'vscode:',
'vscode-insiders:',
'vscodium:',
'jetbrains:',
'sublime:',
'atom:',
'github-mac:',
'github-windows:',
'sourcetree:',
'cursor:',
'spotify:',
'music:',
'itmss:',
'vlc:',
'zoommtg:',
'zoomus:',
'webex:',
'meet:',
'notion:',
'obsidian:',
'bear:',
'things:',
'omnifocus:',
'todoist:',
'evernote:',
'onenote:',
'dropbox:',
'googledrive:',
'onedrive:',
'googlechrome:',
'firefox:',
'safari:',
'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;
}
};