Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,122 @@ describe('ToolConfirmationMessage', () => {
unmount();
});

it('should display WarningMessage for deceptive URLs in info type', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'https://täst.com',
urls: ['https://täst.com'],
};

const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);

await waitUntilReady();

const output = lastFrame();
expect(output).toContain('Deceptive URL(s) detected');
expect(output).toContain('Original: https://täst.com');
expect(output).toContain(
'Actual Host (Punycode): https://xn--tst-qla.com/',
);
unmount();
});

it('should display WarningMessage for deceptive URLs in exec type commands', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Execution',
command: 'curl https://еxample.com',
rootCommand: 'curl',
rootCommands: ['curl'],
};

const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);

await waitUntilReady();

const output = lastFrame();
expect(output).toContain('Deceptive URL(s) detected');
expect(output).toContain('Original: https://еxample.com/');
expect(output).toContain(
'Actual Host (Punycode): https://xn--xample-2of.com/',
);
unmount();
});

it('should exclude shell delimiters from extracted URLs in exec type commands', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Execution',
command: 'curl https://еxample.com;ls',
rootCommand: 'curl',
rootCommands: ['curl'],
};

const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);

await waitUntilReady();

const output = lastFrame();
expect(output).toContain('Deceptive URL(s) detected');
// It should extract "https://еxample.com" and NOT "https://еxample.com;ls"
expect(output).toContain('Original: https://еxample.com/');
// The command itself still contains 'ls', so we check specifically that 'ls' is not part of the URL line.
expect(output).not.toContain('Original: https://еxample.com/;ls');
unmount();
});

it('should aggregate multiple deceptive URLs into a single WarningMessage', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'info',
title: 'Confirm Web Fetch',
prompt: 'Fetch both',
urls: ['https://еxample.com', 'https://täst.com'],
};

const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
);

await waitUntilReady();

const output = lastFrame();
expect(output).toContain('Deceptive URL(s) detected');
expect(output).toContain('Original: https://еxample.com/');
expect(output).toContain('Original: https://täst.com/');
unmount();
});

