From 4a334060d2c113fdb8c4791d615ed059fe5d885e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 03:11:52 -0700 Subject: [PATCH] Validate rejected browser address schemes --- .../browser-pane/BrowserAddressBar.tsx | 117 ++--------------- .../browser-address-bar-suggestions.test.ts | 78 ++++++++++++ .../browser-address-bar-suggestions.ts | 119 ++++++++++++++++++ 3 files changed, 209 insertions(+), 105 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/browser-address-bar-suggestions.test.ts create mode 100644 src/renderer/src/components/browser-pane/browser-address-bar-suggestions.ts diff --git a/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx b/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx index 8d14b8d6bd1..b9cba512eff 100644 --- a/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx +++ b/src/renderer/src/components/browser-pane/BrowserAddressBar.tsx @@ -4,25 +4,8 @@ import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' import { useAppStore } from '@/store' -import { - buildSearchUrl, - looksLikeSearchQuery, - normalizeBrowserNavigationUrl, - SEARCH_ENGINE_LABELS, - DEFAULT_SEARCH_ENGINE, - type SearchEngine -} from '../../../../shared/browser-url' - -const MAX_SUGGESTIONS = 8 - -type SuggestionEntry = { - url: string - title: string - subtitle: string - lastVisitedAt: number - visitCount: number - isSearch: boolean -} +import { DEFAULT_SEARCH_ENGINE, type SearchEngine } from '../../../../shared/browser-url' +import { buildBrowserAddressBarSuggestions } from './browser-address-bar-suggestions' type BrowserAddressBarProps = { value: string @@ -32,28 +15,6 @@ type BrowserAddressBarProps = { inputRef: React.RefObject } -function scoreSuggestion( - entry: { url: string; title: string; lastVisitedAt: number; visitCount: number }, - query: string -): number { - const lowerQuery = query.toLowerCase() - const lowerUrl = entry.url.toLowerCase() - const lowerTitle = entry.title.toLowerCase() - - if (!lowerUrl.includes(lowerQuery) && !lowerTitle.includes(lowerQuery)) { - return -1 - } - - let score = 0 - if (lowerUrl.startsWith(lowerQuery) || lowerUrl.startsWith(`https://${lowerQuery}`)) { - score += 100 - } - score += Math.min(entry.visitCount, 50) - const ageHours = (Date.now() - entry.lastVisitedAt) / (1000 * 60 * 60) - score += Math.max(0, 24 - ageHours) - return score -} - export default function BrowserAddressBar({ value, onChange, @@ -94,70 +55,16 @@ export default function BrowserAddressBar({ const searchEngine: SearchEngine = (browserDefaultSearchEngine as SearchEngine | null) ?? DEFAULT_SEARCH_ENGINE - const suggestions = useMemo((): SuggestionEntry[] => { - const trimmed = value.trim() - if (trimmed === '' || trimmed === 'about:blank' || trimmed.startsWith('data:')) { - if (browserUrlHistory.length === 0) { - return [] - } - return [...browserUrlHistory] - .sort((a, b) => b.lastVisitedAt - a.lastVisitedAt) - .slice(0, MAX_SUGGESTIONS) - .map((entry) => ({ ...entry, subtitle: entry.url, isSearch: false })) - } - - const historySuggestions: SuggestionEntry[] = - browserUrlHistory.length > 0 - ? browserUrlHistory - .map((entry) => ({ entry, score: scoreSuggestion(entry, trimmed) })) - .filter((item) => item.score >= 0) - .sort((a, b) => b.score - a.score) - .slice(0, MAX_SUGGESTIONS - 1) - .map((item) => ({ ...item.entry, subtitle: item.entry.url, isSearch: false })) - : [] - - // Why: the top row of the dropdown must always mirror what pressing Enter - // will do — i.e. what `normalizeBrowserNavigationUrl` resolves to. Chrome - // and Firefox omniboxes work this way: for URL-like inputs the top row is - // the typed URL itself (navigate), and for bare queries it is the search. - // The earlier implementation appended a "Google Search" row as a fallback - // for URL-like inputs, which then got auto-selected when no history - // matched — so Enter on "www.example.com" hit Google instead of the site. - const isQuery = looksLikeSearchQuery(trimmed) - const topAction: SuggestionEntry = isQuery - ? { - url: buildSearchUrl(trimmed, searchEngine, { - kagiSessionLink: browserKagiSessionLink - }), - title: trimmed, - subtitle: `${SEARCH_ENGINE_LABELS[searchEngine]} Search`, - lastVisitedAt: 0, - visitCount: 0, - isSearch: true - } - : { - url: - normalizeBrowserNavigationUrl(trimmed, searchEngine, { - kagiSessionLink: browserKagiSessionLink - }) ?? trimmed, - title: trimmed, - subtitle: '', - lastVisitedAt: 0, - visitCount: 0, - isSearch: false - } - - // Why: if a history row already targets the same URL as the top action, - // skip the synthetic top row — the history row is more informative (real - // page title) and will be auto-selected, so Enter still navigates to the - // same place. - const duplicateIdx = historySuggestions.findIndex((h) => h.url === topAction.url) - if (duplicateIdx >= 0) { - return historySuggestions.slice(0, MAX_SUGGESTIONS) - } - - return [topAction, ...historySuggestions].slice(0, MAX_SUGGESTIONS) - }, [browserUrlHistory, value, searchEngine, browserKagiSessionLink]) + const suggestions = useMemo( + () => + buildBrowserAddressBarSuggestions({ + browserUrlHistory, + kagiSessionLink: browserKagiSessionLink, + searchEngine, + value + }), + [browserUrlHistory, value, searchEngine, browserKagiSessionLink] + ) const selectedValue = selectedValueOverride && diff --git a/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.test.ts b/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.test.ts new file mode 100644 index 00000000000..ffee47fcaea --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import type { BrowserHistoryEntry } from '../../../../shared/types' +import { buildBrowserAddressBarSuggestions } from './browser-address-bar-suggestions' + +function historyEntry(overrides: Partial): BrowserHistoryEntry { + return { + url: 'https://example.com/', + normalizedUrl: 'https://example.com', + title: 'Example', + lastVisitedAt: 1_700_000_000_000, + visitCount: 1, + ...overrides + } +} + +describe('browser address bar suggestions', () => { + it('keeps the most recent history suggestions for blank input', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: '', + browserUrlHistory: [ + historyEntry({ + url: 'https://old.example.com/', + normalizedUrl: 'https://old.example.com', + title: 'Old', + lastVisitedAt: 1 + }), + historyEntry({ + url: 'https://new.example.com/', + normalizedUrl: 'https://new.example.com', + title: 'New', + lastVisitedAt: 2 + }) + ] + }) + + expect(suggestions.map((suggestion) => suggestion.url)).toEqual([ + 'https://new.example.com/', + 'https://old.example.com/' + ]) + }) + + it('puts the search action first for bare query input', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'react hooks', + browserUrlHistory: [], + searchEngine: 'duckduckgo' + }) + + expect(suggestions[0]).toMatchObject({ + url: 'https://duckduckgo.com/?q=react%20hooks', + title: 'react hooks', + subtitle: 'DuckDuckGo Search', + isSearch: true + }) + }) + + it('puts URL-like navigation first when normalization succeeds', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'example.com', + browserUrlHistory: [] + }) + + expect(suggestions[0]).toMatchObject({ + url: 'https://example.com/', + title: 'example.com', + isSearch: false + }) + }) + + it('does not turn a rejected scheme into a selectable navigation row', () => { + const suggestions = buildBrowserAddressBarSuggestions({ + value: 'javascript:alert(1)', + browserUrlHistory: [] + }) + + expect(suggestions).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.ts b/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.ts new file mode 100644 index 00000000000..887ef9fb4dd --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-address-bar-suggestions.ts @@ -0,0 +1,119 @@ +import { + buildSearchUrl, + DEFAULT_SEARCH_ENGINE, + looksLikeSearchQuery, + normalizeBrowserNavigationUrl, + SEARCH_ENGINE_LABELS, + type SearchEngine +} from '../../../../shared/browser-url' +import type { BrowserHistoryEntry } from '../../../../shared/types' + +export const MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS = 8 + +export type BrowserAddressBarSuggestion = { + url: string + title: string + subtitle: string + lastVisitedAt: number + visitCount: number + isSearch: boolean +} + +function scoreBrowserAddressBarSuggestion( + entry: { url: string; title: string; lastVisitedAt: number; visitCount: number }, + query: string +): number { + const lowerQuery = query.toLowerCase() + const lowerUrl = entry.url.toLowerCase() + const lowerTitle = entry.title.toLowerCase() + + if (!lowerUrl.includes(lowerQuery) && !lowerTitle.includes(lowerQuery)) { + return -1 + } + + let score = 0 + if (lowerUrl.startsWith(lowerQuery) || lowerUrl.startsWith(`https://${lowerQuery}`)) { + score += 100 + } + score += Math.min(entry.visitCount, 50) + const ageHours = (Date.now() - entry.lastVisitedAt) / (1000 * 60 * 60) + score += Math.max(0, 24 - ageHours) + return score +} + +export function buildBrowserAddressBarSuggestions({ + browserUrlHistory, + kagiSessionLink, + searchEngine = DEFAULT_SEARCH_ENGINE, + value +}: { + browserUrlHistory: readonly BrowserHistoryEntry[] + kagiSessionLink?: string | null + searchEngine?: SearchEngine + value: string +}): BrowserAddressBarSuggestion[] { + const trimmed = value.trim() + if (trimmed === '' || trimmed === 'about:blank' || trimmed.startsWith('data:')) { + if (browserUrlHistory.length === 0) { + return [] + } + return [...browserUrlHistory] + .sort((a, b) => b.lastVisitedAt - a.lastVisitedAt) + .slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS) + .map((entry) => ({ ...entry, subtitle: entry.url, isSearch: false })) + } + + const historySuggestions: BrowserAddressBarSuggestion[] = + browserUrlHistory.length > 0 + ? browserUrlHistory + .map((entry) => ({ + entry, + score: scoreBrowserAddressBarSuggestion(entry, trimmed) + })) + .filter((item) => item.score >= 0) + .sort((a, b) => b.score - a.score) + .slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS - 1) + .map((item) => ({ ...item.entry, subtitle: item.entry.url, isSearch: false })) + : [] + + const isQuery = looksLikeSearchQuery(trimmed) + let topAction: BrowserAddressBarSuggestion | null + if (isQuery) { + topAction = { + url: buildSearchUrl(trimmed, searchEngine, { kagiSessionLink }), + title: trimmed, + subtitle: `${SEARCH_ENGINE_LABELS[searchEngine]} Search`, + lastVisitedAt: 0, + visitCount: 0, + isSearch: true + } + } else { + const normalizedUrl = normalizeBrowserNavigationUrl(trimmed, searchEngine, { + kagiSessionLink + }) + // Why: rejected schemes must use the submit path's validation error; + // a synthetic row would pass the raw string straight to webview.src. + topAction = normalizedUrl + ? { + url: normalizedUrl, + title: trimmed, + subtitle: '', + lastVisitedAt: 0, + visitCount: 0, + isSearch: false + } + : null + } + + if (!topAction) { + return historySuggestions.slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS) + } + + // Why: the history row gives Enter the same target while showing real page metadata. + const duplicateIdx = historySuggestions.findIndex((h) => h.url === topAction.url) + if (duplicateIdx >= 0) { + return historySuggestions.slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS) + } + + return [topAction, ...historySuggestions].slice(0, MAX_BROWSER_ADDRESS_BAR_SUGGESTIONS) +}