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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user