From a00736e8fc2c371babec4994a5102af65e174e56 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:34:09 -0700 Subject: [PATCH] Prevent duplicate Kagi session tokens and redact load error credentials Canonicalize navigation URLs to strip duplicate token parameters before queueing. Redact Kagi session tokens from loadError.validatedUrl in tab listings to prevent credential leakage in error displays. --- .../agent-browser-bridge-tab-routing.test.ts | 22 +++++++++++++++++++ src/main/browser/agent-browser-bridge.ts | 4 +++- .../kagi-private-initial-navigation.test.ts | 14 ++++++++++++ .../lib/kagi-private-initial-navigation.ts | 20 +++++++++++++++-- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/main/browser/agent-browser-bridge-tab-routing.test.ts b/src/main/browser/agent-browser-bridge-tab-routing.test.ts index 1aa34227d60..60c115e90c5 100644 --- a/src/main/browser/agent-browser-bridge-tab-routing.test.ts +++ b/src/main/browser/agent-browser-bridge-tab-routing.test.ts @@ -192,6 +192,28 @@ describe('AgentBrowserBridge', () => { expect(JSON.stringify(result)).not.toContain('session-secret') }) + it('redacts Kagi session credentials from a listed load error', () => { + const tabs = new Map([['tab-a', 1]]) + const wc = mockWebContents(1, 'chrome-error://chromewebdata/', '') + webContentsFromIdMock.mockReturnValue(wc) + const loadError = { + code: -105, + description: 'Name not resolved', + validatedUrl: 'https://kagi.com/search?token=session-secret&q=private+project' + } + + const result = new AgentBrowserBridge( + mockBrowserManager(tabs, new Map(), { + getBrowserPageLoadError: vi.fn((tabId: string) => (tabId === 'tab-a' ? loadError : null)) + }) + ).tabList() + + expect(result.tabs[0]?.loadError?.validatedUrl).toBe( + 'https://kagi.com/search?q=private+project' + ) + expect(JSON.stringify(result)).not.toContain('session-secret') + }) + it('does not mutate active-tab routing when tab-list infers the first live tab', () => { const tabs = new Map([ ['tab-a', 1], diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 1ed7dc2b5e4..cbd689243a5 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -751,7 +751,9 @@ export class AgentBrowserBridge { url: redactKagiSessionToken(loadError?.validatedUrl ?? wc.getURL() ?? ''), title: redactKagiSessionToken(wc.getTitle() ?? ''), active: wcId === activeWcId, - loadError, + loadError: loadError + ? { ...loadError, validatedUrl: redactKagiSessionToken(loadError.validatedUrl) } + : loadError, certificateFailure }) } diff --git a/src/renderer/src/lib/kagi-private-initial-navigation.test.ts b/src/renderer/src/lib/kagi-private-initial-navigation.test.ts index 4b0d942c79a..89eda35f059 100644 --- a/src/renderer/src/lib/kagi-private-initial-navigation.test.ts +++ b/src/renderer/src/lib/kagi-private-initial-navigation.test.ts @@ -88,6 +88,20 @@ describe('Kagi private initial navigation', () => { }) }) + it('queues a single bearer token when the link carries duplicates', () => { + const modelUrl = 'https://kagi.com/search?q=private+project' + queueKagiPrivateInitialNavigation( + 'page-duplicate', + 'https://kagi.com/search?token=first-secret&q=private+project&token=second-secret' + ) + + expect(getKagiPrivateInitialNavigation('page-duplicate', modelUrl)).toEqual({ + modelUrl, + navigationUrl: 'https://kagi.com/search?token=first-secret&q=private+project' + }) + discardKagiPrivateInitialNavigation('page-duplicate') + }) + it('redacts a defensive model fallback', () => { const privateUrl = 'https://kagi.com/search?token=session-secret&q=private+project' diff --git a/src/renderer/src/lib/kagi-private-initial-navigation.ts b/src/renderer/src/lib/kagi-private-initial-navigation.ts index 172454afb57..a6195264d54 100644 --- a/src/renderer/src/lib/kagi-private-initial-navigation.ts +++ b/src/renderer/src/lib/kagi-private-initial-navigation.ts @@ -25,13 +25,29 @@ function prunePendingNavigations(now: number): void { } } +// Why: queue the validator's canonical link so duplicate `token` params can't send a second bearer to Kagi. +function canonicalizeKagiNavigationUrl(url: string): string | null { + const normalized = normalizeKagiSessionLink(url) + if (!normalized) { + return null + } + const query = new URL(url).searchParams.get('q') + if (query === null) { + return normalized + } + const canonical = new URL(normalized) + canonical.searchParams.set('q', query) + return canonical.toString() +} + export function queueKagiPrivateInitialNavigation(pageId: string, url: string): void { - if (!normalizeKagiSessionLink(url)) { + const canonicalUrl = canonicalizeKagiNavigationUrl(url) + if (!canonicalUrl) { throw new Error('Expected a Kagi private-session URL.') } const now = Date.now() pendingNavigations.delete(pageId) - pendingNavigations.set(pageId, { queuedAt: now, url }) + pendingNavigations.set(pageId, { queuedAt: now, url: canonicalUrl }) prunePendingNavigations(now) }