it('should display multiple commands for exec type when provided', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ import {
} from '../../textConstants.js';
import { AskUserDialog } from '../AskUserDialog.js';
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
import { WarningMessage } from './WarningMessage.js';
import {
getDeceptiveUrlDetails,
toUnicodeUrl,
type DeceptiveUrlDetails,
} from '../../utils/urlSecurityUtils.js';

export interface ToolConfirmationMessageProps {
callId: string;
Expand Down Expand Up @@ -99,6 +105,37 @@ export const ToolConfirmationMessage: React.FC<
[handleConfirm],
);

const deceptiveUrlWarnings = useMemo(() => {
const urls: string[] = [];
if (confirmationDetails.type === 'info' && confirmationDetails.urls) {
urls.push(...confirmationDetails.urls);
} else if (confirmationDetails.type === 'exec') {
const commands =
confirmationDetails.commands && confirmationDetails.commands.length > 0
? confirmationDetails.commands
: [confirmationDetails.command];
for (const cmd of commands) {
const matches = cmd.match(/https?:\/\/[^\s"'`<>;&|()]+/g);
if (matches) urls.push(...matches);
}
}

const uniqueUrls = Array.from(new Set(urls));
return uniqueUrls
.map(getDeceptiveUrlDetails)
.filter((d): d is DeceptiveUrlDetails => d !== null);
}, [confirmationDetails]);

const deceptiveUrlWarningText = useMemo(() => {
if (deceptiveUrlWarnings.length === 0) return null;
return `**Warning:** Deceptive URL(s) detected:\n\n${deceptiveUrlWarnings
.map(
(w) =>
` **Original:** ${w.originalUrl}\n **Actual Host (Punycode):** ${w.punycodeUrl}`,
)
.join('\n\n')}`;
}, [deceptiveUrlWarnings]);

const getOptions = useCallback(() => {
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [];

Expand Down Expand Up @@ -259,11 +296,21 @@ export const ToolConfirmationMessage: React.FC<
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}, [availableTerminalHeight, getOptions, handlesOwnUI]);

const { question, bodyContent, options } = useMemo(() => {
const { question, bodyContent, options, securityWarnings } = useMemo<{
question: string;
bodyContent: React.ReactNode;
options: Array<RadioSelectItem<ToolConfirmationOutcome>>;
securityWarnings: React.ReactNode;
}>(() => {
let bodyContent: React.ReactNode | null = null;
let securityWarnings: React.ReactNode | null = null;
let question = '';
const options = getOptions();

if (deceptiveUrlWarningText) {
securityWarnings = <WarningMessage text={deceptiveUrlWarningText} />;
}

if (confirmationDetails.type === 'ask_user') {
bodyContent = (
<AskUserDialog
Expand All @@ -278,7 +325,12 @@ export const ToolConfirmationMessage: React.FC<
availableHeight={availableBodyContentHeight()}
/>
);
return { question: '', bodyContent, options: [] };
return {
question: '',
bodyContent,
options: [],
securityWarnings: null,
};
}

if (confirmationDetails.type === 'exit_plan_mode') {
Expand All @@ -304,7 +356,7 @@ export const ToolConfirmationMessage: React.FC<
availableHeight={availableBodyContentHeight()}
/>
);
return { question: '', bodyContent, options: [] };
return { question: '', bodyContent, options: [], securityWarnings: null };
}

if (confirmationDetails.type === 'edit') {
Expand Down Expand Up @@ -433,10 +485,10 @@ export const ToolConfirmationMessage: React.FC<
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>URLs to fetch:</Text>
{infoProps.urls.map((url) => (
<Text key={url}>
{infoProps.urls.map((urlString) => (
<Text key={urlString}>
{' '}
- <RenderInline text={url} />
- <RenderInline text={toUnicodeUrl(urlString)} />
</Text>
))}
</Box>
Expand All @@ -455,13 +507,14 @@ export const ToolConfirmationMessage: React.FC<
);
}

return { question, bodyContent, options };
return { question, bodyContent, options, securityWarnings };
}, [
confirmationDetails,
getOptions,
availableBodyContentHeight,
terminalWidth,
handleConfirm,
deceptiveUrlWarningText,
]);

if (confirmationDetails.type === 'edit') {
Expand Down Expand Up @@ -505,6 +558,12 @@ export const ToolConfirmationMessage: React.FC<
</MaxSizedBox>
</Box>

{securityWarnings && (
<Box flexShrink={0} marginBottom={1}>
{securityWarnings}
</Box>
)}

<Box marginBottom={1} flexShrink={0}>
<Text color={theme.text.primary}>{question}</Text>
</Box>
Expand Down
65 changes: 65 additions & 0 deletions packages/cli/src/ui/utils/urlSecurityUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import { getDeceptiveUrlDetails, toUnicodeUrl } from './urlSecurityUtils.js';

describe('urlSecurityUtils', () => {
describe('toUnicodeUrl', () => {
it('should convert a Punycode URL string to its Unicode version', () => {
expect(toUnicodeUrl('https://xn--tst-qla.com/')).toBe(
'https://täst.com/',
);
});

it('should convert a URL object to its Unicode version', () => {
const urlObj = new URL('https://xn--tst-qla.com/path');
expect(toUnicodeUrl(urlObj)).toBe('https://täst.com/path');
});

it('should handle complex URLs with credentials and ports', () => {
const complexUrl = 'https://user:pass@xn--tst-qla.com:8080/path?q=1#hash';
expect(toUnicodeUrl(complexUrl)).toBe(
'https://user:pass@täst.com:8080/path?q=1#hash',
);
});

it('should correctly reconstruct the URL even if the hostname appears in the path', () => {
const urlWithHostnameInPath =
'https://xn--tst-qla.com/some/path/xn--tst-qla.com/index.html';
expect(toUnicodeUrl(urlWithHostnameInPath)).toBe(
'https://täst.com/some/path/xn--tst-qla.com/index.html',
);
});

it('should return the original string if URL parsing fails', () => {
expect(toUnicodeUrl('not a url')).toBe('not a url');
});

it('should return the original string for already safe URLs', () => {
expect(toUnicodeUrl('https://google.com/')).toBe('https://google.com/');
});
});

describe('getDeceptiveUrlDetails', () => {
it('should return full details for a deceptive URL', () => {
const details = getDeceptiveUrlDetails('https://еxample.com');
expect(details).not.toBeNull();
expect(details?.originalUrl).toBe('https://еxample.com/');
expect(details?.punycodeUrl).toBe('https://xn--xample-2of.com/');
});

it('should return null for safe URLs', () => {
expect(getDeceptiveUrlDetails('https://google.com')).toBeNull();
});

it('should handle already Punycoded hostnames', () => {
const details = getDeceptiveUrlDetails('https://xn--tst-qla.com');
expect(details).not.toBeNull();
expect(details?.originalUrl).toBe('https://täst.com/');
});
});
});
Loading
Loading