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 { EmbeddedResource } from '../api';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { errorMessage } from '../utils/conversionUtils';
|
import { errorMessage } from '../utils/conversionUtils';
|
||||||
|
import { isProtocolSafe, getProtocol } from '../utils/urlSecurity';
|
||||||
|
|
||||||
interface MCPUIResourceRendererProps {
|
interface MCPUIResourceRendererProps {
|
||||||
content: EmbeddedResource & { type: 'resource' };
|
content: EmbeddedResource & { type: 'resource' };
|
||||||
@@ -177,14 +178,45 @@ export default function MCPUIResourceRenderer({
|
|||||||
const { url } = actionEvent.payload;
|
const { url } = actionEvent.payload;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
// Safe protocols open directly, unknown protocols require user confirmation
|
||||||
if (!['http:', 'https:'].includes(urlObj.protocol)) {
|
// 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 {
|
return {
|
||||||
status: 'error' as const,
|
status: 'error' as const,
|
||||||
error: {
|
error: {
|
||||||
code: UIActionErrorCode.NAVIGATION_FAILED,
|
code: UIActionErrorCode.NAVIGATION_FAILED,
|
||||||
message: `Blocked potentially unsafe URL protocol: ${urlObj.protocol}`,
|
message: 'User cancelled',
|
||||||
details: { url, protocol: urlObj.protocol },
|
details: { url },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -192,37 +224,17 @@ export default function MCPUIResourceRenderer({
|
|||||||
await window.electron.openExternal(url);
|
await window.electron.openExternal(url);
|
||||||
return {
|
return {
|
||||||
status: 'success' as const,
|
status: 'success' as const,
|
||||||
message: `Opened ${url} in default browser`,
|
message: `Opened ${url} in default application`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof TypeError && error.message.includes('Invalid URL')) {
|
return {
|
||||||
return {
|
status: 'error' as const,
|
||||||
status: 'error' as const,
|
error: {
|
||||||
error: {
|
code: UIActionErrorCode.NAVIGATION_FAILED,
|
||||||
code: UIActionErrorCode.INVALID_PARAMS,
|
message: `Failed to open URL: ${url}`,
|
||||||
message: `Invalid URL format: ${url}`,
|
details: errorMessage(error),
|
||||||
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),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const customOneDarkTheme = {
|
|||||||
|
|
||||||
import { Check, Copy } from './icons';
|
import { Check, Copy } from './icons';
|
||||||
import { wrapHTMLInCodeBlock } from '../utils/htmlSecurity';
|
import { wrapHTMLInCodeBlock } from '../utils/htmlSecurity';
|
||||||
|
import { isProtocolSafe, getProtocol, BLOCKED_PROTOCOLS } from '../utils/urlSecurity';
|
||||||
|
|
||||||
interface CodeProps extends React.ClassAttributes<HTMLElement>, React.HTMLAttributes<HTMLElement> {
|
interface CodeProps extends React.ClassAttributes<HTMLElement>, React.HTMLAttributes<HTMLElement> {
|
||||||
inline?: boolean;
|
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({
|
const MarkdownContent = memo(function MarkdownContent({
|
||||||
content,
|
content,
|
||||||
className = '',
|
className = '',
|
||||||
@@ -179,6 +195,7 @@ const MarkdownContent = memo(function MarkdownContent({
|
|||||||
prose-li:m-0 prose-li:font-sans ${className}`}
|
prose-li:m-0 prose-li:font-sans ${className}`}
|
||||||
>
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
|
urlTransform={customUrlTransform}
|
||||||
remarkPlugins={[remarkGfm, remarkBreaks, [remarkMath, { singleDollarTextMath: false }]]}
|
remarkPlugins={[remarkGfm, remarkBreaks, [remarkMath, { singleDollarTextMath: false }]]}
|
||||||
rehypePlugins={[
|
rehypePlugins={[
|
||||||
[
|
[
|
||||||
@@ -191,7 +208,39 @@ const MarkdownContent = memo(function MarkdownContent({
|
|||||||
],
|
],
|
||||||
]}
|
]}
|
||||||
components={{
|
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,
|
code: MarkdownCode,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { cn } from '../../utils';
|
|||||||
import { DEFAULT_IFRAME_HEIGHT } from './utils';
|
import { DEFAULT_IFRAME_HEIGHT } from './utils';
|
||||||
import { readResource, callTool } from '../../api';
|
import { readResource, callTool } from '../../api';
|
||||||
import { errorMessage } from '../../utils/conversionUtils';
|
import { errorMessage } from '../../utils/conversionUtils';
|
||||||
|
import { isProtocolSafe, getProtocol } from '../../utils/urlSecurity';
|
||||||
|
|
||||||
interface McpAppRendererProps {
|
interface McpAppRendererProps {
|
||||||
resourceUri: string;
|
resourceUri: string;
|
||||||
@@ -119,7 +120,37 @@ export default function McpAppRenderer({
|
|||||||
switch (method) {
|
switch (method) {
|
||||||
case 'ui/open-link': {
|
case 'ui/open-link': {
|
||||||
const { url } = params as McpMethodParams['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 {
|
return {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
message: 'Link opened successfully',
|
message: 'Link opened successfully',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Input } from '../../ui/input';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import { ExternalGoosedConfig } from '../../../utils/settings';
|
import { ExternalGoosedConfig } from '../../../utils/settings';
|
||||||
|
import { WEB_PROTOCOLS } from '../../../utils/urlSecurity';
|
||||||
|
|
||||||
const DEFAULT_CONFIG: ExternalGoosedConfig = {
|
const DEFAULT_CONFIG: ExternalGoosedConfig = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -40,7 +41,7 @@ export default function ExternalBackendSection() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(value);
|
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');
|
setUrlError('URL must use http or https protocol');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-16
@@ -46,6 +46,7 @@ import './utils/recipeHash';
|
|||||||
import { Client, createClient, createConfig } from './api/client';
|
import { Client, createClient, createConfig } from './api/client';
|
||||||
import { GooseApp } from './api';
|
import { GooseApp } from './api';
|
||||||
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
|
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
|
||||||
|
import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity';
|
||||||
|
|
||||||
function shouldSetupUpdater(): boolean {
|
function shouldSetupUpdater(): boolean {
|
||||||
// Setup updater if either the flag is enabled OR dev updates are enabled
|
// 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 }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
// Open all links in external browser
|
try {
|
||||||
if (url.startsWith('http:') || url.startsWith('https:')) {
|
const protocol = new URL(url).protocol;
|
||||||
shell.openExternal(url);
|
if (BLOCKED_PROTOCOLS.includes(protocol)) {
|
||||||
|
return { action: 'deny' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
}
|
}
|
||||||
return { action: 'allow' };
|
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle new-window events (alternative approach for external links)
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
mainWindow.webContents.on('new-window' as any, function (event: any, url: string) {
|
mainWindow.webContents.on('new-window' as any, function (event: any, url: string) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const protocol = new URL(url).protocol;
|
||||||
|
if (BLOCKED_PROTOCOLS.includes(protocol)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1166,15 +1180,15 @@ ipcMain.on('react-ready', (event) => {
|
|||||||
log.info('React ready - window is prepared for deep links');
|
log.info('React ready - window is prepared for deep links');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle external URL opening
|
|
||||||
ipcMain.handle('open-external', async (_event, url: string) => {
|
ipcMain.handle('open-external', async (_event, url: string) => {
|
||||||
try {
|
const parsedUrl = new URL(url);
|
||||||
await shell.openExternal(url);
|
|
||||||
return true;
|
if (BLOCKED_PROTOCOLS.includes(parsedUrl.protocol)) {
|
||||||
} catch (error) {
|
console.warn(`[Main] Blocked dangerous protocol: ${parsedUrl.protocol}`);
|
||||||
console.error('Error opening external URL:', error);
|
return;
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await shell.openExternal(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('directory-chooser', async () => {
|
ipcMain.handle('directory-chooser', async () => {
|
||||||
@@ -2150,8 +2164,8 @@ async function appMain() {
|
|||||||
// Validate URL
|
// Validate URL
|
||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
|
|
||||||
// Only allow http and https protocols
|
// Only allow http and https protocols for fetching web content
|
||||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
if (!WEB_PROTOCOLS.includes(parsedUrl.protocol)) {
|
||||||
throw new Error('Invalid URL protocol. Only HTTP and HTTPS are allowed.');
|
throw new Error('Invalid URL protocol. Only HTTP and HTTPS are allowed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2189,8 +2203,8 @@ async function appMain() {
|
|||||||
// Validate URL
|
// Validate URL
|
||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
|
|
||||||
// Only allow http and https protocols
|
// Only allow http and https protocols for browser URLs
|
||||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
if (!WEB_PROTOCOLS.includes(parsedUrl.protocol)) {
|
||||||
console.error('Invalid URL protocol. Only HTTP and HTTPS are allowed.');
|
console.error('Invalid URL protocol. Only HTTP and HTTPS are allowed.');
|
||||||
return;
|
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