fix(browser): stop impersonating the source browser's UA on cookie import (STA-3514) (#12849)

* 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
This commit is contained in:
Brennan Benson 2026-08-09 17:05:00 -07:00 committed by GitHub
parent 7aae88cd21
commit c03c01ad15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 94 additions and 433 deletions

View File

@ -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 }))

View File

@ -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<typeof childProcessModule>('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<typeof childProcessModule>('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.')
})
})

View File

@ -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<typeof childProcessModule>('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<typeof childProcessModule>('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')

View File

@ -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<typeof childProcessModule>('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<typeof childProcessModule>('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')

View File

@ -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<typeof vi.fn>
let cookiesFlushStoreMock: ReturnType<typeof vi.fn>
let clearStorageDataMock: ReturnType<typeof vi.fn>
let setUserAgentMock: ReturnType<typeof vi.fn>
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()

View File

@ -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,

View File

@ -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<typeof browserSessionUaModule>('./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()

View File

@ -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<string, string>
pendingCookieDbPath: string | null
pendingCookieImports: Record<string, string>
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<string, string> =
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<string, string> =
@ -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
})

View File

@ -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,