diff --git a/docs/site/content/docs/browser/profiles.mdx b/docs/site/content/docs/browser/profiles.mdx index 102dd1e443d..fd9af7176e2 100644 --- a/docs/site/content/docs/browser/profiles.mdx +++ b/docs/site/content/docs/browser/profiles.mdx @@ -9,7 +9,9 @@ Browser-use profiles let you run the Orca browser with a specific identity — a 1. Open [Settings → Browser → Profiles](/docs/settings). 1. Click **Add profile**, give it a name. 1. Optionally seed it with cookies, a user-agent, and a viewport size. -1. Every profile presents Electron's own user agent. Orca no longer rewrites it to look like Chrome, because Cloudflare Turnstile rejects a Chrome-shaped UA that sends no client hints and accepts a declared Electron client. The only exception is Google's sign-in hosts, where Orca presents a Firefox identity so Google issues cookies bound to the embedded browser. A **native user agent** profile (`orca tab profile create --no-ua-spoof`) also skips that Google exception. +1. Default profiles remove Orca and Electron tokens from the browser engine's user agent, preserving the Chrome-shaped identity expected by imported sessions. This focused compatibility measure does not make the embedded browser identical to Chrome. Google sign-in hosts use a scoped Firefox identity. If a site rejects the cleaned identity, including some Cloudflare-protected sites, create a profile that keeps the **native Electron user agent** instead. + +You can also create a no-spoof profile from the CLI with `orca tab profile create --no-ua-spoof` when you script browser setup. ## Cookie import and Google sign-in diff --git a/src/main/browser/browser-manager-auth-user-agent.test.ts b/src/main/browser/browser-manager-auth-user-agent.test.ts index eb0bd75b354..71e128a9c72 100644 --- a/src/main/browser/browser-manager-auth-user-agent.test.ts +++ b/src/main/browser/browser-manager-auth-user-agent.test.ts @@ -51,7 +51,7 @@ import { import { createViewportGuestFactory, flushViewportOps, - GUEST_ELECTRON_UA + GUEST_CLEAN_UA } from './browser-manager-viewport-test-fixtures' const { @@ -197,9 +197,8 @@ describe('browserManager', () => { // Why: popup child windows get attachGuestPolicies but are never entered into tabIdByWebContentsId, // so a direct lookup of the UA mode misses the native opt-out. That is worse than doing nothing — - // native sessions never install the header-level Firefox switch, so the popup would send the - // Electron UA on the wire while navigator.userAgent claimed Firefox. Google sign-in popups are a - // first-class surface. + // native sessions skip setupGoogleAuthUserAgentOverride, so the popup would send the raw Electron UA on the + // wire while navigator.userAgent claimed Firefox. Google sign-in popups are a first-class surface. it('leaves the UA untouched on auth hosts for a popup owned by a native-UA profile', () => { const ownerGuest = { id: 415, @@ -544,7 +543,7 @@ describe('browserManager', () => { ) expect(uaWrites.length).toBeGreaterThan(0) for (const [, params] of uaWrites) { - expect((params as { userAgent: string }).userAgent).toBe(GUEST_ELECTRON_UA) + expect((params as { userAgent: string }).userAgent).toBe(GUEST_CLEAN_UA) } }) }) diff --git a/src/main/browser/browser-manager-navigation.ts b/src/main/browser/browser-manager-navigation.ts index 5cb46ccb682..c11626fd516 100644 --- a/src/main/browser/browser-manager-navigation.ts +++ b/src/main/browser/browser-manager-navigation.ts @@ -1,4 +1,5 @@ import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' +import { cleanElectronUserAgent } from './browser-session-ua' import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua' import { buildViewportUserAgentOverride } from './browser-viewport-user-agent' @@ -14,7 +15,7 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility // not the request header, so the header-level Firefox switch in setupGoogleAuthUserAgentOverride // must be matched here per navigation or the two layers disagree — itself a bot tell. // Restores the session's base identity off the auth hosts. Native-UA profiles opt out - // of the Firefox switch, so they keep their untouched identity everywhere. + // of the whole clean-UA path, so they keep their untouched identity everywhere. protected applyGoogleAuthUserAgent( guest: Electron.WebContents, url: string, @@ -23,8 +24,8 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility const browserPageId = this.tabIdByWebContentsId.get(guest.id) // Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct // lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA. - // That is worse than doing nothing: native sessions never install the header-level Firefox - // switch, so the popup would send the Electron UA on the wire while navigator.userAgent claims Firefox. + // That is worse than doing nothing: native sessions skip setupGoogleAuthUserAgentOverride, so + // the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox. const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) // Session state is authoritative before renderer registration and after a native profile imports a source UA. const mode = @@ -61,8 +62,9 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility if (this.canOverrideUserAgentOverCdp(guest)) { authOverrideIssuedOverCdp = true // Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers - // resolve one identity for this URL — Firefox on auth hosts, the session's base identity - // off them, any mobile preset preserved. + // resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off + // them, any mobile preset preserved. Writing the session UA directly would put the + // unlaundered Electron token back on the wire. void this.applyAuthUserAgentOverrideOverCdp( guest, (browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ?? @@ -186,7 +188,7 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility // Why: Emulation.setUserAgentOverride is set once and stands across every later navigation, // outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an - // auth host would otherwise pin navigator.userAgent to the session's preset UA while the + // auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the // request header says Firefox — the two-layer disagreement this scope exists to remove. protected reapplyViewportUserAgentOverride( guest: Electron.WebContents, @@ -220,7 +222,7 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility // Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not: // applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to // the CDP override, so reading it back here would republish that identity on ordinary hosts. - baseUserAgent: baseUserAgent ?? guest.session.getUserAgent() + baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent()) }) ) } diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index 0ffc3c2a6e1..0228d8f9ed8 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -49,6 +49,7 @@ import { import { createViewportGuestFactory, flushViewportOps, + GUEST_CLEAN_UA, GUEST_ELECTRON_UA } from './browser-manager-viewport-test-fixtures' @@ -206,7 +207,7 @@ describe('browserManager', () => { mobile: false }) expect(debuggerSendCommand).toHaveBeenLastCalledWith('Emulation.setUserAgentOverride', { - userAgent: GUEST_ELECTRON_UA + userAgent: GUEST_CLEAN_UA }) // Navigating to the auth host must move the standing override to the Firefox identity. @@ -217,11 +218,11 @@ describe('browserManager', () => { userAgent: googleAuthUserAgent() }) - // Leaving the auth host restores the session's own preset UA. + // Leaving the auth host restores the clean Chrome-shaped preset UA. debuggerSendCommand.mockClear() willRedirect({ preventDefault: vi.fn() }, 'https://example.com/', false, true) await flushViewportOps() - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA }) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) }) // Why: not an ordering race — debugger.sendCommand dispatches in call order over one channel, so @@ -240,9 +241,9 @@ describe('browserManager', () => { } // Why mobile: on the desktop branch the break is masked by coincidence — applyGoogleAuthUserAgent - // has already switched the WebContents UA to Firefox, so the stale-URL desktop path happens to - // emit Firefox anyway. The mobile branch derives a Chrome-shaped iPhone UA from the session base - // and exposes the real defect. + // has already switched the WebContents UA to Firefox, and cleanElectronUserAgent passes a Firefox + // UA through untouched, so the stale-URL desktop path happens to emit Firefox anyway. The mobile + // branch derives a Chrome-shaped iPhone UA from that same base and exposes the real defect. it('does not leave the Chrome preset UA standing when a mobile preset lands mid-navigation onto an auth host', async () => { const { guest, debuggerSendCommand } = makeGuest(4251, 'https://example.com/') // Hold the preset's first CDP command open so the navigation lands inside its await window. @@ -331,7 +332,7 @@ describe('browserManager', () => { // Without the fix the resuming preset re-reads getURL() as the auth host and clobbers the // navigation's correct write, stranding the Firefox UA on a non-auth page. - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA }) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) }) it('falls back to the committed URL once a navigation commits or fails', async () => { @@ -377,7 +378,7 @@ describe('browserManager', () => { await flushViewportOps() expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA) - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA }) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) // A later preset must also resolve the committed, non-auth URL. debuggerSendCommand.mockClear() @@ -456,7 +457,7 @@ describe('browserManager', () => { expect(guest.setUserAgent).not.toHaveBeenCalled() expect(debuggerSendCommand).not.toHaveBeenCalledWith( 'Emulation.setUserAgentOverride', - expect.objectContaining({ userAgent: GUEST_ELECTRON_UA }) + expect.objectContaining({ userAgent: GUEST_CLEAN_UA }) ) }) @@ -516,7 +517,7 @@ describe('browserManager', () => { didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/redirected', true) await flushViewportOps() expect(guest.setUserAgent).not.toHaveBeenCalled() - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA }) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) }) it('preserves the auth identity when a viewport preset is cleared after a redirect', async () => { @@ -591,7 +592,7 @@ describe('browserManager', () => { didStartNavigation(null, 'https://example.com/', false, true) await flushViewportOps() - expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA }) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) }) it('reapplies a preset when navigation starts during its final UA write', async () => { diff --git a/src/main/browser/browser-manager-viewport-test-fixtures.ts b/src/main/browser/browser-manager-viewport-test-fixtures.ts index 076524ce5d0..b440a768b71 100644 --- a/src/main/browser/browser-manager-viewport-test-fixtures.ts +++ b/src/main/browser/browser-manager-viewport-test-fixtures.ts @@ -3,6 +3,8 @@ import type { BrowserManagerMocks } from './browser-manager-test-harness' export const GUEST_ELECTRON_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36' +export const GUEST_CLEAN_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' // Why: viewport UA writes are queued on the per-tab chain, so draining it takes more than one // microtask hop; loop until the chain is empty rather than guessing a tick count. diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts index 952c199536c..b092cc8344b 100644 --- a/src/main/browser/browser-session-partition-policies.test.ts +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -82,6 +82,7 @@ vi.mock('./browser-media-access', () => ({ requestSystemMediaAccess: async () => false })) vi.mock('./browser-session-ua', () => ({ + cleanElectronUserAgent: (userAgent: string) => userAgent, setupGoogleAuthUserAgentOverride: vi.fn() })) vi.mock('./browser-session-user-agent-mode', () => ({ diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index ec3c68fb45e..b7022185174 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -9,7 +9,7 @@ import { } from './browser-session-proxy' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy' -import { setupGoogleAuthUserAgentOverride } from './browser-session-ua' +import { cleanElectronUserAgent, setupGoogleAuthUserAgentOverride } from './browser-session-ua' import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' import { allowsBrowserWebAuthnPermission, @@ -92,7 +92,9 @@ export function installBrowserSessionPartitionPolicies( } browserManager.installCertificateRequestGuard(sess) - if (profile.userAgentMode !== 'native') { + if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') { + const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) + sess.setUserAgent(cleanUA) setupGoogleAuthUserAgentOverride(sess) } if (options?.permissions === 'deny') { @@ -189,6 +191,10 @@ export function applyBrowserSessionUserAgentModes(profiles: BrowserSessionProfil if (profile.userAgentMode === 'native') { continue } + + // Why: imported sessions need the same Chrome-shaped identity after app restart. + const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) + sess.setUserAgent(cleanUA) setupGoogleAuthUserAgentOverride(sess) } catch { /* session not available yet (e.g. unit tests or pre-ready) */ diff --git a/src/main/browser/browser-session-partition-proxy-install.test.ts b/src/main/browser/browser-session-partition-proxy-install.test.ts index 4beeaab04c9..d0eee031ea0 100644 --- a/src/main/browser/browser-session-partition-proxy-install.test.ts +++ b/src/main/browser/browser-session-partition-proxy-install.test.ts @@ -44,6 +44,7 @@ vi.mock('./browser-media-access', () => ({ requestSystemMediaAccess: vi.fn(async () => false) })) vi.mock('./browser-session-ua', () => ({ + cleanElectronUserAgent: vi.fn((ua: string) => ua), setupGoogleAuthUserAgentOverride: vi.fn() })) vi.mock('./browser-session-user-agent-mode', () => ({ diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 67653c0c76c..b6495d3d1e1 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const USER_DATA = '/user-data' const META_PATH = `${USER_DATA}/browser-session-meta.json` +const RAW_ELECTRON_USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Orca/1.4.198 Chrome/150.0.7871.224 Electron/43.4.1 Safari/537.36' +const CLEAN_USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.7871.224 Safari/537.36' type FsState = { files: Map @@ -27,6 +31,7 @@ function installModuleMocks( copyFailures = new Set() ): { sessionFromPartitionMock: ReturnType + cleanElectronUserAgentMock: ReturnType setupGoogleAuthUserAgentOverrideMock: ReturnType browserManagerHandleGuestWillDownloadMock: ReturnType browserManagerNotifyPermissionDeniedMock: ReturnType @@ -35,8 +40,7 @@ function installModuleMocks( const sessionFromPartitionMock = vi.fn((partition: string) => ({ partition, setUserAgent: vi.fn(), - getUserAgent: vi.fn(() => 'Mozilla/5.0 Electron/31 Orca'), - webRequest: { onBeforeSendHeaders: vi.fn() }, + getUserAgent: vi.fn(() => RAW_ELECTRON_USER_AGENT), setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setDevicePermissionHandler: vi.fn(), @@ -46,6 +50,7 @@ function installModuleMocks( clearStorageData: vi.fn().mockResolvedValue(undefined), clearCache: vi.fn().mockResolvedValue(undefined) })) + const cleanElectronUserAgentMock = vi.fn(() => CLEAN_USER_AGENT) const setupGoogleAuthUserAgentOverrideMock = vi.fn() const browserManagerHandleGuestWillDownloadMock = vi.fn() const browserManagerNotifyPermissionDeniedMock = vi.fn() @@ -120,6 +125,7 @@ function installModuleMocks( requestSystemMediaAccess: requestSystemMediaAccessMock })) vi.doMock('./browser-session-ua', () => ({ + cleanElectronUserAgent: cleanElectronUserAgentMock, setupGoogleAuthUserAgentOverride: setupGoogleAuthUserAgentOverrideMock })) // This suite models replay with an in-memory filesystem. The real file-backed SQLite merge has @@ -149,6 +155,7 @@ function installModuleMocks( return { sessionFromPartitionMock, + cleanElectronUserAgentMock, setupGoogleAuthUserAgentOverrideMock, browserManagerHandleGuestWillDownloadMock, browserManagerNotifyPermissionDeniedMock, @@ -234,24 +241,30 @@ describe('BrowserSessionRegistry persistence', () => { }) }) - // Why: the stock Electron UA is what clears Cloudflare; only the Google auth switch installs. - it('keeps the stock UA and installs the Google auth switch for profiles without an override', async () => { + it('keeps UA cleaning as the fallback for profiles without an override', async () => { const fsState = createFsState() - const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } = - installModuleMocks(fsState) + const { + sessionFromPartitionMock, + cleanElectronUserAgentMock, + setupGoogleAuthUserAgentOverrideMock + } = installModuleMocks(fsState) const { browserSessionRegistry } = await import('./browser-session-registry') await browserSessionRegistry.createProfile('isolated', 'Default identity') const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value - expect(profileSession.setUserAgent).not.toHaveBeenCalled() + expect(cleanElectronUserAgentMock).toHaveBeenCalledWith(RAW_ELECTRON_USER_AGENT) + expect(profileSession.setUserAgent).toHaveBeenCalledWith(CLEAN_USER_AGENT) expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalledWith(profileSession) }) it('leaves UA and client hints untouched for native-mode profiles', async () => { const fsState = createFsState() - const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } = - installModuleMocks(fsState) + const { + sessionFromPartitionMock, + cleanElectronUserAgentMock, + setupGoogleAuthUserAgentOverrideMock + } = installModuleMocks(fsState) const { browserSessionRegistry } = await import('./browser-session-registry') await browserSessionRegistry.createProfile('isolated', 'Google', { userAgentMode: 'native' }) @@ -259,6 +272,7 @@ describe('BrowserSessionRegistry persistence', () => { const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') expect(profileSession.setUserAgent).not.toHaveBeenCalled() + expect(cleanElectronUserAgentMock).not.toHaveBeenCalled() expect(setupGoogleAuthUserAgentOverrideMock).not.toHaveBeenCalled() expect(getBrowserSessionUserAgentMode(profileSession as never)).toBe('native') }) @@ -382,7 +396,7 @@ describe('BrowserSessionRegistry persistence', () => { // 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 keeps the engine UA', async () => { + 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' @@ -408,8 +422,11 @@ describe('BrowserSessionRegistry persistence', () => { ] }) - const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } = - installModuleMocks(fsState) + const { + sessionFromPartitionMock, + cleanElectronUserAgentMock, + setupGoogleAuthUserAgentOverrideMock + } = installModuleMocks(fsState) const { browserSessionRegistry } = await import('./browser-session-registry') browserSessionRegistry.initializeBrowserSessionsFromPersistedState() @@ -417,8 +434,15 @@ describe('BrowserSessionRegistry persistence', () => { const appliedUas = sessionFromPartitionMock.mock.results.flatMap((r) => r.value.setUserAgent.mock.calls.map((c: unknown[]) => c[0]) ) - // Why: no persisted UA is ever written back; every profile keeps the engine's stock UA. - expect(appliedUas).toEqual([]) + 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 === CLEAN_USER_AGENT)).toBe(true) + expect(cleanElectronUserAgentMock).toHaveBeenCalled() + expect( + cleanElectronUserAgentMock.mock.calls.every(([ua]) => ua === RAW_ELECTRON_USER_AGENT) + ).toBe(true) expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalled() }) diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index ae81ffda4e2..5c628723777 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -54,7 +54,6 @@ describe('BrowserSessionRegistry', () => { askForMediaAccessMock.mockResolvedValue(true) getMediaAccessStatusMock.mockReturnValue('granted') sessionFromPartitionMock.mockReturnValue({ - webRequest: { onBeforeSendHeaders: vi.fn() }, setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setDevicePermissionHandler: vi.fn(), @@ -530,9 +529,6 @@ describe('BrowserSessionRegistry', () => { }) describe('setupGoogleAuthUserAgentOverride', () => { - const STOCK_UA = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/147.0.6890.3 Electron/43.0.0 Safari/537.36' - function install(): (details: unknown, callback: ReturnType) => void { const onBeforeSendHeaders = vi.fn() setupGoogleAuthUserAgentOverride({ webRequest: { onBeforeSendHeaders } } as never) @@ -543,53 +539,54 @@ describe('BrowserSessionRegistry', () => { return onBeforeSendHeaders.mock.calls[0][1] } - // Why: the Electron token is what clears Cloudflare Turnstile; a Chrome-shaped UA with no - // client hints is what it rejects, so ordinary hosts must see the session's UA untouched. - it('leaves the stock Electron UA and its client hints alone off the auth hosts', () => { - const listener = install() + it('leaves ordinary-host identity headers untouched', () => { const callback = vi.fn() - listener( + install()( { - url: 'https://example.com/api', - requestHeaders: { 'User-Agent': STOCK_UA, 'sec-ch-ua': 'old', Cookie: 'abc=123' } + url: 'https://example.com/', + requestHeaders: { + 'User-Agent': 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36', + 'sec-ch-ua': 'browser-owned', + Cookie: 'abc=123' + } }, callback ) - const modified = callback.mock.calls[0][0].requestHeaders - expect(modified['User-Agent']).toBe(STOCK_UA) - expect(modified['sec-ch-ua']).toBe('old') - expect(modified.Cookie).toBe('abc=123') + + expect(callback.mock.calls[0][0].requestHeaders).toEqual({ + 'User-Agent': 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36', + 'sec-ch-ua': 'browser-owned', + Cookie: 'abc=123' + }) }) it('presents a Firefox UA and strips client hints on Google auth hosts', () => { - const listener = install() const callback = vi.fn() - listener( + install()( { url: 'https://accounts.google.com/v3/signin/identifier', requestHeaders: { - 'User-Agent': STOCK_UA, + 'User-Agent': 'Chrome/147', 'sec-ch-ua': 'old', - 'sec-ch-ua-full-version-list': 'old', - 'sec-ch-ua-platform': '"macOS"' + 'SEC-CH-UA-Full-Version-List': 'old', + 'sec-ch-ua-platform': '"macOS"', + Accept: 'text/html' } }, callback ) const modified = callback.mock.calls[0][0].requestHeaders - expect(modified['User-Agent']).toBe(googleAuthUserAgent()) + expect(modified['User-Agent']).toMatch(/Firefox\/\d/) expect(modified['User-Agent']).not.toContain('Chrome') - expect(modified['sec-ch-ua']).toBeUndefined() - expect(modified['sec-ch-ua-full-version-list']).toBeUndefined() - expect(modified['sec-ch-ua-platform']).toBeUndefined() + expect(Object.keys(modified).some((key) => key.toLowerCase().startsWith('sec-ch-ua'))).toBe( + false + ) + expect(modified.Accept).toBe('text/html') }) it('strips client hints on a cross-host request that carries the Firefox auth UA', () => { - const listener = install() const callback = vi.fn() - // Subresource/XHR to a non-auth Google host while the auth document is on - // screen: the WebContents Firefox UA leaks onto the request header. - listener( + install()( { url: 'https://play.google.com/log', requestHeaders: { @@ -611,19 +608,19 @@ describe('BrowserSessionRegistry', () => { expect(modified['sec-ch-ua-mobile']).toBeUndefined() }) - it('keeps the session identity on Google app subdomains (not auth hosts)', () => { - const listener = install() + it('keeps the session identity on Google app subdomains', () => { const callback = vi.fn() - listener( + install()( { url: 'https://myaccount.google.com/', - requestHeaders: { 'User-Agent': STOCK_UA, 'sec-ch-ua': 'old' } + requestHeaders: { 'User-Agent': 'Chrome/150', 'sec-ch-ua': 'browser-owned' } }, callback ) - const modified = callback.mock.calls[0][0].requestHeaders - expect(modified['User-Agent']).toBe(STOCK_UA) - expect(modified['sec-ch-ua']).toBe('old') + expect(callback.mock.calls[0][0].requestHeaders).toEqual({ + 'User-Agent': 'Chrome/150', + 'sec-ch-ua': 'browser-owned' + }) }) }) }) diff --git a/src/main/browser/browser-session-ua-wire-identity.electron.test.ts b/src/main/browser/browser-session-ua-wire-identity.electron.test.ts index 4e0719b2745..9fc1c760643 100644 --- a/src/main/browser/browser-session-ua-wire-identity.electron.test.ts +++ b/src/main/browser/browser-session-ua-wire-identity.electron.test.ts @@ -5,12 +5,19 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { build as buildVite } from 'vite' +import { + LOCAL_HTTPS_TEST_CERTIFICATE, + LOCAL_HTTPS_TEST_PRIVATE_KEY +} from './browser-local-https-test-certificate' -// Why this runs a real Electron: Cloudflare Turnstile rejects a Chrome-shaped UA that ships no -// client hints (error 600010) and clears a declared Electron client. The header layer is the -// only place that identity can be proven, and the vm-based unit tests cannot see Chromium's -// header emission at all. Every partition must therefore keep the stock Electron UA on the wire -// for ordinary hosts and present the Firefox identity on Google's sign-in hosts only. +// Why this runs a real Electron: sites that hold a transplanted session re-check the browser +// identity that minted it, and an `Orca/x.y.z … Electron/x.y.z` UA is not one any browser sends — +// LinkedIn and x.com revoked live sessions over it (STA-7147). The header layer is the only place +// that identity can be proven, and the vm-based unit tests cannot see Chromium's header emission +// at all. Every clean-mode partition must therefore strip the Electron and app tokens on the +// wire for ordinary hosts and present the Firefox identity on Google's sign-in hosts only. This +// focused revocation fix does not claim full Chrome fingerprint parity; native mode remains the +// fallback for sites that reject the cleaned identity, including some Turnstile deployments. const electronBinary = createRequire(import.meta.url)('electron') as string const fixtureRoots: string[] = [] @@ -27,12 +34,24 @@ const FIXTURE_LAUNCH_ATTEMPTS = 2 type CapturedRequest = { url: string userAgent: string | null - clientHints: string[] + clientHints: Record +} + +type UserAgentBrand = { + brand: string + version: string +} + +type NavigatorUserAgentData = { + brands: UserAgentBrand[] + highEntropy: { fullVersionList?: UserAgentBrand[] } } type FixtureResult = { + rawUserAgent: string sessionUserAgent: string navigatorUserAgent: string + navigatorUserAgentData: NavigatorUserAgentData | null requests: CapturedRequest[] } @@ -47,9 +66,14 @@ function neverReachedElectronReady(fixtureResult: string): boolean { function buildFixtureMain(modulePath: string, resultPath: string): string { return ` const { app, BrowserWindow, session } = require('electron') +const { createServer } = require('node:https') const { writeFileSync } = require('node:fs') -const { setupGoogleAuthUserAgentOverride } = require(${JSON.stringify(modulePath)}) +const { cleanElectronUserAgent, setupGoogleAuthUserAgentOverride } = require(${JSON.stringify(modulePath)}) const resultPath = ${JSON.stringify(resultPath)} +// Why: production's UA carries an app token ("Orca/1.4.198") between the engine comment and +// Chrome/, and an unnamed fixture emits none — which would leave half of cleanElectronUserAgent +// unexercised while the test still passed. +app.setName('OrcaWireIdentityFixture') let currentStep = 'starting' const mark = (step) => { currentStep = step @@ -65,38 +89,73 @@ async function run() { mark('ready') const partition = 'persist:wire-identity-test' const sess = session.fromPartition(partition) + // Mirrors installBrowserSessionPartitionPolicies for a non-native profile. + const rawUserAgent = sess.getUserAgent() + const cleanUa = cleanElectronUserAgent(rawUserAgent) + sess.setUserAgent(cleanUa) setupGoogleAuthUserAgentOverride(sess) - mark('auth switch installed') + mark('clean identity installed') - // Why: onSendHeaders reports the headers exactly as they leave the network stack, after the - // product's onBeforeSendHeaders listener has rewritten them. The requests must actually be - // dispatched for it to fire, so the session is pointed at a proxy that refuses every - // connection: nothing reaches the real hosts and every load fails fast. - await sess.setProxy({ proxyRules: 'http://127.0.0.1:9', proxyBypassRules: '<-loopback>' }) + sess.setCertificateVerifyProc((_request, callback) => callback(0)) const requests = [] sess.webRequest.onSendHeaders({ urls: ['https://*/*'] }, (details) => { const headers = details.requestHeaders || {} const uaKey = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent') + const clientHints = {} + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase().startsWith('sec-ch-ua')) { + clientHints[key.toLowerCase()] = value + } + } requests.push({ url: details.url, userAgent: uaKey ? headers[uaKey] : null, - clientHints: Object.keys(headers) - .filter((key) => key.toLowerCase().startsWith('sec-ch-ua')) - .sort() + clientHints }) }) + const server = createServer( + { + cert: ${JSON.stringify(LOCAL_HTTPS_TEST_CERTIFICATE)}, + key: ${JSON.stringify(LOCAL_HTTPS_TEST_PRIVATE_KEY)} + }, + (_request, response) => { + response.setHeader('Accept-CH', 'Sec-CH-UA-Full-Version-List') + response.end('identity') + } + ) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const origin = 'https://127.0.0.1:' + server.address().port const window = new BrowserWindow({ show: false, webPreferences: { partition } }) mark('window created') - for (const url of ['https://example.com/', 'https://accounts.google.com/v3/signin/identifier']) { - await window.loadURL(url).catch(() => {}) + let navigatorUserAgent + let navigatorUserAgentData + try { + await window.loadURL(origin + '/') + navigatorUserAgent = await window.webContents.executeJavaScript('navigator.userAgent') + navigatorUserAgentData = await window.webContents.executeJavaScript( + "(async () => { const data = navigator.userAgentData; return data ? { brands: data.brands, highEntropy: await data.getHighEntropyValues(['fullVersionList']) } : null })()" + ) + await window.webContents.executeJavaScript( + 'fetch("/hints").then((response) => response.text())' + ) + } finally { + await new Promise((resolve) => server.close(resolve)) } + + // Dispatch a real auth-host request without allowing it to reach the Internet. + await sess.setProxy({ proxyRules: 'http://127.0.0.1:9', proxyBypassRules: '<-loopback>' }) + await window.loadURL('https://accounts.google.com/v3/signin/identifier').catch(() => {}) mark('navigations attempted') - const navigatorUserAgent = await window.webContents.executeJavaScript('navigator.userAgent') clearTimeout(timeout) writeFileSync(resultPath, JSON.stringify({ + rawUserAgent, sessionUserAgent: sess.getUserAgent(), navigatorUserAgent, + navigatorUserAgentData, requests })) window.destroy() @@ -156,18 +215,47 @@ async function runFixture(): Promise { } } +function parseClientHintBrands(value: string): UserAgentBrand[] { + return [...value.matchAll(/"([^"]+)";v="([^"]+)"/g)].map((match) => ({ + brand: match[1], + version: match[2] + })) +} + describe('browser session wire identity under Electron', () => { - it('sends the stock Electron UA to ordinary hosts and Firefox to Google auth hosts', async () => { + it('strips the Electron and app tokens for ordinary hosts and sends Firefox to Google auth hosts', async () => { const result = await runFixture() - // Presence precondition: the stock identity still carries the Electron token that the old - // Chrome-shaped rewrite stripped, so an identity check below cannot pass on an empty UA. - expect(result.sessionUserAgent).toMatch(/ Electron\/\d/) + // Presence precondition: the raw identity really does carry the tokens, so the absence + // assertions below cannot pass vacuously on an empty or already-clean UA. + expect(result.rawUserAgent).toMatch(/ Electron\/\d/) + expect(result.rawUserAgent).toMatch(/\(KHTML, like Gecko\) \S+ Chrome\//) - const ordinary = result.requests.find((request) => request.url === 'https://example.com/') + // The whole point of STA-7147: nothing between the engine comment and Chrome/, and no + // Electron token anywhere — the shape a real Chrome sends. + expect(result.sessionUserAgent).not.toContain('Electron/') + expect(result.sessionUserAgent).toMatch(/\(KHTML, like Gecko\) Chrome\/[\d.]+ Safari\/537\.36$/) + + const ordinary = result.requests.find((request) => request.url.endsWith('/hints')) expect(ordinary, JSON.stringify(result.requests)).toBeDefined() expect(ordinary?.userAgent).toBe(result.sessionUserAgent) expect(result.navigatorUserAgent).toBe(result.sessionUserAgent) + expect(result.navigatorUserAgentData).not.toBeNull() + + // Chromium owns both client-hint surfaces. Rewriting only the request headers would make this + // comparison fail while leaving the legacy UA assertions above green. + const wireBrands = parseClientHintBrands(ordinary?.clientHints['sec-ch-ua'] ?? '') + expect(wireBrands).toEqual(result.navigatorUserAgentData?.brands) + expect(wireBrands.some(({ brand }) => /Electron|Orca/i.test(brand))).toBe(false) + const chromeMajor = result.sessionUserAgent.match(/Chrome\/(\d+)/)?.[1] + expect(wireBrands.find(({ brand }) => brand === 'Chromium')?.version).toBe(chromeMajor) + + const fullVersionList = ordinary?.clientHints['sec-ch-ua-full-version-list'] + if (fullVersionList) { + expect(parseClientHintBrands(fullVersionList)).toEqual( + result.navigatorUserAgentData?.highEntropy.fullVersionList + ) + } const auth = result.requests.find((request) => request.url.startsWith('https://accounts.google.com/') @@ -175,6 +263,6 @@ describe('browser session wire identity under Electron', () => { expect(auth, JSON.stringify(result.requests)).toBeDefined() expect(auth?.userAgent).toMatch(/Firefox\/\d/) expect(auth?.userAgent).not.toContain('Chrome') - expect(auth?.clientHints).toEqual([]) + expect(auth?.clientHints).toEqual({}) }) }) diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index 1375ebc66f5..b7fd26449f5 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -8,11 +8,23 @@ import { stripClientHints } from './browser-google-auth-ua' -// Why: the session keeps Electron's stock UA. Stripping the Electron/app tokens to look like -// plain Chrome is what Cloudflare Turnstile rejects (error 600010): a Chrome UA that ships no -// client hints reads as a spoof, while a declared Electron client clears the same challenge. -// This handler only owns the Google auth-host Firefox switch, which is a proven, host-scoped -// exception that must stay consistent across the header and every cross-host subresource. +// Why: Electron's default UA includes "Electron/X.X.X" and the app name +// (e.g. "orca/1.2.3"), an impossible identity for sessions imported from Chrome. +// This focused revocation fix strips only those tokens; it does not attempt full Chrome +// impersonation, and Chromium's client-hint identity remains browser-owned. +export function cleanElectronUserAgent(ua: string): string { + return ( + ua + .replace(/\s+Electron\/\S+/, '') + // Why: \S+ matches any non-whitespace token (e.g. "orca/1.3.8-rc.0") + // including pre-release semver strings that [\d.]+ would miss. + .replace(/(\)\s+)\S+\s+(Chrome\/)/, '$1$2') + ) +} + +// Why: Chromium already publishes one internally consistent client-hint identity through both +// request headers and navigator.userAgentData. This handler only owns the host-scoped Firefox +// exception; synthesizing Chrome brands here would make those two browser-owned surfaces disagree. export function setupGoogleAuthUserAgentOverride(sess: Session): void { const firefoxUa = googleAuthUserAgent() @@ -24,11 +36,20 @@ export function setupGoogleAuthUserAgentOverride(sess: Session): void { // sec-ch-ua* because real Firefox sends none. setUserAgentHeader(headers, firefoxUa) stripClientHints(headers) - } else if (currentUserAgent(headers) === firefoxUa) { - // Why: while the auth document is on screen the WebContents UA is Firefox, so its - // cross-host subresource/XHR requests carry the Firefox UA yet still bear Chromium - // client hints — a sharper cross-host identity tell than either alone. + callback({ requestHeaders: headers }) + return + } + if (currentUserAgent(headers) === firefoxUa) { + // Why: while the auth document is on screen the WebContents UA is Firefox, + // so its cross-host subresource/XHR requests (gstatic, play.google.com, the + // sign-in challenge endpoints) reach here carrying the Firefox UA yet still + // bearing Chromium client hints. Rewriting those to Chrome pairs a Firefox + // UA with Chrome hints — a sharper cross-host identity tell than either + // alone, which can stall Google's password-submit challenge. Real Firefox + // sends no client hints, so strip them to keep one identity for the flow. stripClientHints(headers) + callback({ requestHeaders: headers }) + return } callback({ requestHeaders: headers }) }) diff --git a/src/main/browser/browser-viewport-user-agent.ts b/src/main/browser/browser-viewport-user-agent.ts index b8159a44c2a..691ed0b88a8 100644 --- a/src/main/browser/browser-viewport-user-agent.ts +++ b/src/main/browser/browser-viewport-user-agent.ts @@ -44,7 +44,8 @@ export function buildViewportUserAgentOverride(args: { return { userAgent: googleAuthUserAgent() } } if (!args.mobile) { - // Why: desktop presets republish the session's own identity unchanged. + // Why: desktop presets republish the session's clean identity, or a preset would put the + // Electron/app tokens back on the wire and a transplanted session gets revoked (STA-7147). return { userAgent: args.baseUserAgent } } const chromeMajor = extractChromeMajor(args.baseUserAgent) diff --git a/tests/tools/google-signin-ua-probe.cjs b/tests/tools/google-signin-ua-probe.cjs index 70c834b3d9d..47d8b419d7a 100644 --- a/tests/tools/google-signin-ua-probe.cjs +++ b/tests/tools/google-signin-ua-probe.cjs @@ -10,10 +10,9 @@ const MODES = new Set([ 'electron-fixed', 'firefox-auth', 'firefox-fixed', - // Replicates the app as it shipped before the UA rewrite was removed - // (cleaned Chrome-shaped session UA + the Google auth Firefox switch): Firefox UA is written to the WebContents on auth - // navs and to the request header only for auth-host URLs; every other request - // keeps whatever UA the WebContents carries. Logs incoming vs outgoing + // Replicates the app before and after the cross-host fix. Firefox UA is written + // to the WebContents on auth navs and to the request header only for auth-host + // URLs; every other request keeps whatever UA the WebContents carries. Logs incoming vs outgoing // identity for ALL requests to expose cross-host mismatches during the flow. 'app-current', // Same, but with the STA-3811 fix: the header layer strips client hints on any @@ -171,7 +170,7 @@ app.whenReady().then(async () => { const incoming = relevantHeaders(headers) if (isAppMode) { - // Mirror setupClientHintsOverride: only auth-host URLs get the Firefox UA + // Mirror setupGoogleAuthUserAgentOverride: only auth-host URLs get the Firefox UA // header + hint strip; every other request keeps its incoming UA (which is // the WebContents UA — Firefox while the auth document is on screen). if (isGoogleAuthUrl(details.url)) { @@ -184,10 +183,6 @@ app.whenReady().then(async () => { // instead of rewriting to Chrome — keeping UA and hints one story. if (mode === 'app-fixed' && currentUa === identities.firefox) { removeClientHints(headers) - } else { - // The retired client-hints rewrite built Chrome hints once from the - // session's cleaned UA (a closure), never from the per-request UA. - applyChromeClientHints(headers, identities.cleaned) } } const outgoing = relevantHeaders(headers)