diff --git a/src/main/browser/browser-cookie-import.arc-ua.test.ts b/src/main/browser/browser-cookie-import.arc-ua.test.ts new file mode 100644 index 000000000..6a7c7ff06 --- /dev/null +++ b/src/main/browser/browser-cookie-import.arc-ua.test.ts @@ -0,0 +1,81 @@ +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.ts b/src/main/browser/browser-cookie-import.ts index e67c5470b..65ea11c1e 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -75,7 +75,7 @@ import type { } from '../../shared/types' import { browserSessionRegistry } from './browser-session-registry' import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' -import { setupClientHintsOverride } from './browser-session-ua' +import { isAdvertisableChromiumEngineVersion, setupClientHintsOverride } from './browser-session-ua' import { isGoogleSourceBoundCookie, normalizeCookieDomain, @@ -804,32 +804,34 @@ export function getUserAgentForBrowser( } } + 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': { - const v = readBrowserVersion('/Applications/Google Chrome.app') - return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null + return chromeShapedUa(readBrowserVersion('/Applications/Google Chrome.app')) } case 'edge': { - const v = readBrowserVersion('/Applications/Microsoft Edge.app') - return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36 Edg/${v}` : null + return chromeShapedUa(readBrowserVersion('/Applications/Microsoft Edge.app'), true) } case 'arc': { - const v = readBrowserVersion('/Applications/Arc.app') - return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null + return chromeShapedUa(readBrowserVersion('/Applications/Arc.app')) } case 'chromium': { - const v = readBrowserVersion('/Applications/Brave Browser.app') - return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null + 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. - const v = readBrowserVersion('/Applications/Comet.app') - return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null + 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. - const v = readBrowserVersion('/Applications/Helium.app') - return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null + return chromeShapedUa(readBrowserVersion('/Applications/Helium.app')) } case 'firefox': case 'safari': diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index f7792903b..0d84a47fd 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -1,4 +1,5 @@ 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` @@ -118,10 +119,15 @@ function installModuleMocks( hasSystemMediaAccess: vi.fn(() => true), requestSystemMediaAccess: requestSystemMediaAccessMock })) - vi.doMock('./browser-session-ua', () => ({ - cleanElectronUserAgent: vi.fn((ua: string) => ua.replace(/\s*Electron\/\S+/, '')), - setupClientHintsOverride: setupClientHintsOverrideMock - })) + 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 + } + }) return { sessionFromPartitionMock, @@ -405,6 +411,39 @@ 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 d6188bf88..175c81ffa 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -25,7 +25,11 @@ import type { } from '../../shared/types' import { browserManager } from './browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' -import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' +import { + cleanElectronUserAgent, + isUnadvertisableChromeUserAgent, + setupClientHintsOverride +} from './browser-session-ua' import { clearBrowserSessionUserAgentMode, setBrowserSessionUserAgentMode @@ -198,11 +202,17 @@ class BrowserSessionRegistry { setBrowserSessionUserAgentMode(sess, userAgentMode) const persistedUa = meta.userAgentByPartition[partition] if (persistedUa) { - sess.setUserAgent(persistedUa) - setupClientHintsOverride(sess, persistedUa, { - googleAuthOverride: userAgentMode !== 'native' - }) - continue + // 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') { diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index 081e1a5de..86adef919 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -21,6 +21,26 @@ 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