merge: origin/main (fb85f88d64) into ssh-remote-integration-stack

This commit is contained in:
Neil
2026-09-10 15:35:50 -07:00
14 changed files with 259 additions and 119 deletions
+3 -1
View File
@@ -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
@@ -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)
}
})
})
@@ -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())
})
)
}
@@ -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 () => {
@@ -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.
@@ -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', () => ({
@@ -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) */
@@ -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', () => ({
@@ -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,6 +31,7 @@ function installModuleMocks(
copyFailures = new Set<string>()
): {
sessionFromPartitionMock: ReturnType<typeof vi.fn>
cleanElectronUserAgentMock: ReturnType<typeof vi.fn>
setupGoogleAuthUserAgentOverrideMock: ReturnType<typeof vi.fn>
browserManagerHandleGuestWillDownloadMock: ReturnType<typeof vi.fn>
browserManagerNotifyPermissionDeniedMock: ReturnType<typeof vi.fn>
@@ -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()
})
@@ -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<typeof vi.fn>) => 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'
})
})
})
})
@@ -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<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[]
}
@@ -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('<!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()
@@ -156,18 +215,47 @@ 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('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({})
})
})
+30 -9
View File
@@ -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 })
})
@@ -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)
+4 -9
View File
@@ -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)