fix(browser): reject Chromium product versions in cookie-import UA (#12811)
* fix(browser): reject Chromium product versions in cookie-import UA Do not persist Chrome/1.x User-Agents when a fork (e.g. Arc) reports its product CFBundleShortVersionString. Only engine-scale majors (>=70) are advertised; otherwise fall back to Electron's default UA. Preserves Chrome-shaped UAs for real Chromium engine versions. Fixes #12726 * fix(browser): reject malformed Chromium version tokens in UA Validate every numeric component before advertising Chrome/… so values like 70.not-a-version fall back instead of polluting the UA. Also build macOS app paths with path.join per coding guidelines. * fix(browser): drop already-persisted Chrome/1.x UAs on session restore The version gate stops new fork imports from writing Chrome/1.x, but profiles imported before it keep the broken UA in browser-session-meta.json and replay it on every launch, so affected users stay blocked with no in-app recovery. Move the gate into browser-session-ua (the module that owns UA shape, and the one both callers can import without a cycle) and drop an unadvertisable persisted UA during restore so the profile falls back to Orca's own engine UA. --------- Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
This commit is contained in:
parent
982570648a
commit
84e7ca5212
|
|
@ -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<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.')
|
||||
})
|
||||
})
|
||||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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<typeof browserSessionUaModule>('./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()
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue