mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -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();
|
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 () => {
|
it('should display multiple commands for exec type when provided', async () => {
|
||||||
const confirmationDetails: SerializableConfirmationDetails = {
|
const confirmationDetails: SerializableConfirmationDetails = {
|
||||||
type: 'exec',
|
type: 'exec',
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ import {
|
|||||||
} from '../../textConstants.js';
|
} from '../../textConstants.js';
|
||||||
import { AskUserDialog } from '../AskUserDialog.js';
|
import { AskUserDialog } from '../AskUserDialog.js';
|
||||||
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
|
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
|
||||||
|
import { WarningMessage } from './WarningMessage.js';
|
||||||
|
import {
|
||||||
|
getDeceptiveUrlDetails,
|
||||||
|
toUnicodeUrl,
|
||||||
|
type DeceptiveUrlDetails,
|
||||||
|
} from '../../utils/urlSecurityUtils.js';
|
||||||
|
|
||||||
export interface ToolConfirmationMessageProps {
|
export interface ToolConfirmationMessageProps {
|
||||||
callId: string;
|
callId: string;
|
||||||
@@ -102,6 +108,37 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
[handleConfirm],
|
[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 getOptions = useCallback(() => {
|
||||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [];
|
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [];
|
||||||
|
|
||||||
@@ -262,11 +299,21 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
|
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
|
||||||
}, [availableTerminalHeight, getOptions, handlesOwnUI]);
|
}, [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 bodyContent: React.ReactNode | null = null;
|
||||||
|
let securityWarnings: React.ReactNode | null = null;
|
||||||
let question = '';
|
let question = '';
|
||||||
const options = getOptions();
|
const options = getOptions();
|
||||||
|
|
||||||
|
if (deceptiveUrlWarningText) {
|
||||||
|
securityWarnings = <WarningMessage text={deceptiveUrlWarningText} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (confirmationDetails.type === 'ask_user') {
|
if (confirmationDetails.type === 'ask_user') {
|
||||||
bodyContent = (
|
bodyContent = (
|
||||||
<AskUserDialog
|
<AskUserDialog
|
||||||
@@ -281,7 +328,12 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
availableHeight={availableBodyContentHeight()}
|
availableHeight={availableBodyContentHeight()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return { question: '', bodyContent, options: [] };
|
return {
|
||||||
|
question: '',
|
||||||
|
bodyContent,
|
||||||
|
options: [],
|
||||||
|
securityWarnings: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirmationDetails.type === 'exit_plan_mode') {
|
if (confirmationDetails.type === 'exit_plan_mode') {
|
||||||
@@ -307,7 +359,7 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
availableHeight={availableBodyContentHeight()}
|
availableHeight={availableBodyContentHeight()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return { question: '', bodyContent, options: [] };
|
return { question: '', bodyContent, options: [], securityWarnings: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (confirmationDetails.type === 'edit') {
|
if (confirmationDetails.type === 'edit') {
|
||||||
@@ -436,10 +488,10 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
|
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection="column" marginTop={1}>
|
||||||
<Text color={theme.text.primary}>URLs to fetch:</Text>
|
<Text color={theme.text.primary}>URLs to fetch:</Text>
|
||||||
{infoProps.urls.map((url) => (
|
{infoProps.urls.map((urlString) => (
|
||||||
<Text key={url}>
|
<Text key={urlString}>
|
||||||
{' '}
|
{' '}
|
||||||
- <RenderInline text={url} />
|
- <RenderInline text={toUnicodeUrl(urlString)} />
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -462,13 +514,14 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { question, bodyContent, options };
|
return { question, bodyContent, options, securityWarnings };
|
||||||
}, [
|
}, [
|
||||||
confirmationDetails,
|
confirmationDetails,
|
||||||
getOptions,
|
getOptions,
|
||||||
availableBodyContentHeight,
|
availableBodyContentHeight,
|
||||||
terminalWidth,
|
terminalWidth,
|
||||||
handleConfirm,
|
handleConfirm,
|
||||||
|
deceptiveUrlWarningText,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (confirmationDetails.type === 'edit') {
|
if (confirmationDetails.type === 'edit') {
|
||||||
@@ -512,6 +565,12 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
</MaxSizedBox>
|
</MaxSizedBox>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{securityWarnings && (
|
||||||
|
<Box flexShrink={0} marginBottom={1}>
|
||||||
|
{securityWarnings}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box marginBottom={1} flexShrink={0}>
|
<Box marginBottom={1} flexShrink={0}>
|
||||||
<Text color={theme.text.primary}>{question}</Text>
|
<Text color={theme.text.primary}>{question}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import url from 'node:url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Details about a deceptive URL.
|
||||||
|
*/
|
||||||
|
export interface DeceptiveUrlDetails {
|
||||||
|
/** The Unicode version of the visually deceptive URL. */
|
||||||
|
originalUrl: string;
|
||||||
|
/** The ASCII-safe Punycode version of the URL. */
|
||||||
|
punycodeUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a hostname contains non-ASCII or Punycode markers.
|
||||||
|
*
|
||||||
|
* @param hostname The hostname to check.
|
||||||
|
* @returns true if deceptive markers are found, false otherwise.
|
||||||
|
*/
|
||||||
|
function containsDeceptiveMarkers(hostname: string): boolean {
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
hostname.toLowerCase().includes('xn--') || /[^\x00-\x7F]/.test(hostname)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a URL (string or object) to its visually deceptive Unicode version.
|
||||||
|
*
|
||||||
|
* This function manually reconstructs the URL to bypass the automatic Punycode
|
||||||
|
* conversion performed by the WHATWG URL class when setting the hostname.
|
||||||
|
*
|
||||||
|
* @param urlInput The URL string or URL object to convert.
|
||||||
|
* @returns The reconstructed URL string with the hostname in Unicode.
|
||||||
|
*/
|
||||||
|
export function toUnicodeUrl(urlInput: string | URL): string {
|
||||||
|
try {
|
||||||
|
const urlObj = typeof urlInput === 'string' ? new URL(urlInput) : urlInput;
|
||||||
|
const punycodeHost = urlObj.hostname;
|
||||||
|
const unicodeHost = url.domainToUnicode(punycodeHost);
|
||||||
|
|
||||||
|
// Reconstruct the URL manually because the WHATWG URL class automatically
|
||||||
|
// Punycodes the hostname if we try to set it.
|
||||||
|
const protocol = urlObj.protocol + '//';
|
||||||
|
const credentials = urlObj.username
|
||||||
|
? `${urlObj.username}${urlObj.password ? ':' + urlObj.password : ''}@`
|
||||||
|
: '';
|
||||||
|
const port = urlObj.port ? ':' + urlObj.port : '';
|
||||||
|
|
||||||
|
return `${protocol}${credentials}${unicodeHost}${port}${urlObj.pathname}${urlObj.search}${urlObj.hash}`;
|
||||||
|
} catch {
|
||||||
|
return typeof urlInput === 'string' ? urlInput : urlInput.href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts deceptive URL details if a URL hostname contains non-ASCII characters
|
||||||
|
* or is already in Punycode.
|
||||||
|
*
|
||||||
|
* @param urlString The URL string to check.
|
||||||
|
* @returns DeceptiveUrlDetails if a potential deceptive URL is detected, otherwise null.
|
||||||
|
*/
|
||||||
|
export function getDeceptiveUrlDetails(
|
||||||
|
urlString: string,
|
||||||
|
): DeceptiveUrlDetails | null {
|
||||||
|
try {
|
||||||
|
if (!urlString.includes('://')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlObj = new URL(urlString);
|
||||||
|
|
||||||
|
if (!containsDeceptiveMarkers(urlObj.hostname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
originalUrl: toUnicodeUrl(urlObj),
|
||||||
|
punycodeUrl: urlObj.href,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, it's not a valid URL we can safely analyze.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user