fix(desktop): gate external URL protocols centrally (#11311)

This commit is contained in:
Jasper
2026-08-19 16:54:21 +00:00
committed by GitHub
parent 0ede20cc5b
commit fd9f76d9aa
25 changed files with 332 additions and 645 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { render, type RenderOptions } from '@testing-library/react';
import { fireEvent, render, type RenderOptions } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/dom';
import MarkdownContent from './MarkdownContent';
import { IntlTestWrapper } from '../i18n/test-utils';
@@ -222,6 +222,20 @@ console.log('Hello, World!');
});
});
it('delegates unknown protocol confirmation to the main process once', async () => {
const openExternal = vi.fn().mockResolvedValue('cancelled');
window.electron.openExternal = openExternal;
renderWithIntl(<MarkdownContent content="[Open app](custom-handler:resource)" />);
fireEvent.click(await screen.findByRole('link', { name: 'Open app' }));
await waitFor(() => {
expect(openExternal).toHaveBeenCalledOnce();
expect(openExternal).toHaveBeenCalledWith('custom-handler:resource');
});
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders tables correctly', async () => {
const content = `| Name | Value |
|------|-------|
+46 -90
View File
@@ -28,8 +28,7 @@ const customOneDarkTheme = {
import { Check, Copy } from './icons';
import { wrapHTMLInCodeBlock } from '../utils/htmlSecurity';
import { isProtocolSafe, getProtocol, BLOCKED_PROTOCOLS } from '../utils/urlSecurity';
import { ConfirmationModal } from './ui/ConfirmationModal';
import { BLOCKED_PROTOCOLS } from '../utils/urlSecurity';
import { defineMessages, useIntl } from '../i18n';
const i18n = defineMessages({
@@ -37,26 +36,6 @@ const i18n = defineMessages({
id: 'markdownContent.copyCode',
defaultMessage: 'Copy code',
},
openExternalLink: {
id: 'markdownContent.openExternalLink',
defaultMessage: 'Open External Link',
},
openProtocolLink: {
id: 'markdownContent.openProtocolLink',
defaultMessage: 'Open {protocol} link?',
},
thisWillOpen: {
id: 'markdownContent.thisWillOpen',
defaultMessage: 'This will open: {href}',
},
open: {
id: 'markdownContent.open',
defaultMessage: 'Open',
},
cancel: {
id: 'markdownContent.cancel',
defaultMessage: 'Cancel',
},
failedToOpenLink: {
id: 'markdownContent.failedToOpenLink',
defaultMessage: 'Failed to Open Link',
@@ -204,7 +183,6 @@ const MarkdownContent = memo(function MarkdownContent({
}: MarkdownContentProps) {
const intl = useIntl();
const [processedContent, setProcessedContent] = useState(content);
const [pendingLink, setPendingLink] = useState<{ protocol: string; href: string } | null>(null);
useEffect(() => {
try {
@@ -216,31 +194,26 @@ const MarkdownContent = memo(function MarkdownContent({
}
}, [content]);
const handleConfirmOpen = useCallback(async () => {
if (pendingLink) {
const handleOpenExternal = useCallback(
async (href: string) => {
try {
await window.electron.openExternal(pendingLink.href);
await window.electron.openExternal(href);
} catch {
await window.electron.showMessageBox({
type: 'error',
buttons: ['OK'],
title: intl.formatMessage(i18n.failedToOpenLink),
message: intl.formatMessage(i18n.noApplicationFound),
detail: pendingLink.href,
detail: href,
});
}
}
setPendingLink(null);
}, [pendingLink, intl]);
const handleCancelOpen = useCallback(() => {
setPendingLink(null);
}, []);
},
[intl]
);
return (
<>
<div
className={`w-full overflow-x-hidden prose prose-sm text-text-primary dark:prose-invert max-w-full word-break font-sans
<div
className={`w-full overflow-x-hidden prose prose-sm text-text-primary dark:prose-invert max-w-full word-break font-sans
prose-pre:p-0 prose-pre:m-0 !p-0
prose-code:break-all prose-code:whitespace-pre-wrap prose-code:font-mono
prose-a:break-all prose-a:overflow-wrap-anywhere
@@ -256,60 +229,43 @@ const MarkdownContent = memo(function MarkdownContent({
prose-ol:my-2 prose-ol:font-sans
prose-ul:mt-0 prose-ul:mb-3 prose-ul:font-sans
prose-li:m-0 prose-li:font-sans ${className}`}
>
<ReactMarkdown
urlTransform={customUrlTransform}
remarkPlugins={[remarkGfm, remarkBreaks, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[
[
rehypeKatex,
{
throwOnError: false,
errorColor: '#cc0000',
strict: false,
},
],
]}
components={{
a: (props) => {
return (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
onClick={(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;
setPendingLink({ protocol, href: props.href });
}
}}
/>
);
>
<ReactMarkdown
urlTransform={customUrlTransform}
remarkPlugins={[remarkGfm, remarkBreaks, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[
[
rehypeKatex,
{
throwOnError: false,
errorColor: '#cc0000',
strict: false,
},
code: MarkdownCode,
}}
>
{processedContent}
</ReactMarkdown>
</div>
<ConfirmationModal
isOpen={pendingLink !== null}
title={intl.formatMessage(i18n.openExternalLink)}
message={intl.formatMessage(i18n.openProtocolLink, { protocol: pendingLink?.protocol ?? '' })}
detail={intl.formatMessage(i18n.thisWillOpen, { href: pendingLink?.href ?? '' })}
onConfirm={handleConfirmOpen}
onCancel={handleCancelOpen}
confirmLabel={intl.formatMessage(i18n.open)}
cancelLabel={intl.formatMessage(i18n.cancel)}
/>
</>
],
]}
components={{
a: (props) => {
return (
<a
{...props}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!props.href) return;
void handleOpenExternal(props.href);
}}
/>
);
},
code: MarkdownCode,
}}
>
{processedContent}
</ReactMarkdown>
</div>
);
});
@@ -40,7 +40,6 @@ import { AppEvents } from '../../constants/events';
import { useTheme } from '../../contexts/ThemeContext';
import { cn } from '../../utils';
import { errorMessage } from '../../utils/conversionUtils';
import { getProtocol, isProtocolSafe } from '../../utils/urlSecurity';
import { defineMessages, useIntl } from '../../i18n';
import FlyingBird from '../FlyingBird';
import { formatExtensionName } from '../settings/extensions/subcomponents/ExtensionList';
@@ -99,26 +98,6 @@ const i18n = defineMessages({
id: 'mcpAppRenderer.invalidUrl',
defaultMessage: 'Invalid URL',
},
openExternalLinkTitle: {
id: 'mcpAppRenderer.openExternalLinkTitle',
defaultMessage: 'Open External Link',
},
openProtocolLink: {
id: 'mcpAppRenderer.openProtocolLink',
defaultMessage: 'Open {protocol} link?',
},
openLinkDetail: {
id: 'mcpAppRenderer.openLinkDetail',
defaultMessage: 'This will open: {url}',
},
cancelButton: {
id: 'mcpAppRenderer.cancelButton',
defaultMessage: 'Cancel',
},
openButton: {
id: 'mcpAppRenderer.openButton',
defaultMessage: 'Open',
},
failedToLoadResource: {
id: 'mcpAppRenderer.failedToLoadResource',
defaultMessage: 'Failed to load resource',
@@ -799,31 +778,15 @@ export default function McpAppRenderer({
const handleOpenLink = useCallback(
async ({ url }: { url: string }) => {
if (isProtocolSafe(url)) {
await window.electron.openExternal(url);
const result = await window.electron.openExternal(url);
if (result === 'opened') {
return { status: 'success' as const };
}
const protocol = getProtocol(url);
if (!protocol) {
return { status: 'error' as const, message: intl.formatMessage(i18n.invalidUrl) };
}
const result = await window.electron.showMessageBox({
type: 'question',
buttons: [intl.formatMessage(i18n.cancelButton), intl.formatMessage(i18n.openButton)],
defaultId: 0,
title: intl.formatMessage(i18n.openExternalLinkTitle),
message: intl.formatMessage(i18n.openProtocolLink, { protocol }),
detail: intl.formatMessage(i18n.openLinkDetail, { url }),
});
if (result.response !== 1) {
return { status: 'error' as const, message: 'User cancelled' };
}
await window.electron.openExternal(url);
return { status: 'success' as const };
return {
status: 'error' as const,
message: result === 'cancelled' ? 'User cancelled' : intl.formatMessage(i18n.invalidUrl),
};
},
[intl]
);