fix(browser): present Firefox UA on Google auth hosts to end ~1h sign-in staleness (#12884)
Google binds a signed-in session to the browser identity that created it, and rejected Orca's Electron/Chrome-shaped UA on accounts.google.com — so sessions died at ~1h and re-copying cookies only reset the same losing clock. Present a Firefox identity scoped to the Google/YouTube sign-in hosts at every layer Google probes: the request headers (with sec-ch-ua* stripped, since real Firefox sends none), the per-WebContents UA that auth JS reads, and the CDP emulation override a viewport preset installs. All three must agree — a disagreement between them is a sharper bot signal than any single wrong UA. Native-UA profiles opt out at every layer, including popup child windows and after a browser-native cookie import. Validated live against a real account: freshness cookies issued and rotating on a ~10-min cadence, session still signed in and self-renewing past 10 hours, against a pre-fix baseline of death at ~1h. Closes #11518.
This commit is contained in:
parent
ddf58d6d6a
commit
419151dddd
|
|
@ -74,6 +74,7 @@ import type {
|
|||
BrowserSessionProfileSource
|
||||
} from '../../shared/types'
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
|
||||
import { setupClientHintsOverride } from './browser-session-ua'
|
||||
import {
|
||||
isGoogleSourceBoundCookie,
|
||||
|
|
@ -1876,7 +1877,9 @@ export async function importCookiesFromBrowser(
|
|||
const ua = getUserAgentForBrowser(browser.family)
|
||||
if (ua) {
|
||||
targetSession.setUserAgent(ua)
|
||||
setupClientHintsOverride(targetSession, ua)
|
||||
setupClientHintsOverride(targetSession, ua, {
|
||||
googleAuthOverride: getBrowserSessionUserAgentMode(targetSession) !== 'native'
|
||||
})
|
||||
browserSessionRegistry.persistUserAgent(targetPartition, ua)
|
||||
diag(` set UA for partition: ${ua.substring(0, 80)}...`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
googleAuthUserAgent,
|
||||
isGoogleAuthUrl,
|
||||
setUserAgentHeader,
|
||||
stripClientHints
|
||||
} from './browser-google-auth-ua'
|
||||
|
||||
describe('isGoogleAuthUrl', () => {
|
||||
it('matches the Google/YouTube sign-in hosts exactly', () => {
|
||||
expect(isGoogleAuthUrl('https://accounts.google.com/')).toBe(true)
|
||||
expect(isGoogleAuthUrl('https://accounts.google.com/v3/signin/identifier')).toBe(true)
|
||||
expect(isGoogleAuthUrl('https://accounts.youtube.com/signin')).toBe(true)
|
||||
expect(isGoogleAuthUrl('https://ACCOUNTS.GOOGLE.COM/')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match post-auth app subdomains or lookalikes', () => {
|
||||
expect(isGoogleAuthUrl('https://myaccount.google.com/')).toBe(false)
|
||||
expect(isGoogleAuthUrl('https://mail.google.com/')).toBe(false)
|
||||
expect(isGoogleAuthUrl('https://accounts.google.com.evil.test/')).toBe(false)
|
||||
expect(isGoogleAuthUrl('https://google.com/')).toBe(false)
|
||||
expect(isGoogleAuthUrl('not a url')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('googleAuthUserAgent', () => {
|
||||
it('produces an internally consistent Firefox UA for the host platform', () => {
|
||||
const ua = googleAuthUserAgent()
|
||||
expect(ua).toMatch(/^Mozilla\/5\.0 \(.+; rv:\d+\.0\) Gecko\/20100101 Firefox\/\d+\.0$/)
|
||||
expect(ua).not.toContain('Chrome')
|
||||
expect(ua).not.toContain('Electron')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripClientHints', () => {
|
||||
it('removes every sec-ch-ua* header regardless of case, keeps others', () => {
|
||||
const headers: Record<string, string> = {
|
||||
'sec-ch-ua': 'a',
|
||||
'Sec-CH-UA-Platform': 'b',
|
||||
'sec-ch-ua-full-version-list': 'c',
|
||||
'User-Agent': 'ua',
|
||||
Accept: 'text/html'
|
||||
}
|
||||
stripClientHints(headers)
|
||||
expect(Object.keys(headers).some((k) => k.toLowerCase().startsWith('sec-ch-ua'))).toBe(false)
|
||||
expect(headers['User-Agent']).toBe('ua')
|
||||
expect(headers.Accept).toBe('text/html')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setUserAgentHeader', () => {
|
||||
it('overwrites an existing user-agent header in place, preserving its casing', () => {
|
||||
const headers: Record<string, string> = { 'user-agent': 'old' }
|
||||
setUserAgentHeader(headers, 'new')
|
||||
expect(headers['user-agent']).toBe('new')
|
||||
expect(Object.keys(headers)).toEqual(['user-agent'])
|
||||
})
|
||||
|
||||
it('adds a canonical header when none exists', () => {
|
||||
const headers: Record<string, string> = {}
|
||||
setUserAgentHeader(headers, 'new')
|
||||
expect(headers['User-Agent']).toBe('new')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
// 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
|
||||
// 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.
|
||||
|
||||
// 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.
|
||||
const GOOGLE_AUTH_HOSTS = new Set(['accounts.google.com', 'accounts.youtube.com'])
|
||||
|
||||
export function isGoogleAuthUrl(rawUrl: string): boolean {
|
||||
try {
|
||||
return GOOGLE_AUTH_HOSTS.has(new URL(rawUrl).hostname.toLowerCase())
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Why: rv:/Gecko/Firefox tokens must line up with a real released build and the
|
||||
// platform token must match the host OS, or the UA is internally inconsistent and
|
||||
// itself a bot tell.
|
||||
export function googleAuthUserAgent(): string {
|
||||
const platform =
|
||||
process.platform === 'darwin'
|
||||
? 'Macintosh; Intel Mac OS X 10.15'
|
||||
: process.platform === 'win32'
|
||||
? 'Windows NT 10.0; Win64; x64'
|
||||
: 'X11; Linux x86_64'
|
||||
return `Mozilla/5.0 (${platform}; rv:140.0) Gecko/20100101 Firefox/140.0`
|
||||
}
|
||||
|
||||
// Why: real Firefox emits no sec-ch-ua* client hints; leaving Chromium's hints on a
|
||||
// Firefox UA is a sharper mismatch than either signal alone.
|
||||
export function stripClientHints(headers: Record<string, string>): void {
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase().startsWith('sec-ch-ua')) {
|
||||
delete headers[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the user-agent request header may already carry a base identity under a
|
||||
// different case; overwrite the existing key rather than adding a duplicate.
|
||||
export function setUserAgentHeader(headers: Record<string, string>, value: string): void {
|
||||
const existing = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent')
|
||||
headers[existing ?? 'User-Agent'] = value
|
||||
}
|
||||
|
|
@ -55,9 +55,18 @@ vi.mock('./popup-origin-bar-window', () => ({
|
|||
}))
|
||||
|
||||
import { browserManager } from './browser-manager'
|
||||
import { googleAuthUserAgent } from './browser-google-auth-ua'
|
||||
import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
|
||||
|
||||
describe('browserManager', () => {
|
||||
const rendererWebContentsId = 5001
|
||||
// Base (non-Firefox) UA a guest reports off the Google auth hosts.
|
||||
const guestBaseUserAgent = 'Mozilla/5.0 (Test) Chrome/140.0.0.0'
|
||||
const guestUaMethods = () => ({
|
||||
getUserAgent: vi.fn(() => guestBaseUserAgent),
|
||||
setUserAgent: vi.fn(),
|
||||
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
|
||||
})
|
||||
type DownloadItemHandlerState = 'progressing' | 'interrupted' | 'completed' | 'cancelled'
|
||||
type DownloadItemHandler = (event: Electron.Event, state: DownloadItemHandlerState) => void
|
||||
|
||||
|
|
@ -1610,7 +1619,8 @@ describe('browserManager', () => {
|
|||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'http://localhost:3000/')
|
||||
getURL: vi.fn(() => 'http://localhost:3000/'),
|
||||
...guestUaMethods()
|
||||
}
|
||||
|
||||
webContentsFromIdMock.mockImplementation((id: number) => {
|
||||
|
|
@ -1686,7 +1696,8 @@ describe('browserManager', () => {
|
|||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://example.com/')
|
||||
getURL: vi.fn(() => 'https://example.com/'),
|
||||
...guestUaMethods()
|
||||
}
|
||||
webContentsFromIdMock.mockImplementation((id: number) =>
|
||||
id === guest.id
|
||||
|
|
@ -1738,7 +1749,8 @@ describe('browserManager', () => {
|
|||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
send: vi.fn(),
|
||||
getURL: vi.fn(() => 'chrome-error://chromewebdata/')
|
||||
getURL: vi.fn(() => 'chrome-error://chromewebdata/'),
|
||||
...guestUaMethods()
|
||||
}
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
|
||||
|
|
@ -1780,7 +1792,8 @@ describe('browserManager', () => {
|
|||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
send: vi.fn(),
|
||||
getURL: vi.fn(() => 'chrome-error://chromewebdata/')
|
||||
getURL: vi.fn(() => 'chrome-error://chromewebdata/'),
|
||||
...guestUaMethods()
|
||||
}
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
|
||||
|
|
@ -1819,6 +1832,172 @@ describe('browserManager', () => {
|
|||
expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')).toBeNull()
|
||||
})
|
||||
|
||||
it('presents the Firefox UA on Google auth hosts and restores the base UA off them', () => {
|
||||
let currentUa = guestBaseUserAgent
|
||||
const setUserAgent = vi.fn((ua: string) => {
|
||||
currentUa = ua
|
||||
})
|
||||
const guest = {
|
||||
id: 408,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://accounts.google.com/'),
|
||||
getUserAgent: vi.fn(() => currentUa),
|
||||
setUserAgent,
|
||||
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
|
||||
}
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-auth-ua',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
setUserAgent.mockClear()
|
||||
|
||||
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
|
||||
expect(setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent())
|
||||
|
||||
// Off the auth host, the guest's base identity is restored.
|
||||
didStartNavigation(null, 'https://myaccount.google.com/', false, true)
|
||||
expect(setUserAgent).toHaveBeenLastCalledWith(guestBaseUserAgent)
|
||||
|
||||
// A navigation that doesn't change the required UA must not thrash setUserAgent.
|
||||
setUserAgent.mockClear()
|
||||
didStartNavigation(null, 'https://example.com/', false, true)
|
||||
expect(setUserAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves the UA untouched on Google auth hosts for native-UA profiles', () => {
|
||||
const setUserAgent = vi.fn()
|
||||
const guest = {
|
||||
id: 409,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://accounts.google.com/'),
|
||||
getUserAgent: vi.fn(() => guestBaseUserAgent),
|
||||
setUserAgent,
|
||||
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
|
||||
}
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-native-ua',
|
||||
webContentsId: guest.id,
|
||||
rendererWebContentsId,
|
||||
userAgentMode: 'native'
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
setUserAgent.mockClear()
|
||||
|
||||
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
|
||||
expect(setUserAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors native session mode before the guest registration IPC arrives', () => {
|
||||
const nativeSession = { getUserAgent: vi.fn(() => guestBaseUserAgent) }
|
||||
setBrowserSessionUserAgentMode(nativeSession as never, 'native')
|
||||
const setUserAgent = vi.fn()
|
||||
const guest = {
|
||||
id: 417,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://accounts.google.com/'),
|
||||
getUserAgent: vi.fn(() => guestBaseUserAgent),
|
||||
setUserAgent,
|
||||
session: nativeSession
|
||||
}
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
|
||||
expect(setUserAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// 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
|
||||
// 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,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://accounts.google.com/'),
|
||||
getUserAgent: vi.fn(() => guestBaseUserAgent),
|
||||
setUserAgent: vi.fn(),
|
||||
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
|
||||
}
|
||||
webContentsFromIdMock.mockReturnValue(ownerGuest)
|
||||
browserManager.attachGuestPolicies(ownerGuest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'browser-native-popup-owner',
|
||||
webContentsId: ownerGuest.id,
|
||||
rendererWebContentsId,
|
||||
userAgentMode: 'native'
|
||||
})
|
||||
|
||||
// The popup carries its own listeners so its handler is unambiguous.
|
||||
const popupOn = vi.fn()
|
||||
const popupSetUserAgent = vi.fn()
|
||||
const popupGuest = {
|
||||
id: 416,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'window'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: popupOn,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://accounts.google.com/'),
|
||||
getUserAgent: vi.fn(() => guestBaseUserAgent),
|
||||
setUserAgent: popupSetUserAgent,
|
||||
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
|
||||
}
|
||||
browserManager.attachGuestPolicies(popupGuest as never, {
|
||||
browserTabId: 'browser-native-popup-owner',
|
||||
rootGuestWebContentsId: ownerGuest.id
|
||||
})
|
||||
|
||||
const popupDidStartNavigation = popupOn.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
expect(popupDidStartNavigation).toBeDefined()
|
||||
|
||||
popupDidStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
|
||||
expect(popupSetUserAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues permission denials and download requests until the guest registers', () => {
|
||||
const rendererSendMock = vi.fn()
|
||||
const guest = {
|
||||
|
|
@ -2806,23 +2985,46 @@ describe('browserManager', () => {
|
|||
})
|
||||
|
||||
describe('setViewportOverride', () => {
|
||||
function makeGuest(id: number): {
|
||||
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'
|
||||
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.
|
||||
async function flushViewportOps(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function makeGuest(
|
||||
id: number,
|
||||
url = 'https://example.com/'
|
||||
): {
|
||||
guest: Record<string, unknown>
|
||||
debuggerSendCommand: ReturnType<typeof vi.fn>
|
||||
debuggerIsAttached: ReturnType<typeof vi.fn>
|
||||
debuggerAttach: ReturnType<typeof vi.fn>
|
||||
setGuestUserAgent: (ua: string) => void
|
||||
commitNavigationTo: (nextUrl: string) => void
|
||||
} {
|
||||
const debuggerSendCommand = vi.fn().mockResolvedValue(undefined)
|
||||
const debuggerIsAttached = vi.fn(() => true)
|
||||
const debuggerAttach = vi.fn()
|
||||
let currentUa = GUEST_ELECTRON_UA
|
||||
// Why: getURL() reports the last COMMITTED url — it does not move at did-start-navigation.
|
||||
let committedUrl = url
|
||||
const guest = {
|
||||
id,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
getUserAgent: vi.fn(
|
||||
() =>
|
||||
'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'
|
||||
),
|
||||
getURL: vi.fn(() => committedUrl),
|
||||
getUserAgent: vi.fn(() => currentUa),
|
||||
setUserAgent: vi.fn((ua: string) => {
|
||||
currentUa = ua
|
||||
}),
|
||||
session: { getUserAgent: vi.fn(() => GUEST_ELECTRON_UA) },
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
|
|
@ -2835,7 +3037,18 @@ describe('browserManager', () => {
|
|||
sendCommand: debuggerSendCommand
|
||||
}
|
||||
}
|
||||
return { guest, debuggerSendCommand, debuggerIsAttached, debuggerAttach }
|
||||
return {
|
||||
guest,
|
||||
debuggerSendCommand,
|
||||
debuggerIsAttached,
|
||||
debuggerAttach,
|
||||
setGuestUserAgent: (ua: string) => {
|
||||
currentUa = ua
|
||||
},
|
||||
commitNavigationTo: (nextUrl: string) => {
|
||||
committedUrl = nextUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('returns false when the tab is not registered', async () => {
|
||||
|
|
@ -2921,6 +3134,585 @@ describe('browserManager', () => {
|
|||
}
|
||||
)
|
||||
|
||||
// Why: the CDP override outranks setUserAgent for navigator.userAgent, so a preset applied on an
|
||||
// auth host must carry the same Firefox identity the header hook sends (verified against real
|
||||
// Electron 43: Emulation.setUserAgentOverride wins over WebContents.setUserAgent and stands
|
||||
// across every later navigation until explicitly cleared).
|
||||
it.each([false, true])(
|
||||
'presents the Firefox UA for a preset applied on a Google auth host (mobile=%s)',
|
||||
async (mobile) => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(
|
||||
mobile ? 4246 : 4245,
|
||||
'https://accounts.google.com/v3/signin/identifier'
|
||||
)
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: `tab-auth-${mobile}`,
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
await browserManager.setViewportOverride(`tab-auth-${mobile}`, {
|
||||
width: mobile ? 375 : 1024,
|
||||
height: mobile ? 667 : 768,
|
||||
deviceScaleFactor: mobile ? 2 : 1,
|
||||
mobile
|
||||
})
|
||||
|
||||
expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('re-issues the standing UA override when navigating onto and back off an auth host', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4247)
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-auth-nav',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
// Case A: the desktop preset lands first, while the tab is still off the auth host.
|
||||
await browserManager.setViewportOverride('tab-auth-nav', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
expect(debuggerSendCommand).toHaveBeenLastCalledWith('Emulation.setUserAgentOverride', {
|
||||
userAgent: GUEST_CLEAN_UA
|
||||
})
|
||||
|
||||
// Navigating to the auth host must move the standing override to the Firefox identity.
|
||||
debuggerSendCommand.mockClear()
|
||||
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
|
||||
await flushViewportOps()
|
||||
expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
|
||||
// Leaving the auth host restores the clean Chrome-shaped preset UA.
|
||||
debuggerSendCommand.mockClear()
|
||||
didStartNavigation(null, 'https://example.com/', false, true)
|
||||
await flushViewportOps()
|
||||
expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
|
||||
userAgent: GUEST_CLEAN_UA
|
||||
})
|
||||
})
|
||||
|
||||
// Why: not an ordering race — debugger.sendCommand dispatches in call order over one channel, so
|
||||
// the later-issued write always wins. The defect is post-await staleness: verified against real
|
||||
// Electron 43.1.0, did-start-navigation fires while an awaited sendCommand is still pending, and
|
||||
// getURL() keeps reporting the OUTGOING page until commit. A preset resuming mid-navigation
|
||||
// therefore resolves the wrong host and wins with the wrong value. Both writers must resolve the
|
||||
// host from the in-flight navigation target instead.
|
||||
function lastUserAgentOverride(
|
||||
debuggerSendCommand: ReturnType<typeof vi.fn>
|
||||
): Record<string, unknown> | undefined {
|
||||
const calls = debuggerSendCommand.mock.calls.filter(
|
||||
(call) => call[0] === 'Emulation.setUserAgentOverride'
|
||||
)
|
||||
return calls.at(-1)?.[1] as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
// Why mobile: on the desktop branch the break is masked by coincidence — applyGoogleAuthUserAgent
|
||||
// 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.
|
||||
let releaseMetrics = (): void => {}
|
||||
const metricsGate = new Promise<void>((resolve) => {
|
||||
releaseMetrics = () => resolve()
|
||||
})
|
||||
debuggerSendCommand.mockImplementation((method: string) =>
|
||||
method === 'Emulation.setDeviceMetricsOverride' ? metricsGate : Promise.resolve(undefined)
|
||||
)
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-race-onto-auth',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
const presetDone = browserManager.setViewportOverride('tab-race-onto-auth', {
|
||||
width: 375,
|
||||
height: 667,
|
||||
deviceScaleFactor: 2,
|
||||
mobile: true
|
||||
})
|
||||
await flushViewportOps()
|
||||
|
||||
// The tab navigates to the auth host while the preset is still awaiting its first command.
|
||||
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
|
||||
releaseMetrics()
|
||||
await presetDone
|
||||
await flushViewportOps()
|
||||
|
||||
// Without a shared chain + shared URL source, the preset resumes, reads getURL() as the
|
||||
// pre-navigation host, and pins the tab to the Chrome-shaped UA while it is on the auth host.
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not leave the Firefox UA standing when a preset lands mid-navigation off an auth host', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4252, 'https://accounts.google.com/')
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-race-off-auth',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
// Establish a standing preset while the tab really is on the auth host.
|
||||
await browserManager.setViewportOverride('tab-race-off-auth', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
await flushViewportOps()
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
|
||||
// A second preset change is now in flight when the tab leaves the auth host.
|
||||
let releaseMetrics = (): void => {}
|
||||
const metricsGate = new Promise<void>((resolve) => {
|
||||
releaseMetrics = () => resolve()
|
||||
})
|
||||
debuggerSendCommand.mockImplementation((method: string) =>
|
||||
method === 'Emulation.setDeviceMetricsOverride' ? metricsGate : Promise.resolve(undefined)
|
||||
)
|
||||
const presetDone = browserManager.setViewportOverride('tab-race-off-auth', {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
deviceScaleFactor: 2,
|
||||
mobile: false
|
||||
})
|
||||
await flushViewportOps()
|
||||
|
||||
didStartNavigation(null, 'https://example.com/', false, true)
|
||||
releaseMetrics()
|
||||
await presetDone
|
||||
await flushViewportOps()
|
||||
|
||||
// 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_CLEAN_UA })
|
||||
})
|
||||
|
||||
it('falls back to the committed URL once a navigation commits or fails', async () => {
|
||||
const { guest, debuggerSendCommand, commitNavigationTo } = makeGuest(
|
||||
4253,
|
||||
'https://example.com/'
|
||||
)
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-pending-cleared',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
const didFailLoad = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-fail-load'
|
||||
)?.[1] as (
|
||||
event: unknown,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedURL: string,
|
||||
isMainFrame: boolean
|
||||
) => void
|
||||
const didNavigate = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-navigate'
|
||||
)?.[1] as (event: unknown, url: string) => void
|
||||
|
||||
await browserManager.setViewportOverride('tab-pending-cleared', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
await flushViewportOps()
|
||||
|
||||
// An aborted navigation to the auth host must not leave that host standing as the tab's
|
||||
// identity: the tab never went there, so a later preset must resolve the committed URL.
|
||||
didStartNavigation(null, 'https://accounts.google.com/', false, true)
|
||||
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/', true)
|
||||
await flushViewportOps()
|
||||
|
||||
expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA)
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
|
||||
|
||||
// A later preset must also resolve the committed, non-auth URL.
|
||||
debuggerSendCommand.mockClear()
|
||||
await browserManager.setViewportOverride('tab-pending-cleared', {
|
||||
width: 375,
|
||||
height: 667,
|
||||
deviceScaleFactor: 2,
|
||||
mobile: true
|
||||
})
|
||||
await flushViewportOps()
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)?.userAgent).toContain('iPhone')
|
||||
|
||||
// Same after a successful commit: the committed URL takes over from the pending target.
|
||||
didStartNavigation(null, 'https://accounts.google.com/signin', false, true)
|
||||
commitNavigationTo('https://accounts.google.com/signin')
|
||||
didNavigate(null, 'https://accounts.google.com/signin')
|
||||
await flushViewportOps()
|
||||
|
||||
debuggerSendCommand.mockClear()
|
||||
await browserManager.setViewportOverride('tab-pending-cleared', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
await flushViewportOps()
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let a superseded navigation failure revert a newer target', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4255, 'https://example.com/')
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-overlapping-navs',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
const willRedirect = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'will-redirect'
|
||||
)?.[1] as (
|
||||
event: { preventDefault: () => void },
|
||||
url: string,
|
||||
isInPlace: boolean,
|
||||
isMainFrame: boolean
|
||||
) => void
|
||||
const didFailLoad = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-fail-load'
|
||||
)?.[1] as (
|
||||
event: unknown,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedURL: string,
|
||||
isMainFrame: boolean
|
||||
) => void
|
||||
|
||||
await browserManager.setViewportOverride('tab-overlapping-navs', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
didStartNavigation(null, 'https://example.com/start', false, true)
|
||||
willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/same', false, true)
|
||||
didStartNavigation(null, 'https://accounts.google.com/same', false, true)
|
||||
await flushViewportOps()
|
||||
|
||||
debuggerSendCommand.mockClear()
|
||||
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/same', true)
|
||||
await flushViewportOps()
|
||||
|
||||
expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent())
|
||||
expect(debuggerSendCommand).not.toHaveBeenCalledWith(
|
||||
'Emulation.setUserAgentOverride',
|
||||
expect.objectContaining({ userAgent: GUEST_CLEAN_UA })
|
||||
)
|
||||
})
|
||||
|
||||
it('switches identity for a server redirect and restores it if the redirect fails', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4256, 'https://example.com/')
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-auth-redirect',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
const willRedirect = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'will-redirect'
|
||||
)?.[1] as (
|
||||
event: { preventDefault: () => void },
|
||||
url: string,
|
||||
isInPlace: boolean,
|
||||
isMainFrame: boolean
|
||||
) => void
|
||||
const didFailLoad = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-fail-load'
|
||||
)?.[1] as (
|
||||
event: unknown,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedURL: string,
|
||||
isMainFrame: boolean
|
||||
) => void
|
||||
|
||||
await browserManager.setViewportOverride('tab-auth-redirect', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
didStartNavigation(null, 'https://example.com/start', false, true)
|
||||
willRedirect(
|
||||
{ preventDefault: vi.fn() },
|
||||
'https://accounts.google.com/redirected',
|
||||
false,
|
||||
true
|
||||
)
|
||||
await flushViewportOps()
|
||||
|
||||
expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent())
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
|
||||
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/redirected', true)
|
||||
await flushViewportOps()
|
||||
expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA)
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
|
||||
})
|
||||
|
||||
it('reapplies a preset when navigation starts during its final UA write', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4257, 'https://example.com/')
|
||||
let releaseFirstUa = (): void => {}
|
||||
const firstUaGate = new Promise<void>((resolve) => {
|
||||
releaseFirstUa = resolve
|
||||
})
|
||||
let uaWrites = 0
|
||||
debuggerSendCommand.mockImplementation((method: string) => {
|
||||
if (method === 'Emulation.setUserAgentOverride' && uaWrites++ === 0) {
|
||||
return firstUaGate
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-final-apply-race',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
const presetDone = browserManager.setViewportOverride('tab-final-apply-race', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
await flushViewportOps()
|
||||
didStartNavigation(null, 'https://accounts.google.com/', false, true)
|
||||
await flushViewportOps()
|
||||
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
releaseFirstUa()
|
||||
await presetDone
|
||||
})
|
||||
|
||||
it('does not reinstall a preset while its final UA clear is in flight', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4258, 'https://example.com/')
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-final-clear-race',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
await browserManager.setViewportOverride('tab-final-clear-race', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
let releaseClearUa = (): void => {}
|
||||
const clearUaGate = new Promise<void>((resolve) => {
|
||||
releaseClearUa = resolve
|
||||
})
|
||||
debuggerSendCommand.mockImplementation(
|
||||
(method: string, params: { userAgent?: string } | undefined) =>
|
||||
method === 'Emulation.setUserAgentOverride' && params?.userAgent === ''
|
||||
? clearUaGate
|
||||
: Promise.resolve(undefined)
|
||||
)
|
||||
const clearDone = browserManager.setViewportOverride('tab-final-clear-race', null)
|
||||
await flushViewportOps()
|
||||
|
||||
debuggerSendCommand.mockClear()
|
||||
didStartNavigation(null, 'https://accounts.google.com/', false, true)
|
||||
await flushViewportOps()
|
||||
expect(debuggerSendCommand).not.toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
|
||||
releaseClearUa()
|
||||
await clearDone
|
||||
})
|
||||
|
||||
// Why: a failed clear leaves the CDP override standing on the target. Dropping the tracking entry
|
||||
// first makes it untracked, so the navigation path can never correct it again and the tab carries
|
||||
// a Chrome-shaped navigator.userAgent onto the auth hosts — the exact failure this PR prevents.
|
||||
it('keeps tracking the standing override when the CDP clear fails', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4254, 'https://example.com/')
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-failed-clear',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
await browserManager.setViewportOverride('tab-failed-clear', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
await flushViewportOps()
|
||||
|
||||
// The final UA clear fails after tracking was optimistically removed.
|
||||
debuggerSendCommand.mockImplementation(
|
||||
(method: string, params: { userAgent?: string } | undefined) =>
|
||||
method === 'Emulation.setUserAgentOverride' && params?.userAgent === ''
|
||||
? Promise.reject(new Error('debugger detached'))
|
||||
: Promise.resolve(undefined)
|
||||
)
|
||||
await expect(browserManager.setViewportOverride('tab-failed-clear', null)).resolves.toBe(
|
||||
false
|
||||
)
|
||||
await flushViewportOps()
|
||||
|
||||
// The override is still standing on the target, so navigation must still be able to correct it.
|
||||
debuggerSendCommand.mockImplementation(() => Promise.resolve(undefined))
|
||||
debuggerSendCommand.mockClear()
|
||||
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
|
||||
await flushViewportOps()
|
||||
expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not touch the UA override on navigation when no preset is standing', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4248)
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-no-preset',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
didStartNavigation(null, 'https://accounts.google.com/', false, true)
|
||||
await flushViewportOps()
|
||||
expect(debuggerSendCommand).not.toHaveBeenCalledWith(
|
||||
'Emulation.setUserAgentOverride',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('stops re-issuing the UA override once the preset is cleared', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4249)
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-cleared-preset',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
await browserManager.setViewportOverride('tab-cleared-preset', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
await browserManager.setViewportOverride('tab-cleared-preset', null)
|
||||
|
||||
debuggerSendCommand.mockClear()
|
||||
didStartNavigation(null, 'https://accounts.google.com/', false, true)
|
||||
await flushViewportOps()
|
||||
expect(debuggerSendCommand).not.toHaveBeenCalledWith(
|
||||
'Emulation.setUserAgentOverride',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the UA override alone on navigation for native-UA profiles', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4250)
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-native-nav',
|
||||
sessionProfileId: 'native-profile',
|
||||
userAgentMode: 'native',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
await browserManager.setViewportOverride('tab-native-nav', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
debuggerSendCommand.mockClear()
|
||||
didStartNavigation(null, 'https://accounts.google.com/', false, true)
|
||||
await flushViewportOps()
|
||||
expect(debuggerSendCommand).not.toHaveBeenCalledWith(
|
||||
'Emulation.setUserAgentOverride',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('clears device metrics and disables touch for override=null', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4343)
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ import {
|
|||
buildBrowserIframeClickedLinkRoutingScript
|
||||
} from './browser-clicked-link-routing'
|
||||
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'
|
||||
import type {
|
||||
BrowserViewportOverride,
|
||||
BrowserCertificateFailure,
|
||||
|
|
@ -130,16 +133,6 @@ function isAutomationVisibilityToken(token: unknown): token is string {
|
|||
return typeof token === 'string' && token.length > 0
|
||||
}
|
||||
|
||||
// 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).
|
||||
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`
|
||||
}
|
||||
|
||||
function extractChromeMajor(ua: string): string {
|
||||
const match = ua.match(/Chrome\/(\d+)/)
|
||||
return match ? match[1] : '134'
|
||||
}
|
||||
|
||||
export type BrowserGuestRegistration = {
|
||||
browserPageId?: string
|
||||
browserTabId?: string
|
||||
|
|
@ -158,6 +151,10 @@ type PopupOwnerContext = {
|
|||
browserTabId: string
|
||||
rootGuestWebContentsId: number
|
||||
}
|
||||
type PendingMainFrameNavigation = {
|
||||
currentUrl: string
|
||||
supersededUrls: string[]
|
||||
}
|
||||
const SAFE_POPUP_WINDOW_OPTIONS = {
|
||||
alwaysOnTop: false,
|
||||
closable: true,
|
||||
|
|
@ -231,6 +228,12 @@ export class BrowserManager {
|
|||
private readonly rendererWebContentsIdByTabId = new Map<string, number>()
|
||||
// Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state.
|
||||
private readonly viewportOpsByTabId = new Map<string, Promise<unknown>>()
|
||||
// Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation
|
||||
// can re-issue it against the target URL's identity.
|
||||
private readonly viewportUaOverrideMobileByTabId = new Map<string, boolean>()
|
||||
// Why: the in-flight main-frame navigation target, held only until commit or failure — getURL()
|
||||
// still reports the outgoing page until then. See resolveTabNavigationUrl.
|
||||
private readonly pendingNavigationByGuestId = new Map<number, PendingMainFrameNavigation>()
|
||||
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
|
||||
private readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
|
||||
private readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
|
||||
|
|
@ -781,20 +784,35 @@ export class BrowserManager {
|
|||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
const navigationGuard = (event: Electron.Event, url: string): void => {
|
||||
const navigationGuard = (event: Electron.Event, url: string): boolean => {
|
||||
// Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones.
|
||||
if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS.
|
||||
if (url.startsWith('file:')) {
|
||||
event.preventDefault()
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (!normalizeBrowserNavigationUrl(url)) {
|
||||
// Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs.
|
||||
event.preventDefault()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const willRedirectHandler = (
|
||||
event: Electron.Event,
|
||||
url: string,
|
||||
_isInPlace: boolean,
|
||||
isMainFrame: boolean
|
||||
): void => {
|
||||
if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) {
|
||||
return
|
||||
}
|
||||
this.updatePendingNavigationForRedirect(guest.id, url)
|
||||
this.applyGoogleAuthUserAgent(guest, url)
|
||||
}
|
||||
|
||||
const didFailLoadHandler = (
|
||||
|
|
@ -807,6 +825,12 @@ export class BrowserManager {
|
|||
if (!isMainFrame) {
|
||||
return
|
||||
}
|
||||
// Why: a nav that never committed must not leave its target standing as the tab's host.
|
||||
const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL)
|
||||
if (failedNavigationWasCurrent) {
|
||||
// The attempted host never committed, so restore every UA layer to the document that remains.
|
||||
this.applyGoogleAuthUserAgent(guest, guest.getURL())
|
||||
}
|
||||
const browserPageId = this.tabIdByWebContentsId.get(guest.id)
|
||||
const certificateFailure = browserPageId
|
||||
? this.certificateTrustController?.getFailure(browserPageId)
|
||||
|
|
@ -850,6 +874,10 @@ export class BrowserManager {
|
|||
if (!isMainFrame || isChromiumInternalErrorUrl(url)) {
|
||||
return
|
||||
}
|
||||
// Why: getURL() still reports the previous committed URL until this navigation commits, so
|
||||
// every UA writer must read the in-flight target or they disagree about the tab's host.
|
||||
this.startPendingNavigation(guest.id, url)
|
||||
this.applyGoogleAuthUserAgent(guest, url)
|
||||
this.certificateTrustController?.onMainFrameNavigationStarted(guest.id)
|
||||
// Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it.
|
||||
this.pendingLoadFailuresByGuestId.delete(guest.id)
|
||||
|
|
@ -865,13 +893,15 @@ export class BrowserManager {
|
|||
}
|
||||
|
||||
const didNavigateHandler = (_event: Electron.Event, url: string): void => {
|
||||
// Why: once committed, getURL() reports this url, so the pending target is redundant.
|
||||
this.pendingNavigationByGuestId.delete(guest.id)
|
||||
// Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it.
|
||||
this.clearedLoadErrorsByGuestId.delete(guest.id)
|
||||
this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url)
|
||||
}
|
||||
|
||||
guest.on('will-navigate', navigationGuard)
|
||||
guest.on('will-redirect', navigationGuard)
|
||||
guest.on('will-redirect', willRedirectHandler)
|
||||
guest.on('did-start-navigation', didStartNavigationHandler)
|
||||
guest.on('did-navigate', didNavigateHandler)
|
||||
guest.on('did-fail-load', didFailLoadHandler)
|
||||
|
|
@ -905,7 +935,7 @@ export class BrowserManager {
|
|||
}
|
||||
if (!guest.isDestroyed()) {
|
||||
guest.off('will-navigate', navigationGuard)
|
||||
guest.off('will-redirect', navigationGuard)
|
||||
guest.off('will-redirect', willRedirectHandler)
|
||||
guest.off('did-start-navigation', didStartNavigationHandler)
|
||||
guest.off('did-navigate', didNavigateHandler)
|
||||
guest.off('did-fail-load', didFailLoadHandler)
|
||||
|
|
@ -913,6 +943,124 @@ export class BrowserManager {
|
|||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
private applyGoogleAuthUserAgent(guest: Electron.WebContents, url: string): void {
|
||||
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
|
||||
// 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 =
|
||||
getBrowserSessionUserAgentMode(guest.session) ??
|
||||
(ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined)
|
||||
if (mode === 'native') {
|
||||
return
|
||||
}
|
||||
const firefoxUa = googleAuthUserAgent()
|
||||
const currentUa = guest.getUserAgent()
|
||||
if (isGoogleAuthUrl(url)) {
|
||||
if (currentUa !== firefoxUa) {
|
||||
guest.setUserAgent(firefoxUa)
|
||||
}
|
||||
} else if (currentUa === firefoxUa) {
|
||||
// Only restore when the auth-host override is actually in place, so normal
|
||||
// navigation never touches the session UA.
|
||||
guest.setUserAgent(guest.session.getUserAgent())
|
||||
}
|
||||
// Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of
|
||||
// its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport.
|
||||
if (browserPageId) {
|
||||
this.reapplyViewportUserAgentOverride(guest, browserPageId, url)
|
||||
}
|
||||
}
|
||||
|
||||
private startPendingNavigation(guestId: number, url: string): void {
|
||||
const pending = this.pendingNavigationByGuestId.get(guestId)
|
||||
this.pendingNavigationByGuestId.set(guestId, {
|
||||
currentUrl: url,
|
||||
supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : []
|
||||
})
|
||||
}
|
||||
|
||||
private updatePendingNavigationForRedirect(guestId: number, url: string): void {
|
||||
const pending = this.pendingNavigationByGuestId.get(guestId)
|
||||
if (!pending) {
|
||||
this.pendingNavigationByGuestId.set(guestId, {
|
||||
currentUrl: url,
|
||||
supersededUrls: []
|
||||
})
|
||||
return
|
||||
}
|
||||
pending.currentUrl = url
|
||||
}
|
||||
|
||||
private failPendingNavigation(guestId: number, failedUrl: string): boolean {
|
||||
const pending = this.pendingNavigationByGuestId.get(guestId)
|
||||
if (!pending) {
|
||||
return false
|
||||
}
|
||||
const supersededIndex = pending.supersededUrls.indexOf(failedUrl)
|
||||
if (supersededIndex !== -1) {
|
||||
pending.supersededUrls.splice(supersededIndex, 1)
|
||||
return false
|
||||
}
|
||||
if (pending.currentUrl !== failedUrl) {
|
||||
return false
|
||||
}
|
||||
this.pendingNavigationByGuestId.delete(guestId)
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host
|
||||
// the tab is leaving, not the one it is entering. Every UA writer must resolve the host through
|
||||
// here or two writers racing the same navigation will pick opposite identities.
|
||||
private resolveTabNavigationUrl(guest: Electron.WebContents): string {
|
||||
return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL()
|
||||
}
|
||||
|
||||
// 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 Chrome-shaped preset UA while the
|
||||
// request header says Firefox — the two-layer disagreement this scope exists to remove.
|
||||
private reapplyViewportUserAgentOverride(
|
||||
guest: Electron.WebContents,
|
||||
browserTabId: string,
|
||||
url: string
|
||||
): void {
|
||||
const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId)
|
||||
if (mobile === undefined) {
|
||||
return
|
||||
}
|
||||
// Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the
|
||||
// later-issued write wins. What matters is that both writers resolve the SAME host, which they
|
||||
// now do via the navigation target rather than the stale committed URL.
|
||||
void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {})
|
||||
}
|
||||
|
||||
private async sendViewportUserAgentOverride(
|
||||
guest: Electron.WebContents,
|
||||
mobile: boolean,
|
||||
url?: string
|
||||
): Promise<void> {
|
||||
if (guest.isDestroyed() || !guest.debugger.isAttached()) {
|
||||
return
|
||||
}
|
||||
await guest.debugger.sendCommand(
|
||||
'Emulation.setUserAgentOverride',
|
||||
buildViewportUserAgentOverride({
|
||||
url: url ?? this.resolveTabNavigationUrl(guest),
|
||||
mobile,
|
||||
baseUserAgent: cleanElectronUserAgent(guest.getUserAgent())
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private createPopupChildWindowWithOriginBar(
|
||||
openerGuest: Electron.WebContents,
|
||||
targetUrl: string,
|
||||
|
|
@ -961,6 +1109,7 @@ export class BrowserManager {
|
|||
this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId)
|
||||
this.offscreenGuestIds.delete(guestWebContentsId)
|
||||
this.popupOwnerContextByGuestId.delete(guestWebContentsId)
|
||||
this.pendingNavigationByGuestId.delete(guestWebContentsId)
|
||||
// Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child.
|
||||
if (isPrimaryGuest) {
|
||||
for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) {
|
||||
|
|
@ -1094,6 +1243,10 @@ export class BrowserManager {
|
|||
this.worktreeIdByTabId.delete(browserTabId)
|
||||
// Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest.
|
||||
this.viewportOpsByTabId.delete(browserTabId)
|
||||
this.viewportUaOverrideMobileByTabId.delete(browserTabId)
|
||||
if (wcId !== undefined) {
|
||||
this.pendingNavigationByGuestId.delete(wcId)
|
||||
}
|
||||
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
|
||||
}
|
||||
|
||||
|
|
@ -1159,6 +1312,8 @@ export class BrowserManager {
|
|||
this.worktreeIdByTabId.clear()
|
||||
this.sessionProfileIdByPageId.clear()
|
||||
this.userAgentModeByPageId.clear()
|
||||
this.viewportUaOverrideMobileByTabId.clear()
|
||||
this.pendingNavigationByGuestId.clear()
|
||||
this.pendingLoadFailuresByGuestId.clear()
|
||||
this.loadErrorsByGuestId.clear()
|
||||
this.clearedLoadErrorsByGuestId.clear()
|
||||
|
|
@ -1552,36 +1707,10 @@ export class BrowserManager {
|
|||
})
|
||||
// Why: viewport sizing must not override a profile's explicit native-UA identity.
|
||||
if (this.userAgentModeByPageId.get(browserTabId) !== 'native') {
|
||||
if (override.mobile) {
|
||||
const chromeMajor = extractChromeMajor(cleanElectronUserAgent(guest.getUserAgent()))
|
||||
// Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak.
|
||||
await dbg.sendCommand('Emulation.setUserAgentOverride', {
|
||||
userAgent: buildMobileUserAgent(chromeMajor),
|
||||
userAgentMetadata: {
|
||||
brands: [
|
||||
{ brand: 'Google Chrome', version: chromeMajor },
|
||||
{ brand: 'Chromium', version: chromeMajor },
|
||||
{ brand: 'Not/A)Brand', version: '24' }
|
||||
],
|
||||
fullVersionList: [
|
||||
{ brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` },
|
||||
{ brand: 'Chromium', version: `${chromeMajor}.0.0.0` },
|
||||
{ brand: 'Not/A)Brand', version: '24.0.0.0' }
|
||||
],
|
||||
fullVersion: `${chromeMajor}.0.0.0`,
|
||||
platform: 'iOS',
|
||||
platformVersion: '17.0',
|
||||
architecture: '',
|
||||
model: 'iPhone',
|
||||
mobile: true
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session.
|
||||
await dbg.sendCommand('Emulation.setUserAgentOverride', {
|
||||
userAgent: cleanElectronUserAgent(guest.getUserAgent())
|
||||
})
|
||||
}
|
||||
// Navigation must see the preset intent while the final CDP command is in flight.
|
||||
this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile)
|
||||
// Why: same sender as the navigation path, so both resolve the tab's host identically.
|
||||
await this.sendViewportUserAgentOverride(guest, override.mobile)
|
||||
}
|
||||
} else {
|
||||
await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {})
|
||||
|
|
@ -1589,8 +1718,18 @@ export class BrowserManager {
|
|||
enabled: false,
|
||||
maxTouchPoints: 0
|
||||
})
|
||||
const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId)
|
||||
// A navigation after this point must not re-install the override behind the clear.
|
||||
this.viewportUaOverrideMobileByTabId.delete(browserTabId)
|
||||
// Why: passing an empty string restores the session default UA.
|
||||
await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
|
||||
try {
|
||||
await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
|
||||
} catch (error) {
|
||||
if (trackedMobile !== undefined) {
|
||||
this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -230,8 +230,10 @@ describe('BrowserSessionRegistry persistence', () => {
|
|||
browserSessionRegistry.createProfile('isolated', 'Google', { userAgentMode: 'native' })
|
||||
|
||||
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(getBrowserSessionUserAgentMode(profileSession as never)).toBe('native')
|
||||
})
|
||||
|
||||
it('merges partition-keyed pending entries without clobbering unrelated entries', async () => {
|
||||
|
|
@ -391,7 +393,14 @@ describe('BrowserSessionRegistry persistence', () => {
|
|||
setupClientHintsOverrideMock.mock.calls.some(
|
||||
(c: unknown[]) =>
|
||||
(c[0] as { partition?: string } | undefined)?.partition === importedPartition &&
|
||||
c[1] === importedUa
|
||||
c[1] === importedUa &&
|
||||
(c[2] as { googleAuthOverride?: boolean } | undefined)?.googleAuthOverride === false
|
||||
)
|
||||
).toBe(true)
|
||||
const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode')
|
||||
expect(
|
||||
importedSessions.every(
|
||||
(session) => getBrowserSessionUserAgentMode(session as never) === 'native'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
|
@ -432,6 +441,12 @@ describe('BrowserSessionRegistry persistence', () => {
|
|||
([sess]) => (sess as { partition?: string }).partition === importedPartition
|
||||
)
|
||||
).toBe(false)
|
||||
const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode')
|
||||
expect(
|
||||
importedSessions.every(
|
||||
(session) => getBrowserSessionUserAgentMode(session as never) === 'native'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('sets up default-partition policies on restore', async () => {
|
||||
|
|
|
|||
|
|
@ -393,13 +393,89 @@ describe('BrowserSessionRegistry', () => {
|
|||
expect(modified['sec-ch-ua']).not.toContain('Microsoft Edge')
|
||||
})
|
||||
|
||||
it('does not register handler for non-Chrome UA', () => {
|
||||
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).not.toHaveBeenCalled()
|
||||
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')
|
||||
})
|
||||
|
||||
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(
|
||||
{
|
||||
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"'
|
||||
}
|
||||
},
|
||||
callback
|
||||
)
|
||||
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()
|
||||
})
|
||||
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ import type {
|
|||
import { browserManager } from './browser-manager'
|
||||
import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access'
|
||||
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
|
||||
import {
|
||||
clearBrowserSessionUserAgentMode,
|
||||
setBrowserSessionUserAgentMode
|
||||
} from './browser-session-user-agent-mode'
|
||||
import { resolveChromiumCookiesPath } from './chromium-cookie-path'
|
||||
import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy'
|
||||
import {
|
||||
|
|
@ -190,10 +194,14 @@ class BrowserSessionRegistry {
|
|||
const partition = profile.partition
|
||||
try {
|
||||
const sess = session.fromPartition(partition)
|
||||
const userAgentMode = profile.userAgentMode ?? 'clean'
|
||||
setBrowserSessionUserAgentMode(sess, userAgentMode)
|
||||
const persistedUa = meta.userAgentByPartition[partition]
|
||||
if (persistedUa) {
|
||||
sess.setUserAgent(persistedUa)
|
||||
setupClientHintsOverride(sess, persistedUa)
|
||||
setupClientHintsOverride(sess, persistedUa, {
|
||||
googleAuthOverride: userAgentMode !== 'native'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -435,6 +443,7 @@ class BrowserSessionRegistry {
|
|||
// Why: clear the partition's storage so deleting a profile doesn't leave orphaned cookies/cache behind.
|
||||
try {
|
||||
const sess = session.fromPartition(profile.partition)
|
||||
clearBrowserSessionUserAgentMode(sess)
|
||||
this.clearSessionPolicies(profile.partition, sess)
|
||||
await sess.clearStorageData()
|
||||
await sess.clearCache()
|
||||
|
|
@ -523,11 +532,12 @@ class BrowserSessionRegistry {
|
|||
|
||||
private setupSessionPolicies(profile: BrowserSessionProfile): void {
|
||||
const { partition } = profile
|
||||
const sess = session.fromPartition(partition)
|
||||
setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean')
|
||||
if (this.configuredPartitions.has(partition)) {
|
||||
return
|
||||
}
|
||||
|
||||
const sess = session.fromPartition(partition)
|
||||
browserManager.installCertificateRequestGuard(sess)
|
||||
if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') {
|
||||
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import type { Session } from 'electron'
|
||||
|
||||
import {
|
||||
googleAuthUserAgent,
|
||||
isGoogleAuthUrl,
|
||||
setUserAgentHeader,
|
||||
stripClientHints
|
||||
} 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
|
||||
|
|
@ -19,10 +26,46 @@ export function cleanElectronUserAgent(ua: string): string {
|
|||
// reveal the real version, creating a mismatch that Google's anti-fraud
|
||||
// detection flags as CookieMismatch on accounts.google.com. Override Client
|
||||
// Hints on outgoing requests to match the source browser's UA.
|
||||
export function setupClientHintsOverride(sess: Session, ua: string): void {
|
||||
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)
|
||||
const firefoxUa = googleAuthUserAgent()
|
||||
|
||||
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
|
||||
const headers = details.requestHeaders
|
||||
if (options.googleAuthOverride !== false && 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.
|
||||
setUserAgentHeader(headers, firefoxUa)
|
||||
stripClientHints(headers)
|
||||
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
|
||||
return null
|
||||
}
|
||||
const fullChromeVersion = chromeMatch[1]
|
||||
const majorVersion = fullChromeVersion.split('.')[0]
|
||||
|
|
@ -37,19 +80,8 @@ export function setupClientHintsOverride(sess: Session, ua: string): void {
|
|||
}
|
||||
const brandMajor = brandFullVersion.split('.')[0]
|
||||
|
||||
const secChUa = `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`
|
||||
const secChUaFull = `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"`
|
||||
|
||||
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
|
||||
const headers = details.requestHeaders
|
||||
for (const key of Object.keys(headers)) {
|
||||
const lower = key.toLowerCase()
|
||||
if (lower === 'sec-ch-ua') {
|
||||
headers[key] = secChUa
|
||||
} else if (lower === 'sec-ch-ua-full-version-list') {
|
||||
headers[key] = secChUaFull
|
||||
}
|
||||
}
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
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"`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import type { Session } from 'electron'
|
||||
|
||||
import type { BrowserSessionUserAgentMode } from '../../shared/types'
|
||||
|
||||
const userAgentModeBySession = new WeakMap<Session, BrowserSessionUserAgentMode>()
|
||||
|
||||
export function setBrowserSessionUserAgentMode(
|
||||
session: Session,
|
||||
mode: BrowserSessionUserAgentMode
|
||||
): void {
|
||||
userAgentModeBySession.set(session, mode)
|
||||
}
|
||||
|
||||
export function getBrowserSessionUserAgentMode(
|
||||
session: Session
|
||||
): BrowserSessionUserAgentMode | undefined {
|
||||
return userAgentModeBySession.get(session)
|
||||
}
|
||||
|
||||
export function clearBrowserSessionUserAgentMode(session: Session): void {
|
||||
userAgentModeBySession.delete(session)
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { googleAuthUserAgent } from './browser-google-auth-ua'
|
||||
import { buildViewportUserAgentOverride } from './browser-viewport-user-agent'
|
||||
|
||||
const CHROME_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'
|
||||
|
||||
describe('buildViewportUserAgentOverride', () => {
|
||||
it('presents the Firefox UA on Google auth hosts regardless of the preset', () => {
|
||||
for (const mobile of [false, true]) {
|
||||
const override = buildViewportUserAgentOverride({
|
||||
url: 'https://accounts.google.com/v3/signin/identifier',
|
||||
mobile,
|
||||
baseUserAgent: CHROME_UA
|
||||
})
|
||||
expect(override.userAgent).toBe(googleAuthUserAgent())
|
||||
// Real Firefox emits no client hints, so Chrome brands would contradict the stripped headers.
|
||||
expect(override.userAgentMetadata).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the clean desktop UA off the auth hosts', () => {
|
||||
const override = buildViewportUserAgentOverride({
|
||||
url: 'https://myaccount.google.com/',
|
||||
mobile: false,
|
||||
baseUserAgent: CHROME_UA
|
||||
})
|
||||
expect(override.userAgent).toBe(CHROME_UA)
|
||||
expect(override.userAgentMetadata).toBeUndefined()
|
||||
})
|
||||
|
||||
it('splices the real Chrome major into the mobile UA and its client hints', () => {
|
||||
const override = buildViewportUserAgentOverride({
|
||||
url: 'https://example.com/',
|
||||
mobile: true,
|
||||
baseUserAgent: CHROME_UA
|
||||
})
|
||||
expect(override.userAgent).toContain('iPhone')
|
||||
expect(override.userAgent).toContain('CriOS/134.0.0.0')
|
||||
expect(override.userAgentMetadata?.mobile).toBe(true)
|
||||
expect(override.userAgentMetadata?.platform).toBe('iOS')
|
||||
expect(override.userAgentMetadata?.brands).toContainEqual({
|
||||
brand: 'Google Chrome',
|
||||
version: '134'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a known Chrome major when the base UA carries none', () => {
|
||||
const override = buildViewportUserAgentOverride({
|
||||
url: 'https://example.com/',
|
||||
mobile: true,
|
||||
baseUserAgent: googleAuthUserAgent()
|
||||
})
|
||||
expect(override.userAgent).toContain('CriOS/134.0.0.0')
|
||||
})
|
||||
|
||||
it('treats an unparseable URL as a non-auth host', () => {
|
||||
const override = buildViewportUserAgentOverride({
|
||||
url: 'not a url',
|
||||
mobile: false,
|
||||
baseUserAgent: CHROME_UA
|
||||
})
|
||||
expect(override.userAgent).toBe(CHROME_UA)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
// Why: a CDP Emulation.setUserAgentOverride outranks WebContents.setUserAgent for both
|
||||
// navigator.userAgent and the outgoing request header, and it stands across every later
|
||||
// navigation until explicitly cleared. So the viewport preset's UA is a third identity layer
|
||||
// that must agree with the auth-host Firefox switch, or applying a preset silently reintroduces
|
||||
// the exact UA mismatch this scope exists to remove.
|
||||
|
||||
import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua'
|
||||
|
||||
type UserAgentBrand = { brand: string; version: string }
|
||||
|
||||
export type ViewportUserAgentOverride = {
|
||||
userAgent: string
|
||||
userAgentMetadata?: {
|
||||
brands: UserAgentBrand[]
|
||||
fullVersionList: UserAgentBrand[]
|
||||
fullVersion: string
|
||||
platform: string
|
||||
platformVersion: string
|
||||
architecture: string
|
||||
model: string
|
||||
mobile: boolean
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
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`
|
||||
}
|
||||
|
||||
function extractChromeMajor(ua: string): string {
|
||||
const match = ua.match(/Chrome\/(\d+)/)
|
||||
return match ? match[1] : '134'
|
||||
}
|
||||
|
||||
export function buildViewportUserAgentOverride(args: {
|
||||
url: string
|
||||
mobile: boolean
|
||||
baseUserAgent: string
|
||||
}): ViewportUserAgentOverride {
|
||||
if (isGoogleAuthUrl(args.url)) {
|
||||
// Why: match the header-level Firefox switch exactly, and send no userAgentMetadata — real
|
||||
// Firefox emits no client hints, so Chrome brands here would contradict the stripped headers.
|
||||
return { userAgent: googleAuthUserAgent() }
|
||||
}
|
||||
if (!args.mobile) {
|
||||
// Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session.
|
||||
return { userAgent: args.baseUserAgent }
|
||||
}
|
||||
const chromeMajor = extractChromeMajor(args.baseUserAgent)
|
||||
// Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak.
|
||||
return {
|
||||
userAgent: buildMobileUserAgent(chromeMajor),
|
||||
userAgentMetadata: {
|
||||
brands: [
|
||||
{ brand: 'Google Chrome', version: chromeMajor },
|
||||
{ brand: 'Chromium', version: chromeMajor },
|
||||
{ brand: 'Not/A)Brand', version: '24' }
|
||||
],
|
||||
fullVersionList: [
|
||||
{ brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` },
|
||||
{ brand: 'Chromium', version: `${chromeMajor}.0.0.0` },
|
||||
{ brand: 'Not/A)Brand', version: '24.0.0.0' }
|
||||
],
|
||||
fullVersion: `${chromeMajor}.0.0.0`,
|
||||
platform: 'iOS',
|
||||
platformVersion: '17.0',
|
||||
architecture: '',
|
||||
model: 'iPhone',
|
||||
mobile: true
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue