diff --git a/docs/site/content/docs/browser/profiles.mdx b/docs/site/content/docs/browser/profiles.mdx index 97286a6cb0c..fd9af7176e2 100644 --- a/docs/site/content/docs/browser/profiles.mdx +++ b/docs/site/content/docs/browser/profiles.mdx @@ -9,7 +9,7 @@ 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. For sites that reject Orca's default Chrome-shaped UA (some Google sign-in flows), create a profile that keeps the **native Electron user agent** instead of spoofing. Default profiles still use the cleaned Chrome UA for broader Cloudflare compatibility. +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. diff --git a/src/main/browser/browser-google-auth-ua.ts b/src/main/browser/browser-google-auth-ua.ts index 16ed14eb80f..e9b802f6d70 100644 --- a/src/main/browser/browser-google-auth-ua.ts +++ b/src/main/browser/browser-google-auth-ua.ts @@ -1,12 +1,12 @@ // Why: Google binds a signed-in session to the browser identity that created it. -// Cookies copied in from another browser (or sent under an Electron/Chrome-shaped -// UA that doesn't match a real first-party browser) get flagged by anti-fraud on -// accounts.google.com and expire within ~1h. Presenting a Firefox identity scoped +// Cookies copied in from another browser (or sent under a UA that doesn't match a +// real first-party browser) get flagged by anti-fraud on accounts.google.com and +// expire within ~1h. Presenting a Firefox identity scoped // to Google's auth hosts lets the user sign in *inside* the embedded browser, so // Google issues cookies bound to THIS browser that self-refresh — instead of us // transplanting cookies that go stale. Scope is deliberately the auth hosts only: // post-auth app surfaces (mail.google.com, myaccount.google.com, drive, etc.) keep -// the profile's real Chrome-shaped identity so nothing else about the session shifts. +// the profile's real identity so nothing else about the session shifts. // Why: exact hostname match — subdomains such as myaccount.google.com are post-auth // app surfaces, not the sign-in flow, and must retain the profile's real identity. 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 405b689e850..71e128a9c72 100644 --- a/src/main/browser/browser-manager-auth-user-agent.test.ts +++ b/src/main/browser/browser-manager-auth-user-agent.test.ts @@ -197,7 +197,7 @@ 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 skip setupClientHintsOverride, so the popup would send the raw Electron UA on the + // 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 = { diff --git a/src/main/browser/browser-manager-navigation.ts b/src/main/browser/browser-manager-navigation.ts index 4e061d288aa..c11626fd516 100644 --- a/src/main/browser/browser-manager-navigation.ts +++ b/src/main/browser/browser-manager-navigation.ts @@ -12,7 +12,7 @@ import { BrowserManagerVisibility } from './browser-manager-visibility' export abstract class BrowserManagerNavigation extends BrowserManagerVisibility { // Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA, - // not the request header, so the header-level Firefox switch in setupClientHintsOverride + // 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 whole clean-UA path, so they keep their untouched identity everywhere. @@ -24,7 +24,7 @@ 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 skip setupClientHintsOverride entirely, so + // 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. @@ -56,8 +56,8 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility // navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain // cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without // touching the navigation, and it outranks the WebContents UA from then on, so a guest that - // switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride - // rewrites User-Agent per request for auth-host URLs on its own. + // switches to it stays on it. The wire UA never depended on this write: + // setupGoogleAuthUserAgentOverride rewrites User-Agent per request for auth-host URLs on its own. if (options.duringRedirect === true || overrideState !== undefined) { if (this.canOverrideUserAgentOverCdp(guest)) { authOverrideIssuedOverCdp = true diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index b7d3bbabe0a..0228d8f9ed8 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -849,8 +849,7 @@ describe('browserManager', () => { expect(debuggerAttach).toHaveBeenCalledWith('1.3') expect(debuggerSendCommand).toHaveBeenCalled() - // Why: detaching would clear Page.addScriptToEvaluateOnNewDocument - // (anti-detection). Guard regression. + // Why: detaching would clear every standing CDP override (viewport, auth UA). Guard regression. expect((guest.debugger as { detach?: unknown }).detach ?? undefined).toBeUndefined() }) diff --git a/src/main/browser/browser-manager-viewport-test-fixtures.ts b/src/main/browser/browser-manager-viewport-test-fixtures.ts index 8d68977f62e..b440a768b71 100644 --- a/src/main/browser/browser-manager-viewport-test-fixtures.ts +++ b/src/main/browser/browser-manager-viewport-test-fixtures.ts @@ -53,7 +53,9 @@ export function createViewportGuestFactory( debugger: { isAttached: debuggerIsAttached, attach: debuggerAttach, - sendCommand: debuggerSendCommand + sendCommand: debuggerSendCommand, + on: vi.fn(), + off: vi.fn() } } return { diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts index 78ce34d95fd..b092cc8344b 100644 --- a/src/main/browser/browser-session-partition-policies.test.ts +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -83,7 +83,7 @@ vi.mock('./browser-media-access', () => ({ })) vi.mock('./browser-session-ua', () => ({ cleanElectronUserAgent: (userAgent: string) => userAgent, - setupClientHintsOverride: vi.fn() + setupGoogleAuthUserAgentOverride: vi.fn() })) vi.mock('./browser-session-user-agent-mode', () => ({ setBrowserSessionUserAgentMode: vi.fn() diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index 9f25d8840a2..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 { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' +import { cleanElectronUserAgent, setupGoogleAuthUserAgentOverride } from './browser-session-ua' import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' import { allowsBrowserWebAuthnPermission, @@ -95,7 +95,7 @@ export function installBrowserSessionPartitionPolicies( if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') { const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) sess.setUserAgent(cleanUA) - setupClientHintsOverride(sess, cleanUA) + setupGoogleAuthUserAgentOverride(sess) } if (options?.permissions === 'deny') { sess.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)) @@ -192,10 +192,10 @@ export function applyBrowserSessionUserAgentModes(profiles: BrowserSessionProfil continue } - // Why: the default Electron UA leaks "Electron/X.X.X" + app name, which trips Cloudflare Turnstile. + // Why: imported sessions need the same Chrome-shaped identity after app restart. const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) sess.setUserAgent(cleanUA) - setupClientHintsOverride(sess, 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 0841ed94785..d0eee031ea0 100644 --- a/src/main/browser/browser-session-partition-proxy-install.test.ts +++ b/src/main/browser/browser-session-partition-proxy-install.test.ts @@ -45,7 +45,7 @@ vi.mock('./browser-media-access', () => ({ })) vi.mock('./browser-session-ua', () => ({ cleanElectronUserAgent: vi.fn((ua: string) => ua), - setupClientHintsOverride: vi.fn() + setupGoogleAuthUserAgentOverride: vi.fn() })) vi.mock('./browser-session-user-agent-mode', () => ({ setBrowserSessionUserAgentMode: vi.fn(), diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index fdba71e16d6..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,7 +31,8 @@ function installModuleMocks( copyFailures = new Set() ): { sessionFromPartitionMock: ReturnType - setupClientHintsOverrideMock: ReturnType + cleanElectronUserAgentMock: ReturnType + setupGoogleAuthUserAgentOverrideMock: ReturnType browserManagerHandleGuestWillDownloadMock: ReturnType browserManagerNotifyPermissionDeniedMock: ReturnType requestSystemMediaAccessMock: ReturnType @@ -35,7 +40,7 @@ function installModuleMocks( const sessionFromPartitionMock = vi.fn((partition: string) => ({ partition, setUserAgent: vi.fn(), - getUserAgent: vi.fn(() => 'Mozilla/5.0 Electron/31 Orca'), + getUserAgent: vi.fn(() => RAW_ELECTRON_USER_AGENT), setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setDevicePermissionHandler: vi.fn(), @@ -45,7 +50,8 @@ function installModuleMocks( clearStorageData: vi.fn().mockResolvedValue(undefined), clearCache: vi.fn().mockResolvedValue(undefined) })) - const setupClientHintsOverrideMock = vi.fn() + const cleanElectronUserAgentMock = vi.fn(() => CLEAN_USER_AGENT) + const setupGoogleAuthUserAgentOverrideMock = vi.fn() const browserManagerHandleGuestWillDownloadMock = vi.fn() const browserManagerNotifyPermissionDeniedMock = vi.fn() const requestSystemMediaAccessMock = vi.fn().mockResolvedValue(true) @@ -119,8 +125,8 @@ function installModuleMocks( requestSystemMediaAccess: requestSystemMediaAccessMock })) vi.doMock('./browser-session-ua', () => ({ - cleanElectronUserAgent: vi.fn((ua: string) => ua.replace(/\s*Electron\/\S+/, '')), - setupClientHintsOverride: setupClientHintsOverrideMock + cleanElectronUserAgent: cleanElectronUserAgentMock, + setupGoogleAuthUserAgentOverride: setupGoogleAuthUserAgentOverrideMock })) // This suite models replay with an in-memory filesystem. The real file-backed SQLite merge has // dedicated coverage; these fixtures are legacy unmarked images and keep the copy path. @@ -149,7 +155,8 @@ function installModuleMocks( return { sessionFromPartitionMock, - setupClientHintsOverrideMock, + cleanElectronUserAgentMock, + setupGoogleAuthUserAgentOverrideMock, browserManagerHandleGuestWillDownloadMock, browserManagerNotifyPermissionDeniedMock, requestSystemMediaAccessMock @@ -236,19 +243,28 @@ describe('BrowserSessionRegistry persistence', () => { it('keeps UA cleaning as the fallback for profiles without an override', async () => { const fsState = createFsState() - const { sessionFromPartitionMock, setupClientHintsOverrideMock } = 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).toHaveBeenCalledWith('Mozilla/5.0 Orca') - expect(setupClientHintsOverrideMock).toHaveBeenCalledWith(profileSession, 'Mozilla/5.0 Orca') + 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, setupClientHintsOverrideMock } = installModuleMocks(fsState) + const { + sessionFromPartitionMock, + cleanElectronUserAgentMock, + setupGoogleAuthUserAgentOverrideMock + } = installModuleMocks(fsState) const { browserSessionRegistry } = await import('./browser-session-registry') await browserSessionRegistry.createProfile('isolated', 'Google', { userAgentMode: 'native' }) @@ -256,7 +272,8 @@ 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(setupClientHintsOverrideMock).not.toHaveBeenCalled() + expect(cleanElectronUserAgentMock).not.toHaveBeenCalled() + expect(setupGoogleAuthUserAgentOverrideMock).not.toHaveBeenCalled() expect(getBrowserSessionUserAgentMode(profileSession as never)).toBe('native') }) @@ -405,7 +422,11 @@ describe('BrowserSessionRegistry persistence', () => { ] }) - const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState) + const { + sessionFromPartitionMock, + cleanElectronUserAgentMock, + setupGoogleAuthUserAgentOverrideMock + } = installModuleMocks(fsState) const { browserSessionRegistry } = await import('./browser-session-registry') browserSessionRegistry.initializeBrowserSessionsFromPersistedState() @@ -417,12 +438,12 @@ describe('BrowserSessionRegistry persistence', () => { 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(appliedUas.every((ua) => ua === CLEAN_USER_AGENT)).toBe(true) + expect(cleanElectronUserAgentMock).toHaveBeenCalled() expect( - setupClientHintsOverrideMock.mock.calls.every( - (c: unknown[]) => c[1] !== brokenUa && c[1] !== validUa - ) + cleanElectronUserAgentMock.mock.calls.every(([ua]) => ua === RAW_ELECTRON_USER_AGENT) ).toBe(true) + expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalled() }) it('never applies a legacy persisted UA to a native-mode profile', async () => { @@ -487,7 +508,8 @@ describe('BrowserSessionRegistry persistence', () => { ] }) - const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState) + const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } = + installModuleMocks(fsState) const { browserSessionRegistry } = await import('./browser-session-registry') browserSessionRegistry.initializeBrowserSessionsFromPersistedState() @@ -498,7 +520,7 @@ describe('BrowserSessionRegistry persistence', () => { expect(importedSessions.length).toBeGreaterThan(0) expect(importedSessions.every((sess) => sess.setUserAgent.mock.calls.length === 0)).toBe(true) expect( - setupClientHintsOverrideMock.mock.calls.some( + setupGoogleAuthUserAgentOverrideMock.mock.calls.some( ([sess]) => (sess as { partition?: string }).partition === importedPartition ) ).toBe(false) diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index d5111483fcf..5c628723777 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -33,7 +33,7 @@ vi.mock('./browser-manager', () => ({ import { browserSessionRegistry } from './browser-session-registry' import { googleAuthUserAgent } from './browser-google-auth-ua' -import { setupClientHintsOverride } from './browser-session-ua' +import { setupGoogleAuthUserAgentOverride } from './browser-session-ua' import { setBrowserNetworkProxySettingsResolver } from './browser-session-proxy' import { handleElectronProxyLogin } from '../network/electron-proxy-credentials' import { applyProxySettingsToSession } from '../network/proxy-settings' @@ -528,83 +528,49 @@ describe('BrowserSessionRegistry', () => { }) }) - describe('setupClientHintsOverride', () => { - it('overrides sec-ch-ua headers for Edge UA', () => { + describe('setupGoogleAuthUserAgentOverride', () => { + function install(): (details: unknown, callback: ReturnType) => void { const onBeforeSendHeaders = vi.fn() - const mockSess = { webRequest: { onBeforeSendHeaders } } as never - const edgeUa = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36 Edg/147.0.3210.5' - - setupClientHintsOverride(mockSess, edgeUa) - + setupGoogleAuthUserAgentOverride({ webRequest: { onBeforeSendHeaders } } as never) expect(onBeforeSendHeaders).toHaveBeenCalledWith( { urls: ['https://*/*'] }, expect.any(Function) ) + return onBeforeSendHeaders.mock.calls[0][1] + } + it('leaves ordinary-host identity headers untouched', () => { const callback = vi.fn() - const listener = onBeforeSendHeaders.mock.calls[0][1] - listener( - { requestHeaders: { 'sec-ch-ua': 'old', 'sec-ch-ua-full-version-list': 'old' } }, + install()( + { + 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['sec-ch-ua']).toContain('Microsoft Edge') - expect(modified['sec-ch-ua']).toContain('"147"') - expect(modified['sec-ch-ua-full-version-list']).toContain('147.0.3210.5') - }) - it('overrides sec-ch-ua headers for Chrome UA', () => { - const onBeforeSendHeaders = vi.fn() - const mockSess = { webRequest: { onBeforeSendHeaders } } as never - const chromeUa = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' - - setupClientHintsOverride(mockSess, chromeUa) - - const callback = vi.fn() - const listener = onBeforeSendHeaders.mock.calls[0][1] - listener({ requestHeaders: { 'sec-ch-ua': 'old' } }, callback) - const modified = callback.mock.calls[0][0].requestHeaders - expect(modified['sec-ch-ua']).toContain('Google Chrome') - expect(modified['sec-ch-ua']).not.toContain('Microsoft Edge') - }) - - it('registers handler even for non-Chrome UA but leaves sec-ch-ua untouched off auth hosts', () => { - const onBeforeSendHeaders = vi.fn() - const mockSess = { webRequest: { onBeforeSendHeaders } } as never - - // Why: the Google-auth Firefox switch must install regardless of the base UA. - setupClientHintsOverride(mockSess, 'Mozilla/5.0 (compatible; MSIE 10.0)') - - expect(onBeforeSendHeaders).toHaveBeenCalledWith( - { urls: ['https://*/*'] }, - expect.any(Function) - ) - const callback = vi.fn() - const listener = onBeforeSendHeaders.mock.calls[0][1] - listener({ url: 'https://example.com/', requestHeaders: { 'sec-ch-ua': 'old' } }, callback) - expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toBe('old') + 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 onBeforeSendHeaders = vi.fn() - const mockSess = { webRequest: { onBeforeSendHeaders } } as never - setupClientHintsOverride( - mockSess, - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' - ) - const callback = vi.fn() - const listener = onBeforeSendHeaders.mock.calls[0][1] - listener( + install()( { url: 'https://accounts.google.com/v3/signin/identifier', requestHeaders: { '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 @@ -612,24 +578,15 @@ describe('BrowserSessionRegistry', () => { const modified = callback.mock.calls[0][0].requestHeaders 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 onBeforeSendHeaders = vi.fn() - const mockSess = { webRequest: { onBeforeSendHeaders } } as never - setupClientHintsOverride( - mockSess, - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' - ) - const callback = vi.fn() - const listener = onBeforeSendHeaders.mock.calls[0][1] - // 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: { @@ -651,99 +608,19 @@ describe('BrowserSessionRegistry', () => { expect(modified['sec-ch-ua-mobile']).toBeUndefined() }) - it('keeps the clean Chrome identity on cross-host requests that carry the Chrome UA', () => { - const onBeforeSendHeaders = vi.fn() - const mockSess = { webRequest: { onBeforeSendHeaders } } as never - const chromeUa = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' - setupClientHintsOverride(mockSess, chromeUa) - + it('keeps the session identity on Google app subdomains', () => { const callback = vi.fn() - const listener = onBeforeSendHeaders.mock.calls[0][1] - // Regression guard: non-Google sites (Cloudflare) must keep Chrome hints. - listener( + install()( { - url: 'https://example.com/api', - requestHeaders: { 'User-Agent': chromeUa, 'sec-ch-ua': 'old' } + url: 'https://myaccount.google.com/', + requestHeaders: { 'User-Agent': 'Chrome/150', 'sec-ch-ua': 'browser-owned' } }, callback ) - expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome') - }) - - it('does not strip hints for the Firefox UA when googleAuthOverride is disabled', () => { - const onBeforeSendHeaders = vi.fn() - const mockSess = { webRequest: { onBeforeSendHeaders } } as never - const chromeUa = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' - setupClientHintsOverride(mockSess, chromeUa, { googleAuthOverride: false }) - - const callback = vi.fn() - const listener = onBeforeSendHeaders.mock.calls[0][1] - listener( - { - url: 'https://play.google.com/log', - requestHeaders: { 'User-Agent': googleAuthUserAgent(), 'sec-ch-ua': 'old' } - }, - callback - ) - // Imported-native profiles never install the Firefox switch, so the strip - // branch stays inert and hints are aligned to Chrome instead. - expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome') - }) - - it('keeps Chrome client hints on Google app subdomains (not auth hosts)', () => { - const onBeforeSendHeaders = vi.fn() - const mockSess = { webRequest: { onBeforeSendHeaders } } as never - setupClientHintsOverride( - mockSess, - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' - ) - - const callback = vi.fn() - const listener = onBeforeSendHeaders.mock.calls[0][1] - listener( - { url: 'https://myaccount.google.com/', requestHeaders: { 'sec-ch-ua': 'old' } }, - callback - ) - expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome') - }) - - it('keeps an imported native UA on auth hosts while aligning its Chrome hints', () => { - const onBeforeSendHeaders = vi.fn() - const mockSess = { webRequest: { onBeforeSendHeaders } } as never - const importedUa = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' - setupClientHintsOverride(mockSess, importedUa, { googleAuthOverride: false }) - - const callback = vi.fn() - const listener = onBeforeSendHeaders.mock.calls[0][1] - listener( - { - url: 'https://accounts.google.com/v3/signin/identifier', - requestHeaders: { 'User-Agent': importedUa, 'sec-ch-ua': 'old' } - }, - callback - ) - const modified = callback.mock.calls[0][0].requestHeaders - expect(modified['User-Agent']).toBe(importedUa) - expect(modified['sec-ch-ua']).toContain('Google Chrome') - }) - - it('leaves non-Client-Hints headers unchanged', () => { - const onBeforeSendHeaders = vi.fn() - const mockSess = { webRequest: { onBeforeSendHeaders } } as never - setupClientHintsOverride(mockSess, 'Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36') - - const callback = vi.fn() - const listener = onBeforeSendHeaders.mock.calls[0][1] - listener( - { requestHeaders: { Cookie: 'abc=123', 'sec-ch-ua': 'old', Accept: 'text/html' } }, - callback - ) - const modified = callback.mock.calls[0][0].requestHeaders - expect(modified.Cookie).toBe('abc=123') - expect(modified.Accept).toBe('text/html') + 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 c16b23a955f..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,13 +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: 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 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. +// 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[] = [] @@ -28,13 +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[] } @@ -49,8 +66,9 @@ 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 { cleanElectronUserAgent, setupClientHintsOverride } = 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 @@ -75,39 +93,69 @@ async function run() { const rawUserAgent = sess.getUserAgent() const cleanUa = cleanElectronUserAgent(rawUserAgent) sess.setUserAgent(cleanUa) - setupClientHintsOverride(sess, cleanUa) + setupGoogleAuthUserAgentOverride(sess) 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() @@ -167,6 +215,13 @@ 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('strips the Electron and app tokens for ordinary hosts and sends Firefox to Google auth hosts', async () => { const result = await runFixture() @@ -181,10 +236,26 @@ describe('browser session wire identity under Electron', () => { 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 === 'https://example.com/') + 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/') @@ -192,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 96c55cf5ac9..b7fd26449f5 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -9,9 +9,9 @@ import { } from './browser-google-auth-ua' // Why: Electron's default UA includes "Electron/X.X.X" and the app name -// (e.g. "orca/1.2.3"), which Cloudflare Turnstile and other bot detectors -// flag as non-human traffic. Strip those tokens so the webview's UA and -// sec-ch-ua Client Hints look like standard Chrome. +// (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 @@ -22,25 +22,15 @@ export function cleanElectronUserAgent(ua: string): string { ) } -// 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, - options: { googleAuthOverride?: boolean } = {} -): void { - // Why: only Chrome-shaped base UAs carry sec-ch-ua hints to rewrite, but the - // Google-auth Firefox switch below must install regardless, so keep the hints - // optional rather than bailing out of the whole handler. - const chromeHints = buildChromeClientHints(ua) +// 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() sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => { const headers = details.requestHeaders - if (options.googleAuthOverride !== false && isGoogleAuthUrl(details.url)) { + if (isGoogleAuthUrl(details.url)) { // Why: present a Firefox identity on Google's sign-in hosts so the user logs // in inside the app and Google issues self-refreshing bound cookies. Strip // sec-ch-ua* because real Firefox sends none. @@ -49,7 +39,7 @@ export function setupClientHintsOverride( callback({ requestHeaders: headers }) return } - if (options.googleAuthOverride !== false && currentUserAgent(headers) === firefoxUa) { + 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 @@ -61,40 +51,6 @@ export function setupClientHintsOverride( callback({ requestHeaders: headers }) return } - if (chromeHints) { - for (const key of Object.keys(headers)) { - const lower = key.toLowerCase() - if (lower === 'sec-ch-ua') { - headers[key] = chromeHints.secChUa - } else if (lower === 'sec-ch-ua-full-version-list') { - headers[key] = chromeHints.secChUaFull - } - } - } callback({ requestHeaders: headers }) }) } - -function buildChromeClientHints(ua: string): { secChUa: string; secChUaFull: string } | null { - const chromeMatch = ua.match(/Chrome\/([\d.]+)/) - if (!chromeMatch) { - return null - } - const fullChromeVersion = chromeMatch[1] - const majorVersion = fullChromeVersion.split('.')[0] - - let brand = 'Google Chrome' - let brandFullVersion = fullChromeVersion - - const edgeMatch = ua.match(/Edg\/([\d.]+)/) - if (edgeMatch) { - brand = 'Microsoft Edge' - brandFullVersion = edgeMatch[1] - } - const brandMajor = brandFullVersion.split('.')[0] - - return { - secChUa: `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`, - secChUaFull: `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"` - } -} diff --git a/src/main/browser/browser-viewport-user-agent.ts b/src/main/browser/browser-viewport-user-agent.ts index fdeacb895e0..691ed0b88a8 100644 --- a/src/main/browser/browser-viewport-user-agent.ts +++ b/src/main/browser/browser-viewport-user-agent.ts @@ -23,7 +23,7 @@ export type ViewportUserAgentOverride = { } // Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real -// Chrome major spliced in to keep sec-ch-ua consistent (see setupClientHintsOverride). +// Chrome major spliced in so the userAgentMetadata brands below agree with it. function buildMobileUserAgent(chromeMajor: string): string { return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1` } diff --git a/tests/tools/google-signin-ua-probe.cjs b/tests/tools/google-signin-ua-probe.cjs index 7adf06bb7a0..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 SHIPPED app exactly (setupClientHintsOverride + - // applyGoogleAuthUserAgent): 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 { - // Real setupClientHintsOverride builds 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)