From c03c01ad15e37e5146d985b3567e50f71c09b796 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:05:00 -0700 Subject: [PATCH] fix(browser): stop impersonating the source browser's UA on cookie import (STA-3514) (#12849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(browser): stop impersonating the source browser's UA on cookie import (STA-3514) Delete the import-time UA synthesis entirely instead of patching its version detection. The session now always keeps the engine-derived UA the registry sets at startup (clean or native), for imported and non-imported profiles alike. Why deletion is the fix: - The synthesis read CFBundleShortVersionString, which on forks is a marketing version — Arc imports presented Chrome/1.x and sites rejected the browser as ancient (STA-3514). - Its stated purpose (keep Google-bound cookies valid) is handled by isGoogleSourceBoundCookie excluding those cookies since #736, and #12884 measured that Google rejects re-transplants regardless of UA identity. - #12608 measured the synthesized Chrome-shaped UA reaching /v3/signin/rejected while the engine UA reached account lookup. Persisted userAgent/userAgentByPartition meta is no longer read; legacy keys drop off on the next meta write. The #12811 gate existed only to catch bad synthesized values, so it leaves with the synthesis. * test(browser): catch source UA impersonation regression --- .../browser-cookie-import-replacement.test.ts | 3 +- .../browser-cookie-import.arc-ua.test.ts | 81 ------------ .../browser-cookie-import.comet.test.ts | 62 --------- .../browser-cookie-import.helium.test.ts | 55 -------- .../browser/browser-cookie-import.test.ts | 18 ++- src/main/browser/browser-cookie-import.ts | 85 +----------- ...owser-session-registry.persistence.test.ts | 123 +++++++++--------- src/main/browser/browser-session-registry.ts | 70 ++-------- src/main/browser/browser-session-ua.ts | 30 +---- 9 files changed, 94 insertions(+), 433 deletions(-) delete mode 100644 src/main/browser/browser-cookie-import.arc-ua.test.ts diff --git a/src/main/browser/browser-cookie-import-replacement.test.ts b/src/main/browser/browser-cookie-import-replacement.test.ts index c3e330f5a..74c2ec432 100644 --- a/src/main/browser/browser-cookie-import-replacement.test.ts +++ b/src/main/browser/browser-cookie-import-replacement.test.ts @@ -17,8 +17,7 @@ const { vi.mock('./browser-session-registry', () => ({ browserSessionRegistry: { setPendingCookieImport: setPendingCookieImportMock, - clearPendingCookieImport: clearPendingCookieImportMock, - persistUserAgent: vi.fn() + clearPendingCookieImport: clearPendingCookieImportMock } })) vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) diff --git a/src/main/browser/browser-cookie-import.arc-ua.test.ts b/src/main/browser/browser-cookie-import.arc-ua.test.ts deleted file mode 100644 index 6a7c7ff06..000000000 --- a/src/main/browser/browser-cookie-import.arc-ua.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import type * as childProcessModule from 'node:child_process' - -describe('isAdvertisableChromiumEngineVersion', () => { - it('accepts real Chromium engine majors and rejects product versions', async () => { - const { isAdvertisableChromiumEngineVersion } = await import('./browser-session-ua') - expect(isAdvertisableChromiumEngineVersion('120.0.6099.71')).toBe(true) - expect(isAdvertisableChromiumEngineVersion('70.0.0.0')).toBe(true) - expect(isAdvertisableChromiumEngineVersion('1.158.1')).toBe(false) - expect(isAdvertisableChromiumEngineVersion('1.0.0')).toBe(false) - expect(isAdvertisableChromiumEngineVersion('not-a-version')).toBe(false) - // Malformed components with a valid major must not pass (would become Chrome/70.not-a-version). - expect(isAdvertisableChromiumEngineVersion('70.not-a-version')).toBe(false) - expect(isAdvertisableChromiumEngineVersion('120.0.invalid.1')).toBe(false) - }) -}) - -describe('isUnadvertisableChromeUserAgent', () => { - it('flags stored Chrome/1.x UAs and leaves engine-scale ones alone', async () => { - const { isUnadvertisableChromeUserAgent } = await import('./browser-session-ua') - const ua = (version: string): string => - `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version} Safari/537.36` - expect(isUnadvertisableChromeUserAgent(ua('1.158.1'))).toBe(true) - expect(isUnadvertisableChromeUserAgent(ua('150.0.7871.47'))).toBe(false) - expect(isUnadvertisableChromeUserAgent(`${ua('151.0.0.0')} Edg/151.0.0.0`)).toBe(false) - // Why: non-Chrome UAs (Firefox/Safari imports) carry no engine claim to invalidate. - expect(isUnadvertisableChromeUserAgent('Mozilla/5.0 (Macintosh) Firefox/126.0')).toBe(false) - }) -}) - -describe('getUserAgentForBrowser — Arc product version', () => { - const originalPlatform = process.platform - - beforeEach(() => { - vi.resetModules() - Object.defineProperty(process, 'platform', { value: 'darwin' }) - }) - - afterEach(() => { - Object.defineProperty(process, 'platform', { value: originalPlatform }) - vi.restoreAllMocks() - }) - - it('does not persist Chrome/1.x when Arc reports its product version', async () => { - vi.doMock('node:child_process', async () => { - const actual = await vi.importActual('node:child_process') - return { - ...actual, - execFileSync: (cmd: string, args: readonly string[]) => { - if (cmd === 'defaults' && args[1]?.includes('/Applications/Arc.app/Contents/Info')) { - return '1.158.1\n' - } - return actual.execFileSync(cmd, args as never) - } - } - }) - - const { getUserAgentForBrowser } = await import('./browser-cookie-import') - expect(getUserAgentForBrowser('arc')).toBeNull() - }) - - it('still builds a Chrome-shaped UA when Arc reports an engine-scale version', async () => { - vi.doMock('node:child_process', async () => { - const actual = await vi.importActual('node:child_process') - return { - ...actual, - execFileSync: (cmd: string, args: readonly string[]) => { - if (cmd === 'defaults' && args[1]?.includes('/Applications/Arc.app/Contents/Info')) { - return '120.0.6099.71\n' - } - return actual.execFileSync(cmd, args as never) - } - } - }) - - const { getUserAgentForBrowser } = await import('./browser-cookie-import') - const ua = getUserAgentForBrowser('arc') - expect(ua).toContain('Chrome/120.0.6099.71') - expect(ua).not.toContain('Chrome/1.') - }) -}) diff --git a/src/main/browser/browser-cookie-import.comet.test.ts b/src/main/browser/browser-cookie-import.comet.test.ts index 47e84db16..f673990e5 100644 --- a/src/main/browser/browser-cookie-import.comet.test.ts +++ b/src/main/browser/browser-cookie-import.comet.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as childProcessModule from 'node:child_process' import type * as fsModule from 'node:fs' const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({ @@ -223,67 +222,6 @@ describe('detectInstalledBrowsers — Comet', () => { }) }) -describe('getUserAgentForBrowser — Comet', () => { - const originalPlatform = process.platform - - beforeEach(() => { - vi.resetModules() - Object.defineProperty(process, 'platform', { value: 'darwin' }) - }) - - afterEach(() => { - Object.defineProperty(process, 'platform', { value: originalPlatform }) - vi.restoreAllMocks() - }) - - it('returns a Chrome-shaped UA string when Comet plist version reads successfully', async () => { - vi.doMock('node:child_process', async () => { - const actual = await vi.importActual('node:child_process') - return { - ...actual, - execFileSync: (cmd: string, args: readonly string[]) => { - if (cmd === 'defaults' && args[1]?.includes('/Applications/Comet.app/Contents/Info')) { - return '120.0.6099.71\n' - } - return actual.execFileSync(cmd, args as never) - } - } - }) - - const { getUserAgentForBrowser } = await import('./browser-cookie-import') - const ua = getUserAgentForBrowser('comet') - - expect(ua).not.toBeNull() - expect(ua).toContain('Macintosh; Intel Mac OS X 10_15_7') - expect(ua).toContain('AppleWebKit/537.36') - expect(ua).toContain('Chrome/120.0.6099.71') - expect(ua).toContain('Safari/537.36') - }) - - it('returns null when reading the Comet plist version throws', async () => { - vi.doMock('node:child_process', async () => { - const actual = await vi.importActual('node:child_process') - return { - ...actual, - execFileSync: () => { - throw new Error('defaults: domain not found') - } - } - }) - - const { getUserAgentForBrowser } = await import('./browser-cookie-import') - const ua = getUserAgentForBrowser('comet') - expect(ua).toBeNull() - }) - - it('returns null on non-darwin platforms regardless of family', async () => { - Object.defineProperty(process, 'platform', { value: 'linux' }) - const { getUserAgentForBrowser } = await import('./browser-cookie-import') - const ua = getUserAgentForBrowser('comet') - expect(ua).toBeNull() - }) -}) - describe('BROWSER_FAMILY_LABELS — Comet', () => { it('maps the comet family key to the user-facing label "Comet"', () => { expect(BROWSER_FAMILY_LABELS.comet).toBe('Comet') diff --git a/src/main/browser/browser-cookie-import.helium.test.ts b/src/main/browser/browser-cookie-import.helium.test.ts index 1c551ac00..a53b0f32f 100644 --- a/src/main/browser/browser-cookie-import.helium.test.ts +++ b/src/main/browser/browser-cookie-import.helium.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as childProcessModule from 'node:child_process' import type * as fsModule from 'node:fs' const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({ @@ -158,60 +157,6 @@ describe('detectInstalledBrowsers — Helium', () => { }) }) -describe('getUserAgentForBrowser — Helium', () => { - const originalPlatform = process.platform - - beforeEach(() => { - vi.resetModules() - Object.defineProperty(process, 'platform', { value: 'darwin' }) - }) - - afterEach(() => { - Object.defineProperty(process, 'platform', { value: originalPlatform }) - vi.restoreAllMocks() - }) - - it('returns a Chrome-shaped UA string when Helium plist version reads successfully', async () => { - vi.doMock('node:child_process', async () => { - const actual = await vi.importActual('node:child_process') - return { - ...actual, - execFileSync: (cmd: string, args: readonly string[]) => { - if (cmd === 'defaults' && args[1]?.includes('/Applications/Helium.app/Contents/Info')) { - return '120.0.6099.71\n' - } - return actual.execFileSync(cmd, args as never) - } - } - }) - - const { getUserAgentForBrowser } = await import('./browser-cookie-import') - const ua = getUserAgentForBrowser('helium') - - expect(ua).not.toBeNull() - expect(ua).toContain('Macintosh; Intel Mac OS X 10_15_7') - expect(ua).toContain('AppleWebKit/537.36') - expect(ua).toContain('Chrome/120.0.6099.71') - expect(ua).toContain('Safari/537.36') - }) - - it('returns null when reading the Helium plist version throws', async () => { - vi.doMock('node:child_process', async () => { - const actual = await vi.importActual('node:child_process') - return { - ...actual, - execFileSync: () => { - throw new Error('defaults: domain not found') - } - } - }) - - const { getUserAgentForBrowser } = await import('./browser-cookie-import') - const ua = getUserAgentForBrowser('helium') - expect(ua).toBeNull() - }) -}) - describe('BROWSER_FAMILY_LABELS — Helium', () => { it('maps the helium family key to the user-facing label "Helium"', () => { expect(BROWSER_FAMILY_LABELS.helium).toBe('Helium') diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index d657b8ec5..aefdd27fb 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -22,8 +22,7 @@ const { vi.mock('./browser-session-registry', () => ({ browserSessionRegistry: { setPendingCookieImport: setPendingCookieImportMock, - clearPendingCookieImport: clearPendingCookieImportMock, - persistUserAgent: vi.fn() + clearPendingCookieImport: clearPendingCookieImportMock } })) @@ -425,6 +424,7 @@ describe('importCookiesFromBrowser Chromium', () => { let cookiesRemoveMock: ReturnType let cookiesFlushStoreMock: ReturnType let clearStorageDataMock: ReturnType + let setUserAgentMock: ReturnType beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'orca-chromium-cookie-test-')) @@ -432,6 +432,7 @@ describe('importCookiesFromBrowser Chromium', () => { cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) cookiesFlushStoreMock = vi.fn().mockResolvedValue(undefined) clearStorageDataMock = vi.fn().mockResolvedValue(undefined) + setUserAgentMock = vi.fn() appGetPathMock.mockReset() appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) copyFileSyncMock.mockClear() @@ -448,7 +449,8 @@ describe('importCookiesFromBrowser Chromium', () => { remove: cookiesRemoveMock, flushStore: cookiesFlushStoreMock }, - clearStorageData: clearStorageDataMock + clearStorageData: clearStorageDataMock, + setUserAgent: setUserAgentMock }) }) @@ -471,6 +473,12 @@ describe('importCookiesFromBrowser Chromium', () => { ]).close() const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + execFileSyncMock.mockImplementation((command: string) => { + if (command === 'defaults') { + return '120.0.6099.71\n' + } + throw new Error(`Unexpected command: ${command}`) + }) try { expect(existsSync(`${sourceCookiesPath}-wal`)).toBe(true) const sourceFilesBefore = ['', '-wal', '-shm'].map((suffix) => @@ -491,6 +499,7 @@ describe('importCookiesFromBrowser Chromium', () => { }) ) expect(execFileSyncMock.mock.calls.some(([command]) => command === 'security')).toBe(false) + expect(execFileSyncMock.mock.calls.some(([command]) => command === 'defaults')).toBe(false) expect(copyFileSyncMock.mock.calls.some(([source]) => source === sourceCookiesPath)).toBe( true ) @@ -502,6 +511,9 @@ describe('importCookiesFromBrowser Chromium', () => { ).toEqual(sourceFilesBefore) expect(cookiesRemoveMock).not.toHaveBeenCalled() expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] }) + // Why: STA-3514 — imports must never impersonate the source browser; the + // session keeps the engine UA the registry set at startup. + expect(setUserAgentMock).not.toHaveBeenCalled() } finally { platformSpy.mockRestore() sourceDb.close() diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 65ea11c1e..66a3afdb3 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -74,8 +74,6 @@ import type { BrowserSessionProfileSource } from '../../shared/types' import { browserSessionRegistry } from './browser-session-registry' -import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' -import { isAdvertisableChromiumEngineVersion, setupClientHintsOverride } from './browser-session-ua' import { isGoogleSourceBoundCookie, normalizeCookieDomain, @@ -772,74 +770,6 @@ export async function importCookiesFromFile( ) } -// --------------------------------------------------------------------------- -// Direct import from installed Chromium browser -// --------------------------------------------------------------------------- - -// Why: services bind auth cookies to the creating User-Agent, so build a UA matching the source browser's real version. -export function getUserAgentForBrowser( - family: BrowserSessionProfileSource['browserFamily'] -): string | null { - // Why: UA version comes from macOS-only plist reading; elsewhere the default Electron UA is acceptable. - if (process.platform !== 'darwin') { - return null - } - - const platform = 'Macintosh; Intel Mac OS X 10_15_7' - const chromeBase = 'AppleWebKit/537.36 (KHTML, like Gecko)' - - function readBrowserVersion( - appPath: string, - plistKey = 'CFBundleShortVersionString' - ): string | null { - try { - return ( - execFileSync('defaults', ['read', `${appPath}/Contents/Info`, plistKey], { - encoding: 'utf-8', - timeout: 5_000 - }).trim() || null - ) - } catch { - return null - } - } - - function chromeShapedUa(version: string | null, edgeSuffix = false): string | null { - if (!version || !isAdvertisableChromiumEngineVersion(version)) { - return null - } - const base = `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${version} Safari/537.36` - return edgeSuffix ? `${base} Edg/${version}` : base - } - - switch (family) { - case 'chrome': { - return chromeShapedUa(readBrowserVersion('/Applications/Google Chrome.app')) - } - case 'edge': { - return chromeShapedUa(readBrowserVersion('/Applications/Microsoft Edge.app'), true) - } - case 'arc': { - return chromeShapedUa(readBrowserVersion('/Applications/Arc.app')) - } - case 'chromium': { - return chromeShapedUa(readBrowserVersion('/Applications/Brave Browser.app')) - } - case 'comet': { - // Why: Comet is Chromium-based; use Chrome's UA shape so Google-bound auth cookies survive import. - return chromeShapedUa(readBrowserVersion('/Applications/Comet.app')) - } - case 'helium': { - // Why: Helium is Chromium-based; use Chrome's UA shape so Google-bound auth cookies survive import. - return chromeShapedUa(readBrowserVersion('/Applications/Helium.app')) - } - case 'firefox': - case 'safari': - case 'manual': - return null - } -} - const PBKDF2_ITERATIONS = 1003 const PBKDF2_KEY_LENGTH = 16 const PBKDF2_SALT = 'saltysalt' @@ -1876,15 +1806,12 @@ export async function importCookiesFromBrowser( diag(` all cookies loaded in-memory — no restart needed`) } - const ua = getUserAgentForBrowser(browser.family) - if (ua) { - targetSession.setUserAgent(ua) - setupClientHintsOverride(targetSession, ua, { - googleAuthOverride: getBrowserSessionUserAgentMode(targetSession) !== 'native' - }) - browserSessionRegistry.persistUserAgent(targetPartition, ua) - diag(` set UA for partition: ${ua.substring(0, 80)}...`) - } + // Why: the session keeps the UA the registry set at startup (clean or native). + // Imports must not impersonate the source browser — the synthesized UA read a + // fork's marketing version as a Chromium version (STA-3514), and Google binds + // sessions to the re-import, not the UA (#12884), so it bought nothing. + // Google-bound integrity cookies are already excluded by + // isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch. const summary: BrowserCookieImportSummary = { totalCookies: sourceRows.length, diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 0d84a47fd..f5f9bb7a7 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as browserSessionUaModule from './browser-session-ua' const USER_DATA = '/user-data' const META_PATH = `${USER_DATA}/browser-session-meta.json` @@ -119,15 +118,10 @@ function installModuleMocks( hasSystemMediaAccess: vi.fn(() => true), requestSystemMediaAccess: requestSystemMediaAccessMock })) - vi.doMock('./browser-session-ua', async () => { - // Why: the version gate is the behavior under test, so use the real predicate here. - const actual = await vi.importActual('./browser-session-ua') - return { - cleanElectronUserAgent: vi.fn((ua: string) => ua.replace(/\s*Electron\/\S+/, '')), - isUnadvertisableChromeUserAgent: actual.isUnadvertisableChromeUserAgent, - setupClientHintsOverride: setupClientHintsOverrideMock - } - }) + vi.doMock('./browser-session-ua', () => ({ + cleanElectronUserAgent: vi.fn((ua: string) => ua.replace(/\s*Electron\/\S+/, '')), + setupClientHintsOverride: setupClientHintsOverrideMock + })) return { sessionFromPartitionMock, @@ -355,18 +349,63 @@ describe('BrowserSessionRegistry persistence', () => { expect(fsState.present.has('/staged/default')).toBe(true) }) - it('restores a persisted source UA even for native-mode profiles', async () => { + // Why: imports before Aug 2026 persisted a synthesized source-browser UA + // (fork imports as a broken Chrome/1.x, Chrome imports as a valid version). + // Neither may ever be applied again — the engine-derived UA is the only one. + it('ignores legacy persisted UAs, valid or broken, and applies the engine UA', async () => { + const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111' + const brokenUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1.158.1 Safari/537.36' + const validUa = 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: { browserFamily: 'arc', importedAt: 1 }, + userAgent: brokenUa, + userAgentByPartition: { + 'persist:orca-browser': brokenUa, + [importedPartition]: validUa + }, + pendingCookieDbPath: null, + pendingCookieImports: {}, + profiles: [ + { + id: '11111111-1111-4111-8111-111111111111', + scope: 'imported', + partition: importedPartition, + label: 'Imported', + source: { browserFamily: 'chrome', importedAt: 1 } + } + ] + }) + + const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.initializeBrowserSessionsFromPersistedState() + + const appliedUas = sessionFromPartitionMock.mock.results.flatMap((r) => + r.value.setUserAgent.mock.calls.map((c: unknown[]) => c[0]) + ) + expect(appliedUas).not.toContain(brokenUa) + expect(appliedUas).not.toContain(validUa) + // Why: every non-native profile falls to Orca's own cleaned engine UA. + expect(appliedUas.length).toBeGreaterThan(0) + expect(appliedUas.every((ua) => ua === 'Mozilla/5.0 Orca')).toBe(true) + expect( + setupClientHintsOverrideMock.mock.calls.every( + (c: unknown[]) => c[1] !== brokenUa && c[1] !== validUa + ) + ).toBe(true) + }) + + it('never applies a legacy persisted UA to a native-mode profile', async () => { const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111' const importedUa = 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36' - const defaultUa = 'Mozilla/5.0 Chrome/119.0.0.0 Safari/537.36' const fsState = createFsState() seedMeta(fsState, { defaultSource: null, - userAgent: defaultUa, - userAgentByPartition: { - 'persist:orca-browser': defaultUa, - [importedPartition]: importedUa - }, + userAgent: null, + userAgentByPartition: { [importedPartition]: importedUa }, pendingCookieDbPath: null, pendingCookieImports: {}, profiles: [ @@ -381,7 +420,7 @@ describe('BrowserSessionRegistry persistence', () => { ] }) - const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState) + const { sessionFromPartitionMock } = installModuleMocks(fsState) const { browserSessionRegistry } = await import('./browser-session-registry') browserSessionRegistry.initializeBrowserSessionsFromPersistedState() @@ -390,19 +429,8 @@ describe('BrowserSessionRegistry persistence', () => { .filter((_, idx) => sessionFromPartitionMock.mock.calls[idx]?.[0] === importedPartition) .map((r) => r.value) expect(importedSessions.length).toBeGreaterThan(0) - expect( - importedSessions.some((s) => - s.setUserAgent.mock.calls.some((c: unknown[]) => c[0] === importedUa) - ) - ).toBe(true) - expect( - setupClientHintsOverrideMock.mock.calls.some( - (c: unknown[]) => - (c[0] as { partition?: string } | undefined)?.partition === importedPartition && - c[1] === importedUa && - (c[2] as { googleAuthOverride?: boolean } | undefined)?.googleAuthOverride === false - ) - ).toBe(true) + // Why: native mode means the engine UA stands untouched — no setUserAgent at all. + expect(importedSessions.every((s) => s.setUserAgent.mock.calls.length === 0)).toBe(true) const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') expect( importedSessions.every( @@ -411,39 +439,6 @@ describe('BrowserSessionRegistry persistence', () => { ).toBe(true) }) - it('drops a persisted fork product-version UA instead of replaying Chrome/1.x', async () => { - const brokenUa = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1.158.1 Safari/537.36' - const fsState = createFsState() - seedMeta(fsState, { - defaultSource: { browserFamily: 'arc', importedAt: 1 }, - userAgent: brokenUa, - userAgentByPartition: { 'persist:orca-browser': brokenUa }, - pendingCookieDbPath: null, - pendingCookieImports: {}, - profiles: [] - }) - - const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState) - const { browserSessionRegistry } = await import('./browser-session-registry') - - browserSessionRegistry.initializeBrowserSessionsFromPersistedState() - - const appliedUas = sessionFromPartitionMock.mock.results.flatMap((r) => - r.value.setUserAgent.mock.calls.map((c: unknown[]) => c[0]) - ) - expect(appliedUas).not.toContain(brokenUa) - // Why: with the broken UA gone the profile must fall back to Orca's own cleaned engine UA. - expect(appliedUas).toContain('Mozilla/5.0 Orca') - expect(setupClientHintsOverrideMock.mock.calls.some((c: unknown[]) => c[1] === brokenUa)).toBe( - false - ) - - const persisted = JSON.parse(fsState.files.get(META_PATH) ?? '{}') - expect(persisted.userAgentByPartition).toEqual({}) - expect(persisted.userAgent).toBeNull() - }) - it('preserves native mode across hydration when no source UA was imported', async () => { const importedPartition = 'persist:orca-browser-session-12121212-1212-4121-8121-121212121212' const fsState = createFsState() diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 175c81ffa..b0b477a8e 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -25,11 +25,7 @@ import type { } from '../../shared/types' import { browserManager } from './browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' -import { - cleanElectronUserAgent, - isUnadvertisableChromeUserAgent, - setupClientHintsOverride -} from './browser-session-ua' +import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' import { clearBrowserSessionUserAgentMode, setBrowserSessionUserAgentMode @@ -42,10 +38,12 @@ import { installBrowserWebAuthnAccessHandlers } from './browser-webauthn-access' +// Why: no userAgent fields — the session UA is always derived from the running +// engine at startup (clean or native), never persisted. Imports before Aug 2026 +// stored a synthesized source-browser UA here; persistMeta drops those legacy +// keys on the next write because this loader no longer carries them. type BrowserSessionMeta = { defaultSource: BrowserSessionProfile['source'] - userAgent: string | null - userAgentByPartition: Record pendingCookieDbPath: string | null pendingCookieImports: Record profiles: BrowserSessionProfile[] @@ -122,11 +120,8 @@ class BrowserSessionRegistry { } } - private persistSource(source: BrowserSessionProfile['source'], userAgent?: string | null): void { - this.persistMeta({ - defaultSource: source, - ...(userAgent !== undefined ? { userAgent } : {}) - }) + private persistSource(source: BrowserSessionProfile['source']): void { + this.persistMeta({ defaultSource: source }) } // Why: non-default profiles are in-memory only; without this they vanish on restart. @@ -139,15 +134,6 @@ class BrowserSessionRegistry { try { const raw = readFileSync(this.metadataPath, 'utf-8') const data = JSON.parse(raw) - const legacyUserAgent = typeof data?.userAgent === 'string' ? data.userAgent : null - const userAgentByPartition: Record = - data && typeof data.userAgentByPartition === 'object' && data.userAgentByPartition - ? { ...data.userAgentByPartition } - : {} - if (legacyUserAgent && !userAgentByPartition[this.defaultPartition]) { - userAgentByPartition[this.defaultPartition] = legacyUserAgent - } - const legacyPendingCookieDbPath = typeof data?.pendingCookieDbPath === 'string' ? data.pendingCookieDbPath : null const pendingCookieImports: Record = @@ -159,8 +145,6 @@ class BrowserSessionRegistry { } return { defaultSource: data?.defaultSource ?? null, - userAgent: legacyUserAgent, - userAgentByPartition, pendingCookieDbPath: legacyPendingCookieDbPath, pendingCookieImports, profiles: Array.isArray(data?.profiles) ? data.profiles : [] @@ -168,8 +152,6 @@ class BrowserSessionRegistry { } catch { return { defaultSource: null, - userAgent: null, - userAgentByPartition: {}, pendingCookieDbPath: null, pendingCookieImports: {}, profiles: [] @@ -200,20 +182,6 @@ class BrowserSessionRegistry { const sess = session.fromPartition(partition) const userAgentMode = profile.userAgentMode ?? 'clean' setBrowserSessionUserAgentMode(sess, userAgentMode) - const persistedUa = meta.userAgentByPartition[partition] - if (persistedUa) { - // Why: imports before the engine-version gate stored a fork's product version (Chrome/1.x); - // it is reapplied every launch, so drop it here or the profile stays blocked forever. - if (isUnadvertisableChromeUserAgent(persistedUa)) { - this.persistUserAgent(partition, null) - } else { - sess.setUserAgent(persistedUa) - setupClientHintsOverride(sess, persistedUa, { - googleAuthOverride: userAgentMode !== 'native' - }) - continue - } - } if (profile.userAgentMode === 'native') { continue @@ -334,20 +302,6 @@ class BrowserSessionRegistry { } } - persistUserAgent(partition: string, userAgent: string | null): void { - const meta = this.loadPersistedMeta() - const userAgentByPartition = { ...meta.userAgentByPartition } - if (userAgent) { - userAgentByPartition[partition] = userAgent - } else { - delete userAgentByPartition[partition] - } - this.persistMeta({ - userAgentByPartition, - userAgent: userAgentByPartition[this.defaultPartition] ?? null - }) - } - getDefaultProfile(): BrowserSessionProfile { return this.profiles.get('default')! } @@ -441,13 +395,9 @@ class BrowserSessionRegistry { const meta = this.loadPersistedMeta() const pendingCookieImports = { ...meta.pendingCookieImports } delete pendingCookieImports[profile.partition] - const userAgentByPartition = { ...meta.userAgentByPartition } - delete userAgentByPartition[profile.partition] this.persistMeta({ pendingCookieImports, - pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null, - userAgentByPartition, - userAgent: userAgentByPartition[this.defaultPartition] ?? null + pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null }) // Why: clear the partition's storage so deleting a profile doesn't leave orphaned cookies/cache behind. @@ -474,12 +424,8 @@ class BrowserSessionRegistry { const meta = this.loadPersistedMeta() const pendingCookieImports = { ...meta.pendingCookieImports } delete pendingCookieImports[this.defaultPartition] - const userAgentByPartition = { ...meta.userAgentByPartition } - delete userAgentByPartition[this.defaultPartition] this.persistMeta({ defaultSource: null, - userAgent: null, - userAgentByPartition, pendingCookieDbPath: null, pendingCookieImports }) diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index 86adef919..092b79995 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -21,31 +21,11 @@ export function cleanElectronUserAgent(ua: string): string { ) } -// Why: Chromium forks (Arc, Brave, …) report a product version (1.x) in -// CFBundleShortVersionString. Advertising that as Chrome/1.x makes version-gating -// sites treat the session as ancient Chromium, so only engine-scale majors are safe. -export function isAdvertisableChromiumEngineVersion(version: string): boolean { - const normalizedVersion = version.trim() - // Reject malformed tokens (e.g. 70.not-a-version) so they never become Chrome/… in the UA. - if (!/^\d+(?:\.\d+)*$/.test(normalizedVersion)) { - return false - } - // Chrome 70+ covers every Chromium engine we still support; product versions stay below. - return Number(normalizedVersion.split('.')[0]) >= 70 -} - -// Why: builds without the version gate persisted Chrome/1.x for fork imports, and a stored -// UA is reapplied on every launch — so the profile stays blocked until the value is dropped. -export function isUnadvertisableChromeUserAgent(ua: string): boolean { - const chromeVersion = /Chrome\/(\S+)/.exec(ua)?.[1] - return chromeVersion !== undefined && !isAdvertisableChromiumEngineVersion(chromeVersion) -} - -// Why: Electron's actual Chromium version (e.g. 134) differs from the source -// browser's version (e.g. Edge 147). The sec-ch-ua Client Hints headers -// reveal the real version, creating a mismatch that Google's anti-fraud -// detection flags as CookieMismatch on accounts.google.com. Override Client -// Hints on outgoing requests to match the source browser's UA. +// Why: Electron emits sec-ch-ua brands like "Not A(Brand" without a +// "Google Chrome" entry, which disagrees with the Chrome-shaped UA the session +// presents. Rewrite the hint headers to the brand set Chrome ships for the same +// engine version so the two surfaces tell one story. Also owns the Google +// auth-host Firefox switch, which must install even for a non-Chrome-shaped UA. export function setupClientHintsOverride( sess: Session, ua: string,