mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
security: implement deceptive URL detection and disclosure in tool confirmations (#19288)
This commit is contained in:
@@ -88,6 +88,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',
|
||||
|
||||
@@ -37,6 +37,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;
|
||||
@@ -102,6 +108,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>> = [];
|
||||
|
||||
@@ -262,11 +299,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
|
||||
@@ -281,7 +328,12 @@ export const ToolConfirmationMessage: React.FC<
|
||||
availableHeight={availableBodyContentHeight()}
|
||||
/>
|
||||
);
|
||||
return { question: '', bodyContent, options: [] };
|
||||
return {
|
||||
question: '',
|
||||
bodyContent,
|
||||
options: [],
|
||||
securityWarnings: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'exit_plan_mode') {
|
||||
@@ -307,7 +359,7 @@ export const ToolConfirmationMessage: React.FC<
|
||||
availableHeight={availableBodyContentHeight()}
|
||||
/>
|
||||
);
|
||||
return { question: '', bodyContent, options: [] };
|
||||
return { question: '', bodyContent, options: [], securityWarnings: null };
|
||||
}
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
@@ -436,10 +488,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>
|
||||
@@ -462,13 +514,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') {
|
||||
@@ -512,6 +565,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>
|
||||
|
||||
Reference in New Issue
Block a user