diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 131adcca6..e67c5470b 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -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)}...`) } diff --git a/src/main/browser/browser-google-auth-ua.test.ts b/src/main/browser/browser-google-auth-ua.test.ts new file mode 100644 index 000000000..a1ed13704 --- /dev/null +++ b/src/main/browser/browser-google-auth-ua.test.ts @@ -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 = { + '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 = { '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 = {} + setUserAgentHeader(headers, 'new') + expect(headers['User-Agent']).toBe('new') + }) +}) diff --git a/src/main/browser/browser-google-auth-ua.ts b/src/main/browser/browser-google-auth-ua.ts new file mode 100644 index 000000000..4392e6351 --- /dev/null +++ b/src/main/browser/browser-google-auth-ua.ts @@ -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): 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, value: string): void { + const existing = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent') + headers[existing ?? 'User-Agent'] = value +} diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index fcda38460..2119c1fe1 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -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 { + for (let i = 0; i < 20; i++) { + await Promise.resolve() + } + } + + function makeGuest( + id: number, + url = 'https://example.com/' + ): { guest: Record debuggerSendCommand: ReturnType debuggerIsAttached: ReturnType debuggerAttach: ReturnType + 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 + ): Record | undefined { + const calls = debuggerSendCommand.mock.calls.filter( + (call) => call[0] === 'Emulation.setUserAgentOverride' + ) + return calls.at(-1)?.[1] as Record | 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((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((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((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((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) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index d4547abad..fbcac3784 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -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() // 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>() + // 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() + // 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() private readonly contextMenuCleanupByTabId = new Map void>() private readonly grabShortcutCleanupByTabId = new Map void>() private readonly shortcutForwardingCleanupByTabId = new Map 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 { + 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 { diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 76ddaf4fe..f7792903b 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -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 () => { diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 31260f4cf..44e1b6b8e 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -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', () => { diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index e30c3a023..d6188bf88 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -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()) diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index f30ce96fc..081e1a5de 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -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"` + } } diff --git a/src/main/browser/browser-session-user-agent-mode.ts b/src/main/browser/browser-session-user-agent-mode.ts new file mode 100644 index 000000000..8fa8f71d1 --- /dev/null +++ b/src/main/browser/browser-session-user-agent-mode.ts @@ -0,0 +1,22 @@ +import type { Session } from 'electron' + +import type { BrowserSessionUserAgentMode } from '../../shared/types' + +const userAgentModeBySession = new WeakMap() + +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) +} diff --git a/src/main/browser/browser-viewport-user-agent.test.ts b/src/main/browser/browser-viewport-user-agent.test.ts new file mode 100644 index 000000000..1d069a209 --- /dev/null +++ b/src/main/browser/browser-viewport-user-agent.test.ts @@ -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) + }) +}) diff --git a/src/main/browser/browser-viewport-user-agent.ts b/src/main/browser/browser-viewport-user-agent.ts new file mode 100644 index 000000000..7dedf8d8a --- /dev/null +++ b/src/main/browser/browser-viewport-user-agent.ts @@ -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 + } + } +}