mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
merge: origin/main (fb85f88d64) into ssh-remote-integration-stack
This commit is contained in:
@@ -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