fix(browser): keep restored UA hints browser-owned

This commit is contained in:
Merge Sim
2026-09-10 14:42:22 -07:00
parent 652342000c
commit af7a4ce9b0
15 changed files with 201 additions and 279 deletions
+1 -1
View File
@@ -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.
+4 -4
View File
@@ -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.
@@ -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 = {
@@ -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
@@ -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()
})
@@ -53,7 +53,9 @@ export function createViewportGuestFactory(
debugger: {
isAttached: debuggerIsAttached,
attach: debuggerAttach,
sendCommand: debuggerSendCommand
sendCommand: debuggerSendCommand,
on: vi.fn(),
off: vi.fn()
}
}
return {
@@ -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()
@@ -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) */
}
@@ -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(),
@@ -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<string, string>
@@ -27,7 +31,8 @@ function installModuleMocks(
copyFailures = new Set<string>()
): {
sessionFromPartitionMock: ReturnType<typeof vi.fn>
setupClientHintsOverrideMock: ReturnType<typeof vi.fn>
cleanElectronUserAgentMock: ReturnType<typeof vi.fn>
setupGoogleAuthUserAgentOverrideMock: ReturnType<typeof vi.fn>
browserManagerHandleGuestWillDownloadMock: ReturnType<typeof vi.fn>
browserManagerNotifyPermissionDeniedMock: ReturnType<typeof vi.fn>
requestSystemMediaAccessMock: ReturnType<typeof vi.fn>
@@ -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)
+38 -161
View File
@@ -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<typeof vi.fn>) => 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'
})
})
})
})
@@ -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<string, string>
}
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('<!doctype html><title>identity</title>')
}
)
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<FixtureResult> {
}
}
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({})
})
})
+9 -53
View File
@@ -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"`
}
}
@@ -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`
}
+4 -9
View File
@@ -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)