mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(browser): restore the Chrome-shaped browser identity (STA-7147) (#19927)
* fix(browser): restore the Chrome-shaped browser identity (STA-7147)
#18749 replaced every browser partition's Chrome-shaped UA with Electron's stock
one, so since v1.4.198 the embedded browser announces itself on every non-Google
host as:
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
No browser sends that. Sites that re-check the identity holding a session reject
it: users report being signed out of x.com, LinkedIn and "most websites," and at
least one was signed out of LinkedIn in their own Chrome and met LinkedIn's
"suspicious activity" SMS check -- server-side revocation, which reaches beyond
our app. The repo already documented the mechanism in browser-google-auth-ua.ts:
copied-in cookies "sent under a UA that doesn't match a real first-party browser
get flagged by anti-fraud." That is why the Google auth-host switch exists;
#18749 kept it for accounts.google.com and handed every other host an Electron
identity.
Restore the pre-#18749 session identity: strip the Electron and app tokens, and
rewrite sec-ch-ua to match. Nothing in the cookie-import write path changed --
it never did; cookies were always written correctly and servers were refusing
them.
Deliberately KEPT from #18749, all independent of the UA:
- anti-detection.ts stays deleted. Its premises were measured false on Electron
43 and its overrides are themselves published bot signatures.
- No Runtime.enable into cross-origin iframes (the documented Cloudflare CDP tell).
- No unconditional CDP debugger attach on every browsing guest.
Known tradeoff, measured: this re-opens #13822. On the unmerged predecessor
branch brennan/sta-3905-cloudflare-ua, commit 9f0a4772fe recorded the stock UA
clearing dash.cloudflare.com 5/5 while every rewritten variant failed 12/12, and
noted that adding client hints does not rescue it. So Cloudflare-gated sites will
show verification failures again until a coherent-identity fix lands. That is a
bounded, in-app annoyance; session revocation damages users' real accounts. A
CDP Emulation.setUserAgentOverride with full userAgentMetadata -- which drives
navigator.userAgentData as well as the headers, and was never tested -- is the
candidate that could satisfy both, and is being measured separately.
Tests: the real-Electron wire-identity test now asserts the stripped identity on
ordinary hosts and Firefox on Google auth hosts. Ablation-verified: neutering
cleanElectronUserAgent turns it red on the Electron-token assertion. Its fixture
also gained an app name -- without one the raw UA carried no app token, so the
Orca/x.y.z half of the cleaner was never exercised.
* fix(browser): finish the identity revert in the files CI caught
browser-session-registry.persistence.test.ts still asserted #18749's behaviour
("keeps the stock UA", "keeps the engine UA"), so the shipped code and its test
disagreed. Caught by CI shard 4/8, not locally: I reverted four test files and
went to typecheck without re-running the browser suite.
Also restores the accurate wording that #18749 generalised away, now that the
behaviour it described is back:
- browser-google-auth-ua.ts: names the Electron/Chrome-shaped UA again as what
anti-fraud flags, which is the reason the auth-host switch exists at all.
- docs/browser/profiles.mdx: documents the cleaned Chrome UA default and the
--no-ua-spoof escape hatch, which is real again.
- tests/tools/google-signin-ua-probe.cjs: comments name the live handler.
Deliberately left at #18749's version, because those changes stay correct with
anti-detection.ts deleted:
- browser-manager-viewport.ts: its comment no longer cites the retired
addScriptToEvaluateOnNewDocument injection.
- browser-webauthn-profile-delete.test.ts: its added webRequest mock is REQUIRED
by the restored setupClientHintsOverride, so reverting it would break the test.
* fix(browser): keep restored UA hints browser-owned
---------
Co-authored-by: Merge Sim <sim@local>
(cherry picked from commit fb85f88d64)
This commit is contained in:
committed by
Merge Sim
parent
2385291638
commit
c912eda7d7
@@ -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({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user