mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 08:02:28 +00:00
fix(browser): keep restored UA hints browser-owned
This commit is contained in:
@@ -9,7 +9,7 @@ Browser-use profiles let you run the Orca browser with a specific identity — a
|
||||
1. Open [Settings → Browser → Profiles](/docs/settings).
|
||||
1. Click **Add profile**, give it a name.
|
||||
1. Optionally seed it with cookies, a user-agent, and a viewport size.
|
||||
1. For sites that reject Orca's default Chrome-shaped UA (some Google sign-in flows), create a profile that keeps the **native Electron user agent** instead of spoofing. Default profiles still use the cleaned Chrome UA for broader Cloudflare compatibility.
|
||||
1. Default profiles remove Orca and Electron tokens from the browser engine's user agent, preserving the Chrome-shaped identity expected by imported sessions. This focused compatibility measure does not make the embedded browser identical to Chrome. Google sign-in hosts use a scoped Firefox identity. If a site rejects the cleaned identity, including some Cloudflare-protected sites, create a profile that keeps the **native Electron user agent** instead.
|
||||
|
||||
You can also create a no-spoof profile from the CLI with `orca tab profile create --no-ua-spoof` when you script browser setup.
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Why: Google binds a signed-in session to the browser identity that created it.
|
||||
// Cookies copied in from another browser (or sent under an Electron/Chrome-shaped
|
||||
// UA that doesn't match a real first-party browser) get flagged by anti-fraud on
|
||||
// accounts.google.com and expire within ~1h. Presenting a Firefox identity scoped
|
||||
// Cookies copied in from another browser (or sent under a UA that doesn't match a
|
||||
// real first-party browser) get flagged by anti-fraud on accounts.google.com and
|
||||
// expire within ~1h. Presenting a Firefox identity scoped
|
||||
// to Google's auth hosts lets the user sign in *inside* the embedded browser, so
|
||||
// Google issues cookies bound to THIS browser that self-refresh — instead of us
|
||||
// transplanting cookies that go stale. Scope is deliberately the auth hosts only:
|
||||
// post-auth app surfaces (mail.google.com, myaccount.google.com, drive, etc.) keep
|
||||
// the profile's real Chrome-shaped identity so nothing else about the session shifts.
|
||||
// the profile's real identity so nothing else about the session shifts.
|
||||
|
||||
// Why: exact hostname match — subdomains such as myaccount.google.com are post-auth
|
||||
// app surfaces, not the sign-in flow, and must retain the profile's real identity.
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('browserManager', () => {
|
||||
|
||||
// Why: popup child windows get attachGuestPolicies but are never entered into tabIdByWebContentsId,
|
||||
// so a direct lookup of the UA mode misses the native opt-out. That is worse than doing nothing —
|
||||
// native sessions skip setupClientHintsOverride, so the popup would send the raw Electron UA on the
|
||||
// native sessions skip setupGoogleAuthUserAgentOverride, so the popup would send the raw Electron UA on the
|
||||
// wire while navigator.userAgent claimed Firefox. Google sign-in popups are a first-class surface.
|
||||
it('leaves the UA untouched on auth hosts for a popup owned by a native-UA profile', () => {
|
||||
const ownerGuest = {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { BrowserManagerVisibility } from './browser-manager-visibility'
|
||||
|
||||
export abstract class BrowserManagerNavigation extends BrowserManagerVisibility {
|
||||
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
|
||||
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
|
||||
// not the request header, so the header-level Firefox switch in setupGoogleAuthUserAgentOverride
|
||||
// must be matched here per navigation or the two layers disagree — itself a bot tell.
|
||||
// Restores the session's base identity off the auth hosts. Native-UA profiles opt out
|
||||
// of the whole clean-UA path, so they keep their untouched identity everywhere.
|
||||
@@ -24,7 +24,7 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility
|
||||
const browserPageId = this.tabIdByWebContentsId.get(guest.id)
|
||||
// Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct
|
||||
// lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA.
|
||||
// That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so
|
||||
// That is worse than doing nothing: native sessions skip setupGoogleAuthUserAgentOverride, so
|
||||
// the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox.
|
||||
const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id)
|
||||
// Session state is authoritative before renderer registration and after a native profile imports a source UA.
|
||||
@@ -56,8 +56,8 @@ export abstract class BrowserManagerNavigation extends BrowserManagerVisibility
|
||||
// navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain
|
||||
// cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without
|
||||
// touching the navigation, and it outranks the WebContents UA from then on, so a guest that
|
||||
// switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride
|
||||
// rewrites User-Agent per request for auth-host URLs on its own.
|
||||
// switches to it stays on it. The wire UA never depended on this write:
|
||||
// setupGoogleAuthUserAgentOverride rewrites User-Agent per request for auth-host URLs on its own.
|
||||
if (options.duringRedirect === true || overrideState !== undefined) {
|
||||
if (this.canOverrideUserAgentOverCdp(guest)) {
|
||||
authOverrideIssuedOverCdp = true
|
||||
|
||||
@@ -849,8 +849,7 @@ describe('browserManager', () => {
|
||||
|
||||
expect(debuggerAttach).toHaveBeenCalledWith('1.3')
|
||||
expect(debuggerSendCommand).toHaveBeenCalled()
|
||||
// Why: detaching would clear Page.addScriptToEvaluateOnNewDocument
|
||||
// (anti-detection). Guard regression.
|
||||
// Why: detaching would clear every standing CDP override (viewport, auth UA). Guard regression.
|
||||
expect((guest.debugger as { detach?: unknown }).detach ?? undefined).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ export function createViewportGuestFactory(
|
||||
debugger: {
|
||||
isAttached: debuggerIsAttached,
|
||||
attach: debuggerAttach,
|
||||
sendCommand: debuggerSendCommand
|
||||
sendCommand: debuggerSendCommand,
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -83,7 +83,7 @@ vi.mock('./browser-media-access', () => ({
|
||||
}))
|
||||
vi.mock('./browser-session-ua', () => ({
|
||||
cleanElectronUserAgent: (userAgent: string) => userAgent,
|
||||
setupClientHintsOverride: vi.fn()
|
||||
setupGoogleAuthUserAgentOverride: vi.fn()
|
||||
}))
|
||||
vi.mock('./browser-session-user-agent-mode', () => ({
|
||||
setBrowserSessionUserAgentMode: vi.fn()
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from './browser-session-proxy'
|
||||
import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access'
|
||||
import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy'
|
||||
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
|
||||
import { cleanElectronUserAgent, setupGoogleAuthUserAgentOverride } from './browser-session-ua'
|
||||
import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
|
||||
import {
|
||||
allowsBrowserWebAuthnPermission,
|
||||
@@ -95,7 +95,7 @@ export function installBrowserSessionPartitionPolicies(
|
||||
if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') {
|
||||
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
|
||||
sess.setUserAgent(cleanUA)
|
||||
setupClientHintsOverride(sess, cleanUA)
|
||||
setupGoogleAuthUserAgentOverride(sess)
|
||||
}
|
||||
if (options?.permissions === 'deny') {
|
||||
sess.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false))
|
||||
@@ -192,10 +192,10 @@ export function applyBrowserSessionUserAgentModes(profiles: BrowserSessionProfil
|
||||
continue
|
||||
}
|
||||
|
||||
// Why: the default Electron UA leaks "Electron/X.X.X" + app name, which trips Cloudflare Turnstile.
|
||||
// Why: imported sessions need the same Chrome-shaped identity after app restart.
|
||||
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
|
||||
sess.setUserAgent(cleanUA)
|
||||
setupClientHintsOverride(sess, cleanUA)
|
||||
setupGoogleAuthUserAgentOverride(sess)
|
||||
} catch {
|
||||
/* session not available yet (e.g. unit tests or pre-ready) */
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ vi.mock('./browser-media-access', () => ({
|
||||
}))
|
||||
vi.mock('./browser-session-ua', () => ({
|
||||
cleanElectronUserAgent: vi.fn((ua: string) => ua),
|
||||
setupClientHintsOverride: vi.fn()
|
||||
setupGoogleAuthUserAgentOverride: vi.fn()
|
||||
}))
|
||||
vi.mock('./browser-session-user-agent-mode', () => ({
|
||||
setBrowserSessionUserAgentMode: vi.fn(),
|
||||
|
||||
@@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const USER_DATA = '/user-data'
|
||||
const META_PATH = `${USER_DATA}/browser-session-meta.json`
|
||||
const RAW_ELECTRON_USER_AGENT =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Orca/1.4.198 Chrome/150.0.7871.224 Electron/43.4.1 Safari/537.36'
|
||||
const CLEAN_USER_AGENT =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.7871.224 Safari/537.36'
|
||||
|
||||
type FsState = {
|
||||
files: Map<string, string>
|
||||
@@ -27,7 +31,8 @@ function installModuleMocks(
|
||||
copyFailures = new Set<string>()
|
||||
): {
|
||||
sessionFromPartitionMock: ReturnType<typeof vi.fn>
|
||||
setupClientHintsOverrideMock: ReturnType<typeof vi.fn>
|
||||
cleanElectronUserAgentMock: ReturnType<typeof vi.fn>
|
||||
setupGoogleAuthUserAgentOverrideMock: ReturnType<typeof vi.fn>
|
||||
browserManagerHandleGuestWillDownloadMock: ReturnType<typeof vi.fn>
|
||||
browserManagerNotifyPermissionDeniedMock: ReturnType<typeof vi.fn>
|
||||
requestSystemMediaAccessMock: ReturnType<typeof vi.fn>
|
||||
@@ -35,7 +40,7 @@ function installModuleMocks(
|
||||
const sessionFromPartitionMock = vi.fn((partition: string) => ({
|
||||
partition,
|
||||
setUserAgent: vi.fn(),
|
||||
getUserAgent: vi.fn(() => 'Mozilla/5.0 Electron/31 Orca'),
|
||||
getUserAgent: vi.fn(() => RAW_ELECTRON_USER_AGENT),
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
setDevicePermissionHandler: vi.fn(),
|
||||
@@ -45,7 +50,8 @@ function installModuleMocks(
|
||||
clearStorageData: vi.fn().mockResolvedValue(undefined),
|
||||
clearCache: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
const setupClientHintsOverrideMock = vi.fn()
|
||||
const cleanElectronUserAgentMock = vi.fn(() => CLEAN_USER_AGENT)
|
||||
const setupGoogleAuthUserAgentOverrideMock = vi.fn()
|
||||
const browserManagerHandleGuestWillDownloadMock = vi.fn()
|
||||
const browserManagerNotifyPermissionDeniedMock = vi.fn()
|
||||
const requestSystemMediaAccessMock = vi.fn().mockResolvedValue(true)
|
||||
@@ -119,8 +125,8 @@ function installModuleMocks(
|
||||
requestSystemMediaAccess: requestSystemMediaAccessMock
|
||||
}))
|
||||
vi.doMock('./browser-session-ua', () => ({
|
||||
cleanElectronUserAgent: vi.fn((ua: string) => ua.replace(/\s*Electron\/\S+/, '')),
|
||||
setupClientHintsOverride: setupClientHintsOverrideMock
|
||||
cleanElectronUserAgent: cleanElectronUserAgentMock,
|
||||
setupGoogleAuthUserAgentOverride: setupGoogleAuthUserAgentOverrideMock
|
||||
}))
|
||||
// This suite models replay with an in-memory filesystem. The real file-backed SQLite merge has
|
||||
// dedicated coverage; these fixtures are legacy unmarked images and keep the copy path.
|
||||
@@ -149,7 +155,8 @@ function installModuleMocks(
|
||||
|
||||
return {
|
||||
sessionFromPartitionMock,
|
||||
setupClientHintsOverrideMock,
|
||||
cleanElectronUserAgentMock,
|
||||
setupGoogleAuthUserAgentOverrideMock,
|
||||
browserManagerHandleGuestWillDownloadMock,
|
||||
browserManagerNotifyPermissionDeniedMock,
|
||||
requestSystemMediaAccessMock
|
||||
@@ -236,19 +243,28 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
|
||||
it('keeps UA cleaning as the fallback for profiles without an override', async () => {
|
||||
const fsState = createFsState()
|
||||
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
|
||||
const {
|
||||
sessionFromPartitionMock,
|
||||
cleanElectronUserAgentMock,
|
||||
setupGoogleAuthUserAgentOverrideMock
|
||||
} = installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
await browserSessionRegistry.createProfile('isolated', 'Default identity')
|
||||
|
||||
const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value
|
||||
expect(profileSession.setUserAgent).toHaveBeenCalledWith('Mozilla/5.0 Orca')
|
||||
expect(setupClientHintsOverrideMock).toHaveBeenCalledWith(profileSession, 'Mozilla/5.0 Orca')
|
||||
expect(cleanElectronUserAgentMock).toHaveBeenCalledWith(RAW_ELECTRON_USER_AGENT)
|
||||
expect(profileSession.setUserAgent).toHaveBeenCalledWith(CLEAN_USER_AGENT)
|
||||
expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalledWith(profileSession)
|
||||
})
|
||||
|
||||
it('leaves UA and client hints untouched for native-mode profiles', async () => {
|
||||
const fsState = createFsState()
|
||||
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
|
||||
const {
|
||||
sessionFromPartitionMock,
|
||||
cleanElectronUserAgentMock,
|
||||
setupGoogleAuthUserAgentOverrideMock
|
||||
} = installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
await browserSessionRegistry.createProfile('isolated', 'Google', { userAgentMode: 'native' })
|
||||
@@ -256,7 +272,8 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value
|
||||
const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode')
|
||||
expect(profileSession.setUserAgent).not.toHaveBeenCalled()
|
||||
expect(setupClientHintsOverrideMock).not.toHaveBeenCalled()
|
||||
expect(cleanElectronUserAgentMock).not.toHaveBeenCalled()
|
||||
expect(setupGoogleAuthUserAgentOverrideMock).not.toHaveBeenCalled()
|
||||
expect(getBrowserSessionUserAgentMode(profileSession as never)).toBe('native')
|
||||
})
|
||||
|
||||
@@ -405,7 +422,11 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
|
||||
const {
|
||||
sessionFromPartitionMock,
|
||||
cleanElectronUserAgentMock,
|
||||
setupGoogleAuthUserAgentOverrideMock
|
||||
} = installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
|
||||
@@ -417,12 +438,12 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
expect(appliedUas).not.toContain(validUa)
|
||||
// Why: every non-native profile falls to Orca's own cleaned engine UA.
|
||||
expect(appliedUas.length).toBeGreaterThan(0)
|
||||
expect(appliedUas.every((ua) => ua === 'Mozilla/5.0 Orca')).toBe(true)
|
||||
expect(appliedUas.every((ua) => ua === CLEAN_USER_AGENT)).toBe(true)
|
||||
expect(cleanElectronUserAgentMock).toHaveBeenCalled()
|
||||
expect(
|
||||
setupClientHintsOverrideMock.mock.calls.every(
|
||||
(c: unknown[]) => c[1] !== brokenUa && c[1] !== validUa
|
||||
)
|
||||
cleanElectronUserAgentMock.mock.calls.every(([ua]) => ua === RAW_ELECTRON_USER_AGENT)
|
||||
).toBe(true)
|
||||
expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never applies a legacy persisted UA to a native-mode profile', async () => {
|
||||
@@ -487,7 +508,8 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
]
|
||||
})
|
||||
|
||||
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
|
||||
const { sessionFromPartitionMock, setupGoogleAuthUserAgentOverrideMock } =
|
||||
installModuleMocks(fsState)
|
||||
const { browserSessionRegistry } = await import('./browser-session-registry')
|
||||
|
||||
browserSessionRegistry.initializeBrowserSessionsFromPersistedState()
|
||||
@@ -498,7 +520,7 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
expect(importedSessions.length).toBeGreaterThan(0)
|
||||
expect(importedSessions.every((sess) => sess.setUserAgent.mock.calls.length === 0)).toBe(true)
|
||||
expect(
|
||||
setupClientHintsOverrideMock.mock.calls.some(
|
||||
setupGoogleAuthUserAgentOverrideMock.mock.calls.some(
|
||||
([sess]) => (sess as { partition?: string }).partition === importedPartition
|
||||
)
|
||||
).toBe(false)
|
||||
|
||||
@@ -33,7 +33,7 @@ vi.mock('./browser-manager', () => ({
|
||||
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
import { googleAuthUserAgent } from './browser-google-auth-ua'
|
||||
import { setupClientHintsOverride } from './browser-session-ua'
|
||||
import { setupGoogleAuthUserAgentOverride } from './browser-session-ua'
|
||||
import { setBrowserNetworkProxySettingsResolver } from './browser-session-proxy'
|
||||
import { handleElectronProxyLogin } from '../network/electron-proxy-credentials'
|
||||
import { applyProxySettingsToSession } from '../network/proxy-settings'
|
||||
@@ -528,83 +528,49 @@ describe('BrowserSessionRegistry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupClientHintsOverride', () => {
|
||||
it('overrides sec-ch-ua headers for Edge UA', () => {
|
||||
describe('setupGoogleAuthUserAgentOverride', () => {
|
||||
function install(): (details: unknown, callback: ReturnType<typeof vi.fn>) => void {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
const edgeUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36 Edg/147.0.3210.5'
|
||||
|
||||
setupClientHintsOverride(mockSess, edgeUa)
|
||||
|
||||
setupGoogleAuthUserAgentOverride({ webRequest: { onBeforeSendHeaders } } as never)
|
||||
expect(onBeforeSendHeaders).toHaveBeenCalledWith(
|
||||
{ urls: ['https://*/*'] },
|
||||
expect.any(Function)
|
||||
)
|
||||
return onBeforeSendHeaders.mock.calls[0][1]
|
||||
}
|
||||
|
||||
it('leaves ordinary-host identity headers untouched', () => {
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener(
|
||||
{ requestHeaders: { 'sec-ch-ua': 'old', 'sec-ch-ua-full-version-list': 'old' } },
|
||||
install()(
|
||||
{
|
||||
url: 'https://example.com/',
|
||||
requestHeaders: {
|
||||
'User-Agent': 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36',
|
||||
'sec-ch-ua': 'browser-owned',
|
||||
Cookie: 'abc=123'
|
||||
}
|
||||
},
|
||||
callback
|
||||
)
|
||||
const modified = callback.mock.calls[0][0].requestHeaders
|
||||
expect(modified['sec-ch-ua']).toContain('Microsoft Edge')
|
||||
expect(modified['sec-ch-ua']).toContain('"147"')
|
||||
expect(modified['sec-ch-ua-full-version-list']).toContain('147.0.3210.5')
|
||||
})
|
||||
|
||||
it('overrides sec-ch-ua headers for Chrome UA', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
const chromeUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
|
||||
setupClientHintsOverride(mockSess, chromeUa)
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener({ requestHeaders: { 'sec-ch-ua': 'old' } }, callback)
|
||||
const modified = callback.mock.calls[0][0].requestHeaders
|
||||
expect(modified['sec-ch-ua']).toContain('Google Chrome')
|
||||
expect(modified['sec-ch-ua']).not.toContain('Microsoft Edge')
|
||||
})
|
||||
|
||||
it('registers handler even for non-Chrome UA but leaves sec-ch-ua untouched off auth hosts', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
|
||||
// Why: the Google-auth Firefox switch must install regardless of the base UA.
|
||||
setupClientHintsOverride(mockSess, 'Mozilla/5.0 (compatible; MSIE 10.0)')
|
||||
|
||||
expect(onBeforeSendHeaders).toHaveBeenCalledWith(
|
||||
{ urls: ['https://*/*'] },
|
||||
expect.any(Function)
|
||||
)
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener({ url: 'https://example.com/', requestHeaders: { 'sec-ch-ua': 'old' } }, callback)
|
||||
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toBe('old')
|
||||
expect(callback.mock.calls[0][0].requestHeaders).toEqual({
|
||||
'User-Agent': 'Mozilla/5.0 Chrome/150.0.0.0 Safari/537.36',
|
||||
'sec-ch-ua': 'browser-owned',
|
||||
Cookie: 'abc=123'
|
||||
})
|
||||
})
|
||||
|
||||
it('presents a Firefox UA and strips client hints on Google auth hosts', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
setupClientHintsOverride(
|
||||
mockSess,
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
)
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener(
|
||||
install()(
|
||||
{
|
||||
url: 'https://accounts.google.com/v3/signin/identifier',
|
||||
requestHeaders: {
|
||||
'User-Agent': 'Chrome/147',
|
||||
'sec-ch-ua': 'old',
|
||||
'sec-ch-ua-full-version-list': 'old',
|
||||
'sec-ch-ua-platform': '"macOS"'
|
||||
'SEC-CH-UA-Full-Version-List': 'old',
|
||||
'sec-ch-ua-platform': '"macOS"',
|
||||
Accept: 'text/html'
|
||||
}
|
||||
},
|
||||
callback
|
||||
@@ -612,24 +578,15 @@ describe('BrowserSessionRegistry', () => {
|
||||
const modified = callback.mock.calls[0][0].requestHeaders
|
||||
expect(modified['User-Agent']).toMatch(/Firefox\/\d/)
|
||||
expect(modified['User-Agent']).not.toContain('Chrome')
|
||||
expect(modified['sec-ch-ua']).toBeUndefined()
|
||||
expect(modified['sec-ch-ua-full-version-list']).toBeUndefined()
|
||||
expect(modified['sec-ch-ua-platform']).toBeUndefined()
|
||||
expect(Object.keys(modified).some((key) => key.toLowerCase().startsWith('sec-ch-ua'))).toBe(
|
||||
false
|
||||
)
|
||||
expect(modified.Accept).toBe('text/html')
|
||||
})
|
||||
|
||||
it('strips client hints on a cross-host request that carries the Firefox auth UA', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
setupClientHintsOverride(
|
||||
mockSess,
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
)
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
// Subresource/XHR to a non-auth Google host while the auth document is on
|
||||
// screen: the WebContents Firefox UA leaks onto the request header.
|
||||
listener(
|
||||
install()(
|
||||
{
|
||||
url: 'https://play.google.com/log',
|
||||
requestHeaders: {
|
||||
@@ -651,99 +608,19 @@ describe('BrowserSessionRegistry', () => {
|
||||
expect(modified['sec-ch-ua-mobile']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the clean Chrome identity on cross-host requests that carry the Chrome UA', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
const chromeUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
setupClientHintsOverride(mockSess, chromeUa)
|
||||
|
||||
it('keeps the session identity on Google app subdomains', () => {
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
// Regression guard: non-Google sites (Cloudflare) must keep Chrome hints.
|
||||
listener(
|
||||
install()(
|
||||
{
|
||||
url: 'https://example.com/api',
|
||||
requestHeaders: { 'User-Agent': chromeUa, 'sec-ch-ua': 'old' }
|
||||
url: 'https://myaccount.google.com/',
|
||||
requestHeaders: { 'User-Agent': 'Chrome/150', 'sec-ch-ua': 'browser-owned' }
|
||||
},
|
||||
callback
|
||||
)
|
||||
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome')
|
||||
})
|
||||
|
||||
it('does not strip hints for the Firefox UA when googleAuthOverride is disabled', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
const chromeUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
setupClientHintsOverride(mockSess, chromeUa, { googleAuthOverride: false })
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener(
|
||||
{
|
||||
url: 'https://play.google.com/log',
|
||||
requestHeaders: { 'User-Agent': googleAuthUserAgent(), 'sec-ch-ua': 'old' }
|
||||
},
|
||||
callback
|
||||
)
|
||||
// Imported-native profiles never install the Firefox switch, so the strip
|
||||
// branch stays inert and hints are aligned to Chrome instead.
|
||||
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome')
|
||||
})
|
||||
|
||||
it('keeps Chrome client hints on Google app subdomains (not auth hosts)', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
setupClientHintsOverride(
|
||||
mockSess,
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
)
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener(
|
||||
{ url: 'https://myaccount.google.com/', requestHeaders: { 'sec-ch-ua': 'old' } },
|
||||
callback
|
||||
)
|
||||
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome')
|
||||
})
|
||||
|
||||
it('keeps an imported native UA on auth hosts while aligning its Chrome hints', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
const importedUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
setupClientHintsOverride(mockSess, importedUa, { googleAuthOverride: false })
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener(
|
||||
{
|
||||
url: 'https://accounts.google.com/v3/signin/identifier',
|
||||
requestHeaders: { 'User-Agent': importedUa, 'sec-ch-ua': 'old' }
|
||||
},
|
||||
callback
|
||||
)
|
||||
const modified = callback.mock.calls[0][0].requestHeaders
|
||||
expect(modified['User-Agent']).toBe(importedUa)
|
||||
expect(modified['sec-ch-ua']).toContain('Google Chrome')
|
||||
})
|
||||
|
||||
it('leaves non-Client-Hints headers unchanged', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
setupClientHintsOverride(mockSess, 'Mozilla/5.0 Chrome/147.0.0.0 Safari/537.36')
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener(
|
||||
{ requestHeaders: { Cookie: 'abc=123', 'sec-ch-ua': 'old', Accept: 'text/html' } },
|
||||
callback
|
||||
)
|
||||
const modified = callback.mock.calls[0][0].requestHeaders
|
||||
expect(modified.Cookie).toBe('abc=123')
|
||||
expect(modified.Accept).toBe('text/html')
|
||||
expect(callback.mock.calls[0][0].requestHeaders).toEqual({
|
||||
'User-Agent': 'Chrome/150',
|
||||
'sec-ch-ua': 'browser-owned'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,13 +5,19 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
import {
|
||||
LOCAL_HTTPS_TEST_CERTIFICATE,
|
||||
LOCAL_HTTPS_TEST_PRIVATE_KEY
|
||||
} from './browser-local-https-test-certificate'
|
||||
|
||||
// Why this runs a real Electron: sites that hold a transplanted session re-check the browser
|
||||
// identity that minted it, and an `Orca/x.y.z … Electron/x.y.z` UA is not one any browser sends —
|
||||
// LinkedIn and x.com revoked live sessions over it (STA-7147). The header layer is the only place
|
||||
// that identity can be proven, and the vm-based unit tests cannot see Chromium's header emission
|
||||
// at all. Every partition must therefore strip the Electron and app tokens on the wire for
|
||||
// ordinary hosts and present the Firefox identity on Google's sign-in hosts only.
|
||||
// at all. Every clean-mode partition must therefore strip the Electron and app tokens on the
|
||||
// wire for ordinary hosts and present the Firefox identity on Google's sign-in hosts only. This
|
||||
// focused revocation fix does not claim full Chrome fingerprint parity; native mode remains the
|
||||
// fallback for sites that reject the cleaned identity, including some Turnstile deployments.
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
@@ -28,13 +34,24 @@ const FIXTURE_LAUNCH_ATTEMPTS = 2
|
||||
type CapturedRequest = {
|
||||
url: string
|
||||
userAgent: string | null
|
||||
clientHints: string[]
|
||||
clientHints: Record<string, string>
|
||||
}
|
||||
|
||||
type UserAgentBrand = {
|
||||
brand: string
|
||||
version: string
|
||||
}
|
||||
|
||||
type NavigatorUserAgentData = {
|
||||
brands: UserAgentBrand[]
|
||||
highEntropy: { fullVersionList?: UserAgentBrand[] }
|
||||
}
|
||||
|
||||
type FixtureResult = {
|
||||
rawUserAgent: string
|
||||
sessionUserAgent: string
|
||||
navigatorUserAgent: string
|
||||
navigatorUserAgentData: NavigatorUserAgentData | null
|
||||
requests: CapturedRequest[]
|
||||
}
|
||||
|
||||
@@ -49,8 +66,9 @@ function neverReachedElectronReady(fixtureResult: string): boolean {
|
||||
function buildFixtureMain(modulePath: string, resultPath: string): string {
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { createServer } = require('node:https')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const { cleanElectronUserAgent, setupClientHintsOverride } = require(${JSON.stringify(modulePath)})
|
||||
const { cleanElectronUserAgent, setupGoogleAuthUserAgentOverride } = require(${JSON.stringify(modulePath)})
|
||||
const resultPath = ${JSON.stringify(resultPath)}
|
||||
// Why: production's UA carries an app token ("Orca/1.4.198") between the engine comment and
|
||||
// Chrome/, and an unnamed fixture emits none — which would leave half of cleanElectronUserAgent
|
||||
@@ -75,39 +93,69 @@ async function run() {
|
||||
const rawUserAgent = sess.getUserAgent()
|
||||
const cleanUa = cleanElectronUserAgent(rawUserAgent)
|
||||
sess.setUserAgent(cleanUa)
|
||||
setupClientHintsOverride(sess, cleanUa)
|
||||
setupGoogleAuthUserAgentOverride(sess)
|
||||
mark('clean identity installed')
|
||||
|
||||
// Why: onSendHeaders reports the headers exactly as they leave the network stack, after the
|
||||
// product's onBeforeSendHeaders listener has rewritten them. The requests must actually be
|
||||
// dispatched for it to fire, so the session is pointed at a proxy that refuses every
|
||||
// connection: nothing reaches the real hosts and every load fails fast.
|
||||
await sess.setProxy({ proxyRules: 'http://127.0.0.1:9', proxyBypassRules: '<-loopback>' })
|
||||
sess.setCertificateVerifyProc((_request, callback) => callback(0))
|
||||
const requests = []
|
||||
sess.webRequest.onSendHeaders({ urls: ['https://*/*'] }, (details) => {
|
||||
const headers = details.requestHeaders || {}
|
||||
const uaKey = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent')
|
||||
const clientHints = {}
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase().startsWith('sec-ch-ua')) {
|
||||
clientHints[key.toLowerCase()] = value
|
||||
}
|
||||
}
|
||||
requests.push({
|
||||
url: details.url,
|
||||
userAgent: uaKey ? headers[uaKey] : null,
|
||||
clientHints: Object.keys(headers)
|
||||
.filter((key) => key.toLowerCase().startsWith('sec-ch-ua'))
|
||||
.sort()
|
||||
clientHints
|
||||
})
|
||||
})
|
||||
|
||||
const server = createServer(
|
||||
{
|
||||
cert: ${JSON.stringify(LOCAL_HTTPS_TEST_CERTIFICATE)},
|
||||
key: ${JSON.stringify(LOCAL_HTTPS_TEST_PRIVATE_KEY)}
|
||||
},
|
||||
(_request, response) => {
|
||||
response.setHeader('Accept-CH', 'Sec-CH-UA-Full-Version-List')
|
||||
response.end('<!doctype html><title>identity</title>')
|
||||
}
|
||||
)
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const origin = 'https://127.0.0.1:' + server.address().port
|
||||
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
|
||||
mark('window created')
|
||||
for (const url of ['https://example.com/', 'https://accounts.google.com/v3/signin/identifier']) {
|
||||
await window.loadURL(url).catch(() => {})
|
||||
let navigatorUserAgent
|
||||
let navigatorUserAgentData
|
||||
try {
|
||||
await window.loadURL(origin + '/')
|
||||
navigatorUserAgent = await window.webContents.executeJavaScript('navigator.userAgent')
|
||||
navigatorUserAgentData = await window.webContents.executeJavaScript(
|
||||
"(async () => { const data = navigator.userAgentData; return data ? { brands: data.brands, highEntropy: await data.getHighEntropyValues(['fullVersionList']) } : null })()"
|
||||
)
|
||||
await window.webContents.executeJavaScript(
|
||||
'fetch("/hints").then((response) => response.text())'
|
||||
)
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
|
||||
// Dispatch a real auth-host request without allowing it to reach the Internet.
|
||||
await sess.setProxy({ proxyRules: 'http://127.0.0.1:9', proxyBypassRules: '<-loopback>' })
|
||||
await window.loadURL('https://accounts.google.com/v3/signin/identifier').catch(() => {})
|
||||
mark('navigations attempted')
|
||||
const navigatorUserAgent = await window.webContents.executeJavaScript('navigator.userAgent')
|
||||
clearTimeout(timeout)
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
rawUserAgent,
|
||||
sessionUserAgent: sess.getUserAgent(),
|
||||
navigatorUserAgent,
|
||||
navigatorUserAgentData,
|
||||
requests
|
||||
}))
|
||||
window.destroy()
|
||||
@@ -167,6 +215,13 @@ async function runFixture(): Promise<FixtureResult> {
|
||||
}
|
||||
}
|
||||
|
||||
function parseClientHintBrands(value: string): UserAgentBrand[] {
|
||||
return [...value.matchAll(/"([^"]+)";v="([^"]+)"/g)].map((match) => ({
|
||||
brand: match[1],
|
||||
version: match[2]
|
||||
}))
|
||||
}
|
||||
|
||||
describe('browser session wire identity under Electron', () => {
|
||||
it('strips the Electron and app tokens for ordinary hosts and sends Firefox to Google auth hosts', async () => {
|
||||
const result = await runFixture()
|
||||
@@ -181,10 +236,26 @@ describe('browser session wire identity under Electron', () => {
|
||||
expect(result.sessionUserAgent).not.toContain('Electron/')
|
||||
expect(result.sessionUserAgent).toMatch(/\(KHTML, like Gecko\) Chrome\/[\d.]+ Safari\/537\.36$/)
|
||||
|
||||
const ordinary = result.requests.find((request) => request.url === 'https://example.com/')
|
||||
const ordinary = result.requests.find((request) => request.url.endsWith('/hints'))
|
||||
expect(ordinary, JSON.stringify(result.requests)).toBeDefined()
|
||||
expect(ordinary?.userAgent).toBe(result.sessionUserAgent)
|
||||
expect(result.navigatorUserAgent).toBe(result.sessionUserAgent)
|
||||
expect(result.navigatorUserAgentData).not.toBeNull()
|
||||
|
||||
// Chromium owns both client-hint surfaces. Rewriting only the request headers would make this
|
||||
// comparison fail while leaving the legacy UA assertions above green.
|
||||
const wireBrands = parseClientHintBrands(ordinary?.clientHints['sec-ch-ua'] ?? '')
|
||||
expect(wireBrands).toEqual(result.navigatorUserAgentData?.brands)
|
||||
expect(wireBrands.some(({ brand }) => /Electron|Orca/i.test(brand))).toBe(false)
|
||||
const chromeMajor = result.sessionUserAgent.match(/Chrome\/(\d+)/)?.[1]
|
||||
expect(wireBrands.find(({ brand }) => brand === 'Chromium')?.version).toBe(chromeMajor)
|
||||
|
||||
const fullVersionList = ordinary?.clientHints['sec-ch-ua-full-version-list']
|
||||
if (fullVersionList) {
|
||||
expect(parseClientHintBrands(fullVersionList)).toEqual(
|
||||
result.navigatorUserAgentData?.highEntropy.fullVersionList
|
||||
)
|
||||
}
|
||||
|
||||
const auth = result.requests.find((request) =>
|
||||
request.url.startsWith('https://accounts.google.com/')
|
||||
@@ -192,6 +263,6 @@ describe('browser session wire identity under Electron', () => {
|
||||
expect(auth, JSON.stringify(result.requests)).toBeDefined()
|
||||
expect(auth?.userAgent).toMatch(/Firefox\/\d/)
|
||||
expect(auth?.userAgent).not.toContain('Chrome')
|
||||
expect(auth?.clientHints).toEqual([])
|
||||
expect(auth?.clientHints).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
} from './browser-google-auth-ua'
|
||||
|
||||
// Why: Electron's default UA includes "Electron/X.X.X" and the app name
|
||||
// (e.g. "orca/1.2.3"), which Cloudflare Turnstile and other bot detectors
|
||||
// flag as non-human traffic. Strip those tokens so the webview's UA and
|
||||
// sec-ch-ua Client Hints look like standard Chrome.
|
||||
// (e.g. "orca/1.2.3"), an impossible identity for sessions imported from Chrome.
|
||||
// This focused revocation fix strips only those tokens; it does not attempt full Chrome
|
||||
// impersonation, and Chromium's client-hint identity remains browser-owned.
|
||||
export function cleanElectronUserAgent(ua: string): string {
|
||||
return (
|
||||
ua
|
||||
@@ -22,25 +22,15 @@ export function cleanElectronUserAgent(ua: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
// Why: Electron emits sec-ch-ua brands like "Not A(Brand" without a
|
||||
// "Google Chrome" entry, which disagrees with the Chrome-shaped UA the session
|
||||
// presents. Rewrite the hint headers to the brand set Chrome ships for the same
|
||||
// engine version so the two surfaces tell one story. Also owns the Google
|
||||
// auth-host Firefox switch, which must install even for a non-Chrome-shaped UA.
|
||||
export function setupClientHintsOverride(
|
||||
sess: Session,
|
||||
ua: string,
|
||||
options: { googleAuthOverride?: boolean } = {}
|
||||
): void {
|
||||
// Why: only Chrome-shaped base UAs carry sec-ch-ua hints to rewrite, but the
|
||||
// Google-auth Firefox switch below must install regardless, so keep the hints
|
||||
// optional rather than bailing out of the whole handler.
|
||||
const chromeHints = buildChromeClientHints(ua)
|
||||
// Why: Chromium already publishes one internally consistent client-hint identity through both
|
||||
// request headers and navigator.userAgentData. This handler only owns the host-scoped Firefox
|
||||
// exception; synthesizing Chrome brands here would make those two browser-owned surfaces disagree.
|
||||
export function setupGoogleAuthUserAgentOverride(sess: Session): void {
|
||||
const firefoxUa = googleAuthUserAgent()
|
||||
|
||||
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
|
||||
const headers = details.requestHeaders
|
||||
if (options.googleAuthOverride !== false && isGoogleAuthUrl(details.url)) {
|
||||
if (isGoogleAuthUrl(details.url)) {
|
||||
// Why: present a Firefox identity on Google's sign-in hosts so the user logs
|
||||
// in inside the app and Google issues self-refreshing bound cookies. Strip
|
||||
// sec-ch-ua* because real Firefox sends none.
|
||||
@@ -49,7 +39,7 @@ export function setupClientHintsOverride(
|
||||
callback({ requestHeaders: headers })
|
||||
return
|
||||
}
|
||||
if (options.googleAuthOverride !== false && currentUserAgent(headers) === firefoxUa) {
|
||||
if (currentUserAgent(headers) === firefoxUa) {
|
||||
// Why: while the auth document is on screen the WebContents UA is Firefox,
|
||||
// so its cross-host subresource/XHR requests (gstatic, play.google.com, the
|
||||
// sign-in challenge endpoints) reach here carrying the Firefox UA yet still
|
||||
@@ -61,40 +51,6 @@ export function setupClientHintsOverride(
|
||||
callback({ requestHeaders: headers })
|
||||
return
|
||||
}
|
||||
if (chromeHints) {
|
||||
for (const key of Object.keys(headers)) {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === 'sec-ch-ua') {
|
||||
headers[key] = chromeHints.secChUa
|
||||
} else if (lower === 'sec-ch-ua-full-version-list') {
|
||||
headers[key] = chromeHints.secChUaFull
|
||||
}
|
||||
}
|
||||
}
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
}
|
||||
|
||||
function buildChromeClientHints(ua: string): { secChUa: string; secChUaFull: string } | null {
|
||||
const chromeMatch = ua.match(/Chrome\/([\d.]+)/)
|
||||
if (!chromeMatch) {
|
||||
return null
|
||||
}
|
||||
const fullChromeVersion = chromeMatch[1]
|
||||
const majorVersion = fullChromeVersion.split('.')[0]
|
||||
|
||||
let brand = 'Google Chrome'
|
||||
let brandFullVersion = fullChromeVersion
|
||||
|
||||
const edgeMatch = ua.match(/Edg\/([\d.]+)/)
|
||||
if (edgeMatch) {
|
||||
brand = 'Microsoft Edge'
|
||||
brandFullVersion = edgeMatch[1]
|
||||
}
|
||||
const brandMajor = brandFullVersion.split('.')[0]
|
||||
|
||||
return {
|
||||
secChUa: `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`,
|
||||
secChUaFull: `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export type ViewportUserAgentOverride = {
|
||||
}
|
||||
|
||||
// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real
|
||||
// Chrome major spliced in to keep sec-ch-ua consistent (see setupClientHintsOverride).
|
||||
// Chrome major spliced in so the userAgentMetadata brands below agree with it.
|
||||
function buildMobileUserAgent(chromeMajor: string): string {
|
||||
return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1`
|
||||
}
|
||||
|
||||
@@ -10,10 +10,9 @@ const MODES = new Set([
|
||||
'electron-fixed',
|
||||
'firefox-auth',
|
||||
'firefox-fixed',
|
||||
// Replicates the SHIPPED app exactly (setupClientHintsOverride +
|
||||
// applyGoogleAuthUserAgent): Firefox UA is written to the WebContents on auth
|
||||
// navs and to the request header only for auth-host URLs; every other request
|
||||
// keeps whatever UA the WebContents carries. Logs incoming vs outgoing
|
||||
// Replicates the app before and after the cross-host fix. Firefox UA is written
|
||||
// to the WebContents on auth navs and to the request header only for auth-host
|
||||
// URLs; every other request keeps whatever UA the WebContents carries. Logs incoming vs outgoing
|
||||
// identity for ALL requests to expose cross-host mismatches during the flow.
|
||||
'app-current',
|
||||
// Same, but with the STA-3811 fix: the header layer strips client hints on any
|
||||
@@ -171,7 +170,7 @@ app.whenReady().then(async () => {
|
||||
const incoming = relevantHeaders(headers)
|
||||
|
||||
if (isAppMode) {
|
||||
// Mirror setupClientHintsOverride: only auth-host URLs get the Firefox UA
|
||||
// Mirror setupGoogleAuthUserAgentOverride: only auth-host URLs get the Firefox UA
|
||||
// header + hint strip; every other request keeps its incoming UA (which is
|
||||
// the WebContents UA — Firefox while the auth document is on screen).
|
||||
if (isGoogleAuthUrl(details.url)) {
|
||||
@@ -184,10 +183,6 @@ app.whenReady().then(async () => {
|
||||
// instead of rewriting to Chrome — keeping UA and hints one story.
|
||||
if (mode === 'app-fixed' && currentUa === identities.firefox) {
|
||||
removeClientHints(headers)
|
||||
} else {
|
||||
// Real setupClientHintsOverride builds Chrome hints once from the
|
||||
// session's cleaned UA (a closure), never from the per-request UA.
|
||||
applyChromeClientHints(headers, identities.cleaned)
|
||||
}
|
||||
}
|
||||
const outgoing = relevantHeaders(headers)
|
||||
|
||||
Reference in New Issue
Block a user