fix(browser): keep the viewport UA override in step with the auth-host identity

The CDP Emulation.setUserAgentOverride a viewport preset installs outranks
WebContents.setUserAgent for navigator.userAgent and stands across every later
navigation, so a preset applied before reaching accounts.google.com pinned
navigator.userAgent to the Chrome-shaped preset UA while the request header said
Firefox — reintroducing the mismatch this scope removes.
This commit is contained in:
Brennan Benson
2026-08-06 23:36:26 -07:00
parent ad03f3defe
commit 86bc670412
4 changed files with 373 additions and 45 deletions
+180 -6
View File
@@ -2897,23 +2897,35 @@ 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'
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
} {
const debuggerSendCommand = vi.fn().mockResolvedValue(undefined)
const debuggerIsAttached = vi.fn(() => true)
const debuggerAttach = vi.fn()
let currentUa = GUEST_ELECTRON_UA
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(() => url),
getUserAgent: vi.fn(() => currentUa),
setUserAgent: vi.fn((ua: string) => {
currentUa = ua
}),
session: { getUserAgent: vi.fn(() => GUEST_ELECTRON_UA) },
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
@@ -2926,7 +2938,15 @@ describe('browserManager', () => {
sendCommand: debuggerSendCommand
}
}
return { guest, debuggerSendCommand, debuggerIsAttached, debuggerAttach }
return {
guest,
debuggerSendCommand,
debuggerIsAttached,
debuggerAttach,
setGuestUserAgent: (ua: string) => {
currentUa = ua
}
}
}
it('returns false when the tab is not registered', async () => {
@@ -3012,6 +3032,160 @@ 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 Promise.resolve()
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 Promise.resolve()
expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
userAgent: GUEST_CLEAN_UA
})
})
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 Promise.resolve()
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 Promise.resolve()
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 Promise.resolve()
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)
+54 -39
View File
@@ -44,6 +44,7 @@ import {
} from './browser-clicked-link-routing'
import { cleanElectronUserAgent } from './browser-session-ua'
import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua'
import { buildViewportUserAgentOverride } from './browser-viewport-user-agent'
import type {
BrowserViewportOverride,
BrowserCertificateFailure,
@@ -131,16 +132,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
@@ -232,6 +223,9 @@ 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 a CDP UA override is standing for the tab (value = the preset's mobile flag),
// so navigation can re-issue it against the target URL's identity.
private readonly viewportUaOverrideMobileByTabId = new Map<string, boolean>()
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
private readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
private readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
@@ -937,6 +931,43 @@ export class BrowserManager {
// navigation never touches the session UA.
guest.setUserAgent(guest.session.getUserAgent())
}
if (browserPageId) {
this.reapplyViewportUserAgentOverride(guest, browserPageId, url)
}
}
// 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
}
try {
if (!guest.debugger.isAttached()) {
return
}
// Why: navigator.userAgent is read by page JS after commit, so landing this override
// asynchronously still beats every reader; the header layer already forces Firefox on the wire.
void guest.debugger
.sendCommand(
'Emulation.setUserAgentOverride',
buildViewportUserAgentOverride({
url,
mobile,
baseUserAgent: cleanElectronUserAgent(guest.getUserAgent())
})
)
.catch(() => {})
} catch {
// guest may be mid-teardown or the debugger taken over by DevTools
}
}
private createPopupChildWindowWithOriginBar(
@@ -1120,6 +1151,7 @@ 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)
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
}
@@ -1185,6 +1217,7 @@ export class BrowserManager {
this.worktreeIdByTabId.clear()
this.sessionProfileIdByPageId.clear()
this.userAgentModeByPageId.clear()
this.viewportUaOverrideMobileByTabId.clear()
this.pendingLoadFailuresByGuestId.clear()
this.loadErrorsByGuestId.clear()
this.clearedLoadErrorsByGuestId.clear()
@@ -1578,38 +1611,20 @@ 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
}
// Why: remember the standing override so later navigations can re-issue it; it outranks
// setUserAgent and would otherwise pin the auth-host identity to whatever was set here.
this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile)
await dbg.sendCommand(
'Emulation.setUserAgentOverride',
buildViewportUserAgentOverride({
url: guest.getURL(),
mobile: override.mobile,
baseUserAgent: cleanElectronUserAgent(guest.getUserAgent())
})
} 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())
})
}
)
}
} else {
this.viewportUaOverrideMobileByTabId.delete(browserTabId)
await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {})
await dbg.sendCommand('Emulation.setTouchEmulationEnabled', {
enabled: false,
@@ -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
}
}
}