diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index bef187ea226..904cb0319d1 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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', diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 42642d66f90..d54dd105706 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -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; @@ -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> = []; @@ -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>; + securityWarnings: React.ReactNode; + }>(() => { let bodyContent: React.ReactNode | null = null; + let securityWarnings: React.ReactNode | null = null; let question = ''; const options = getOptions(); + if (deceptiveUrlWarningText) { + securityWarnings = ; + } + if (confirmationDetails.type === 'ask_user') { bodyContent = ( ); - return { question: '', bodyContent, options: [] }; + return { + question: '', + bodyContent, + options: [], + securityWarnings: null, + }; } if (confirmationDetails.type === 'exit_plan_mode') { @@ -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') { @@ -433,10 +485,10 @@ export const ToolConfirmationMessage: React.FC< {displayUrls && infoProps.urls && infoProps.urls.length > 0 && ( URLs to fetch: - {infoProps.urls.map((url) => ( - + {infoProps.urls.map((urlString) => ( + {' '} - - + - ))} @@ -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') { @@ -505,6 +558,12 @@ export const ToolConfirmationMessage: React.FC< + {securityWarnings && ( + + {securityWarnings} + + )} + {question} diff --git a/packages/cli/src/ui/utils/urlSecurityUtils.test.ts b/packages/cli/src/ui/utils/urlSecurityUtils.test.ts new file mode 100644 index 00000000000..3bec00a534a --- /dev/null +++ b/packages/cli/src/ui/utils/urlSecurityUtils.test.ts @@ -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/'); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/urlSecurityUtils.ts b/packages/cli/src/ui/utils/urlSecurityUtils.ts new file mode 100644 index 00000000000..c3a5ca20a26 --- /dev/null +++ b/packages/cli/src/ui/utils/urlSecurityUtils.ts @@ -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; + } +}