fix deep links not working in markdown (#6907)
This commit is contained in:
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user