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;
}