fix(browser): stop the auth-host UA write from cancelling redirects (#15221)

* fix(browser): stop the auth-host UA write from cancelling redirects

WebContents.setUserAgent() from will-redirect makes Chromium abort the
in-flight navigation (ERR_ABORTED) and replay the original request, so any
redirect crossing the Google auth-host boundary dies on a blank tab. Route
that write through the CDP override, which retargets navigator.userAgent
without touching the navigation.

Fixes #15216

* fix(browser): recover auth UA override state

* fix(browser): keep the viewport UA override on the session identity

The auth-host switch writes the Firefox UA through WebContents.setUserAgent on a
direct navigation and then moves to the CDP override, and nothing restores that
WebContents UA. sendViewportUserAgentOverride read it back as its base identity,
so a tab with a viewport preset republished the Firefox UA on every ordinary host
afterwards, with sec-ch-ua still saying Chrome. Read the profile's session UA
instead, which is the stable base identity at both call sites.

---------

Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
(cherry picked from commit 0b8107a41f)
This commit is contained in:
Gideon
2026-08-20 21:48:36 -04:00
committed by Jinwoo-H
parent d0a47e04e6
commit 93354291d1
3 changed files with 569 additions and 50 deletions
@@ -48,6 +48,11 @@ import {
resetBrowserManagerMocks,
resetBrowserManagerState
} from './browser-manager-test-harness'
import {
createViewportGuestFactory,
flushViewportOps,
GUEST_CLEAN_UA
} from './browser-manager-viewport-test-fixtures'
const {
guestOffMock,
@@ -57,6 +62,7 @@ const {
guestOpenDevToolsMock,
webContentsFromIdMock
} = browserMocks
const makeViewportGuest = createViewportGuestFactory(browserMocks)
describe('browserManager', () => {
beforeEach(() => {
@@ -68,8 +74,9 @@ describe('browserManager', () => {
vi.useRealTimers()
})
it('presents the Firefox UA on Google auth hosts and restores the base UA off them', () => {
it('presents the Firefox UA on Google auth hosts and restores the base UA off them', async () => {
let currentUa = guestBaseUserAgent
const sendCommand = vi.fn().mockResolvedValue(undefined)
const setUserAgent = vi.fn((ua: string) => {
currentUa = ua
})
@@ -85,6 +92,7 @@ describe('browserManager', () => {
getURL: vi.fn(() => 'https://accounts.google.com/'),
getUserAgent: vi.fn(() => currentUa),
setUserAgent,
debugger: { isAttached: vi.fn(() => true), sendCommand },
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
}
webContentsFromIdMock.mockReturnValue(guest)
@@ -98,14 +106,26 @@ describe('browserManager', () => {
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: 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 redirect off the auth host must derive the CDP write from the session UA, not the stale
// Firefox WebContents UA installed by the direct navigation.
sendCommand.mockClear()
willRedirect(null, 'https://myaccount.google.com/', false, true)
const uaOverrideIndex = sendCommand.mock.calls.findIndex(
([method]) => method === 'Emulation.setUserAgentOverride'
)
await expect(sendCommand.mock.results[uaOverrideIndex]?.value).resolves.toBeUndefined()
expect(sendCommand.mock.calls[uaOverrideIndex]).toEqual([
'Emulation.setUserAgentOverride',
{ userAgent: guestBaseUserAgent }
])
// A navigation that doesn't change the required UA must not thrash setUserAgent.
setUserAgent.mockClear()
@@ -233,4 +253,297 @@ describe('browserManager', () => {
popupDidStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
expect(popupSetUserAgent).not.toHaveBeenCalled()
})
// Why: WebContents.setUserAgent() from will-redirect makes Chromium cancel the in-flight navigation
// (ERR_ABORTED) and replay the original request. A "Sign in with Google" button POSTs to the
// provider and lands on accounts.google.com only by redirect, so the replay never reproduces it and
// the tab is left blank. Verified against Electron 43.1.0: the server sees the original request twice.
it('retargets the auth-host UA over CDP during a redirect instead of cancelling it', async () => {
const debuggerHandlers = new Map<string, () => void>()
let holdNextUserAgentOverride = false
let releaseHeldUserAgentOverride = (): void => {}
let rejectNextUserAgentOverride = false
const sendCommand = vi.fn((method: string) => {
if (method === 'Emulation.setUserAgentOverride' && holdNextUserAgentOverride) {
holdNextUserAgentOverride = false
return new Promise<void>((resolve) => {
releaseHeldUserAgentOverride = resolve
})
}
if (method === 'Emulation.setUserAgentOverride' && rejectNextUserAgentOverride) {
rejectNextUserAgentOverride = false
return Promise.reject(new Error('debugger detached'))
}
return Promise.resolve(undefined)
})
const guest = {
id: 418,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock,
getURL: vi.fn(() => 'https://mail.google.com/'),
getUserAgent: vi.fn(() => guestBaseUserAgent),
setUserAgent: vi.fn(),
debugger: {
isAttached: vi.fn(() => true),
sendCommand,
on: vi.fn((event: string, handler: () => void) => debuggerHandlers.set(event, handler)),
off: vi.fn((event: string) => debuggerHandlers.delete(event))
},
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'browser-redirect-ua',
webContentsId: guest.id,
rendererWebContentsId
})
const willRedirect = guestOnMock.mock.calls.find(
([event]) => event === 'will-redirect'
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
sendCommand.mockClear()
rejectNextUserAgentOverride = true
willRedirect(
{ preventDefault: vi.fn() },
'https://accounts.google.com/v3/signin/identifier',
false,
true
)
await expect(sendCommand.mock.results.at(-1)?.value).rejects.toThrow('debugger detached')
// A rejected write must not be recorded as installed; the next navigation retries it.
willRedirect(
{ preventDefault: vi.fn() },
'https://accounts.google.com/v3/signin/identifier',
false,
true
)
await expect(sendCommand.mock.results.at(-1)?.value).resolves.toBeUndefined()
expect(guest.setUserAgent).not.toHaveBeenCalled()
expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
userAgent: googleAuthUserAgent()
})
// Detaching clears Chromium's CDP overrides, so the confirmed identity must be invalidated too.
sendCommand.mockClear()
debuggerHandlers.get('detach')?.()
willRedirect(
{ preventDefault: vi.fn() },
'https://accounts.google.com/v3/signin/identifier',
false,
true
)
expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
userAgent: googleAuthUserAgent()
})
// Why: the CDP override outranks the WebContents UA, so getUserAgent() still reports the base
// identity. Leaving the auth host must read the override, not that stale value, or the guest
// keeps presenting Firefox on every later host.
sendCommand.mockClear()
const didStartNavigation = guestOnMock.mock.calls.find(
([event]) => event === 'did-start-navigation'
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
rejectNextUserAgentOverride = true
didStartNavigation(null, 'https://example.com/', false, true)
await expect(sendCommand.mock.results.at(-1)?.value).rejects.toThrow('debugger detached')
didStartNavigation(null, 'https://example.com/', false, true)
expect(guest.setUserAgent).not.toHaveBeenCalled()
expect(sendCommand.mock.calls).toEqual([
['Emulation.setUserAgentOverride', { userAgent: guestBaseUserAgent }],
['Emulation.setUserAgentOverride', { userAgent: guestBaseUserAgent }]
])
// A newer confirmed write outranks an older write that remains in flight.
debuggerHandlers.get('detach')?.()
holdNextUserAgentOverride = true
willRedirect(
{ preventDefault: vi.fn() },
'https://accounts.google.com/v3/signin/identifier',
false,
true
)
const olderHeldWrite = sendCommand.mock.results.at(-1)?.value as Promise<void>
didStartNavigation(null, 'https://example.com/', false, true)
await expect(sendCommand.mock.results.at(-1)?.value).resolves.toBeUndefined()
sendCommand.mockClear()
willRedirect(
{ preventDefault: vi.fn() },
'https://accounts.google.com/v3/signin/identifier',
false,
true
)
expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
userAgent: googleAuthUserAgent()
})
releaseHeldUserAgentOverride()
await olderHeldWrite
// A newer failure must not discard an older write that is still in flight.
debuggerHandlers.get('detach')?.()
holdNextUserAgentOverride = true
willRedirect(
{ preventDefault: vi.fn() },
'https://accounts.google.com/v3/signin/identifier',
false,
true
)
const heldWrite = sendCommand.mock.results.at(-1)?.value as Promise<void>
rejectNextUserAgentOverride = true
didStartNavigation(null, 'https://example.com/', false, true)
await expect(sendCommand.mock.results.at(-1)?.value).rejects.toThrow('debugger detached')
releaseHeldUserAgentOverride()
await heldWrite
sendCommand.mockClear()
didStartNavigation(null, 'https://example.com/', false, true)
expect(sendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
userAgent: guestBaseUserAgent
})
browserManager.unregisterGuest('browser-redirect-ua')
})
// Why: a redirect must never be cancelled to keep the identity in sync. Without a debugger there is
// no cancel-free write available, and a stale navigator.userAgent is recoverable where a dead
// navigation is not.
it('leaves a redirect alone when no debugger is attached to write the UA', () => {
const guest = {
id: 419,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock,
getURL: vi.fn(() => 'https://mail.google.com/'),
getUserAgent: vi.fn(() => guestBaseUserAgent),
setUserAgent: vi.fn(),
debugger: { isAttached: vi.fn(() => false), sendCommand: vi.fn() },
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'browser-redirect-no-debugger',
webContentsId: guest.id,
rendererWebContentsId
})
const willRedirect = guestOnMock.mock.calls.find(
([event]) => event === 'will-redirect'
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
willRedirect(
{ preventDefault: vi.fn() },
'https://accounts.google.com/v3/signin/identifier',
false,
true
)
expect(guest.setUserAgent).not.toHaveBeenCalled()
expect(guest.debugger.sendCommand).not.toHaveBeenCalledWith(
'Emulation.setUserAgentOverride',
expect.anything()
)
})
// Why: a direct load reaches did-start-navigation before the request is dispatched, so the
// WebContents write is safe there and must stay — CDP is the redirect-path mechanism only.
it('still uses the WebContents UA write for navigations that are not redirects', () => {
const guest = {
id: 420,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock,
getURL: vi.fn(() => 'https://example.com/'),
getUserAgent: vi.fn(() => guestBaseUserAgent),
setUserAgent: vi.fn(),
debugger: { isAttached: vi.fn(() => true), sendCommand: vi.fn(async () => undefined) },
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'browser-direct-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
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent())
expect(guest.debugger.sendCommand).not.toHaveBeenCalledWith(
'Emulation.setUserAgentOverride',
expect.anything()
)
})
// Why: the direct-navigation branch still writes the Firefox UA through WebContents.setUserAgent,
// and nothing ever restores it once the guest switches to the CDP override. A viewport preset that
// read getUserAgent() back as its base identity would therefore republish Firefox on every ordinary
// host — the wire UA saying Firefox while sec-ch-ua still says Chrome, the exact cross-layer tell
// this scope exists to remove.
it('keeps a viewport preset on the session identity after an auth-host visit', async () => {
const { guest, debuggerSendCommand } = makeViewportGuest(9001)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-auth-then-preset',
webContentsId: guest.id as number,
rendererWebContentsId
})
await browserManager.setViewportOverride('tab-auth-then-preset', {
width: 1280,
height: 800,
deviceScaleFactor: 1,
mobile: false
})
await flushViewportOps()
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: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
await flushViewportOps()
// The direct branch pins the WebContents UA to Firefox and never restores it.
expect((guest.getUserAgent as () => string)()).toBe(googleAuthUserAgent())
willRedirect({ preventDefault: vi.fn() }, 'https://myaccount.google.com/', false, true)
await flushViewportOps()
debuggerSendCommand.mockClear()
didStartNavigation(null, 'https://github.com/', false, true)
await flushViewportOps()
const uaWrites = debuggerSendCommand.mock.calls.filter(
([method]) => method === 'Emulation.setUserAgentOverride'
)
expect(uaWrites.length).toBeGreaterThan(0)
for (const [, params] of uaWrites) {
expect((params as { userAgent: string }).userAgent).toBe(GUEST_CLEAN_UA)
}
})
})
@@ -55,6 +55,12 @@ import {
const { guestOnMock, webContentsFromIdMock } = browserMocks
const makeGuest = createViewportGuestFactory(browserMocks)
const MOBILE_VIEWPORT_OVERRIDE = {
width: 375,
height: 667,
deviceScaleFactor: 2,
mobile: true
} as const
describe('browserManager', () => {
beforeEach(() => {
@@ -68,12 +74,7 @@ describe('browserManager', () => {
describe('setViewportOverride', () => {
it('returns false when the tab is not registered', async () => {
const result = await browserManager.setViewportOverride('missing', {
width: 375,
height: 667,
deviceScaleFactor: 2,
mobile: true
})
const result = await browserManager.setViewportOverride('missing', MOBILE_VIEWPORT_OVERRIDE)
expect(result).toBe(false)
})
@@ -89,12 +90,7 @@ describe('browserManager', () => {
webContentsFromIdMock.mockReset()
webContentsFromIdMock.mockReturnValue(guest)
const ok = await browserManager.setViewportOverride('tab-mobile', {
width: 375,
height: 667,
deviceScaleFactor: 2,
mobile: true
})
const ok = await browserManager.setViewportOverride('tab-mobile', MOBILE_VIEWPORT_OVERRIDE)
expect(ok).toBe(true)
expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setDeviceMetricsOverride', {
@@ -194,6 +190,14 @@ describe('browserManager', () => {
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
// Case A: the desktop preset lands first, while the tab is still off the auth host.
await browserManager.setViewportOverride('tab-auth-nav', {
@@ -216,11 +220,9 @@ describe('browserManager', () => {
// Leaving the auth host restores the clean Chrome-shaped preset UA.
debuggerSendCommand.mockClear()
didStartNavigation(null, 'https://example.com/', false, true)
willRedirect({ preventDefault: vi.fn() }, 'https://example.com/', false, true)
await flushViewportOps()
expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
userAgent: GUEST_CLEAN_UA
})
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
})
// Why: not an ordering race — debugger.sendCommand dispatches in call order over one channel, so
@@ -263,12 +265,10 @@ describe('browserManager', () => {
([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
})
const presetDone = browserManager.setViewportOverride(
'tab-race-onto-auth',
MOBILE_VIEWPORT_OVERRIDE
)
await flushViewportOps()
// The tab navigates to the auth host while the preset is still awaiting its first command.
@@ -382,12 +382,7 @@ describe('browserManager', () => {
// 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 browserManager.setViewportOverride('tab-pending-cleared', MOBILE_VIEWPORT_OVERRIDE)
await flushViewportOps()
expect(lastUserAgentOverride(debuggerSendCommand)?.userAgent).toContain('iPhone')
@@ -450,12 +445,16 @@ describe('browserManager', () => {
willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/same', false, true)
didStartNavigation(null, 'https://accounts.google.com/same', false, true)
await flushViewportOps()
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
userAgent: googleAuthUserAgent()
})
debuggerSendCommand.mockClear()
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/same', true)
await flushViewportOps()
expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent())
// Why: the redirect path never writes the WebContents UA — that write cancels the navigation.
expect(guest.setUserAgent).not.toHaveBeenCalled()
expect(debuggerSendCommand).not.toHaveBeenCalledWith(
'Emulation.setUserAgentOverride',
expect.objectContaining({ userAgent: GUEST_CLEAN_UA })
@@ -507,14 +506,92 @@ describe('browserManager', () => {
)
await flushViewportOps()
expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent())
// Why: the identity switch for a redirect goes over CDP only. WebContents.setUserAgent() here
// makes Chromium cancel the in-flight navigation and replay the original request, which is what
// left Google sign-in on a blank tab.
expect(guest.setUserAgent).not.toHaveBeenCalled()
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(guest.setUserAgent).not.toHaveBeenCalled()
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
})
it('preserves the auth identity when a viewport preset is cleared after a redirect', async () => {
const { guest, debuggerSendCommand } = makeGuest(4262, 'https://example.com/')
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-clear-on-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 willRedirect = guestOnMock.mock.calls.find(
([event]) => event === 'will-redirect'
)?.[1] as (
event: { preventDefault: () => void },
url: string,
isInPlace: boolean,
isMainFrame: boolean
) => void
await browserManager.setViewportOverride('tab-clear-on-auth', MOBILE_VIEWPORT_OVERRIDE)
didStartNavigation(null, 'https://example.com/start', false, true)
willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/', false, true)
await flushViewportOps()
debuggerSendCommand.mockClear()
await expect(browserManager.setViewportOverride('tab-clear-on-auth', null)).resolves.toBe(
true
)
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
userAgent: googleAuthUserAgent()
})
expect(debuggerSendCommand).not.toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
userAgent: ''
})
})
it('does not inherit a mobile owner UA in a desktop popup', async () => {
const { guest: ownerGuest } = makeGuest(4263)
webContentsFromIdMock.mockReturnValue(ownerGuest)
browserManager.attachGuestPolicies(ownerGuest as never)
browserManager.registerGuest({
browserPageId: 'tab-mobile-popup-owner',
webContentsId: ownerGuest.id as number,
rendererWebContentsId
})
await browserManager.setViewportOverride('tab-mobile-popup-owner', MOBILE_VIEWPORT_OVERRIDE)
const { guest: popupGuest, debuggerSendCommand } = makeGuest(4264, 'https://mail.google.com/')
const popupOn = vi.fn()
popupGuest.on = popupOn
browserManager.attachGuestPolicies(popupGuest as never, {
browserTabId: 'tab-mobile-popup-owner',
rootGuestWebContentsId: ownerGuest.id as number
})
const willRedirect = popupOn.mock.calls.find(([event]) => event === 'will-redirect')?.[1] as (
event: { preventDefault: () => void },
url: string,
isInPlace: boolean,
isMainFrame: boolean
) => void
const didStartNavigation = popupOn.mock.calls.find(
([event]) => event === 'did-start-navigation'
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/', false, true)
await flushViewportOps()
debuggerSendCommand.mockClear()
didStartNavigation(null, 'https://example.com/', false, true)
await flushViewportOps()
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
})
+144 -15
View File
@@ -153,6 +153,15 @@ type PendingMainFrameNavigation = {
currentUrl: string
supersededUrls: string[]
}
type AuthUserAgentOverrideOperation = {
sequence: number
userAgent: string
}
type AuthUserAgentOverrideState = {
confirmed: AuthUserAgentOverrideOperation | null
nextSequence: number
pending: AuthUserAgentOverrideOperation[]
}
const SAFE_POPUP_WINDOW_OPTIONS = {
alwaysOnTop: false,
closable: true,
@@ -229,6 +238,12 @@ export class BrowserManager {
// 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 confirmed CDP identity outranks getUserAgent; pending intent keeps rapid navigations
// ordered without claiming a failed write was installed.
private readonly authUserAgentOverrideStateByGuestId = new Map<
number,
AuthUserAgentOverrideState
>()
// 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>()
@@ -315,6 +330,7 @@ export class BrowserManager {
// Why: proxy/bridge stop detaches the debugger and drops injections; re-attach (500ms delay to avoid racing a mid-restart) to keep overrides.
const onDetach = (): void => {
this.authUserAgentOverrideStateByGuestId.delete(guest.id)
if (!disposed && !guest.isDestroyed() && reattachTimer === null) {
reattachTimer = setTimeout(() => {
reattachTimer = null
@@ -810,7 +826,7 @@ export class BrowserManager {
return
}
this.updatePendingNavigationForRedirect(guest.id, url)
this.applyGoogleAuthUserAgent(guest, url)
this.applyGoogleAuthUserAgent(guest, url, { duringRedirect: true })
}
const didFailLoadHandler = (
@@ -946,7 +962,11 @@ export class BrowserManager {
// 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 {
private applyGoogleAuthUserAgent(
guest: Electron.WebContents,
url: string,
options: { duringRedirect?: boolean } = {}
): 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.
@@ -961,23 +981,113 @@ export class BrowserManager {
return
}
const firefoxUa = googleAuthUserAgent()
const currentUa = guest.getUserAgent()
if (isGoogleAuthUrl(url)) {
if (currentUa !== firefoxUa) {
guest.setUserAgent(firefoxUa)
const overrideState = this.authUserAgentOverrideStateByGuestId.get(guest.id)
const latestPendingOverride = overrideState?.pending.at(-1)
const confirmedOverride = overrideState?.confirmed
const currentOverride =
latestPendingOverride && latestPendingOverride.sequence > (confirmedOverride?.sequence ?? -1)
? latestPendingOverride
: confirmedOverride
const currentUa = currentOverride?.userAgent ?? guest.getUserAgent()
const nextUa = isGoogleAuthUrl(url)
? firefoxUa
: // Only restore when the auth-host override is actually in place, so normal
// navigation never touches the session UA.
currentUa === firefoxUa
? guest.session.getUserAgent()
: null
let authOverrideIssuedOverCdp = false
if (nextUa !== null && nextUa !== currentUa) {
// Why: WebContents.setUserAgent() during a redirect makes Chromium cancel the in-flight
// navigation (ERR_ABORTED) and replay the original request, which a POST-started OAuth chain
// cannot survive — the sign-in lands on a blank tab. CDP retargets navigator.userAgent without
// touching the navigation, and it outranks the WebContents UA from then on, so a guest that
// switches to it stays on it. The wire UA never depended on this write: setupClientHintsOverride
// rewrites User-Agent per request for auth-host URLs on its own.
if (options.duringRedirect === true || overrideState !== undefined) {
if (this.canOverrideUserAgentOverCdp(guest)) {
authOverrideIssuedOverCdp = true
// Why: go through the viewport builder rather than writing nextUa raw, so both CDP writers
// resolve one identity for this URL — Firefox on auth hosts, the profile's clean base off
// them, any mobile preset preserved. Writing the session UA directly would put the
// unlaundered Electron token back on the wire.
void this.applyAuthUserAgentOverrideOverCdp(
guest,
(browserPageId ? this.viewportUaOverrideMobileByTabId.get(browserPageId) : undefined) ??
false,
url,
nextUa
)
}
// Why: with no debugger there is no way to retarget the identity without cancelling the
// redirect. A stale navigator.userAgent is recoverable; a dead navigation is not.
} else {
guest.setUserAgent(nextUa)
}
} 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) {
if (browserPageId && !authOverrideIssuedOverCdp) {
this.reapplyViewportUserAgentOverride(guest, browserPageId, url)
}
}
private canOverrideUserAgentOverCdp(guest: Electron.WebContents): boolean {
try {
return !guest.isDestroyed() && guest.debugger.isAttached()
} catch {
return false
}
}
private applyAuthUserAgentOverrideOverCdp(
guest: Electron.WebContents,
mobile: boolean,
url: string,
userAgent: string
): Promise<boolean> {
if (!this.canOverrideUserAgentOverCdp(guest)) {
return Promise.resolve(false)
}
const state = this.authUserAgentOverrideStateByGuestId.get(guest.id) ?? {
confirmed: null,
nextSequence: 0,
pending: []
}
const operation = { sequence: ++state.nextSequence, userAgent }
state.pending.push(operation)
this.authUserAgentOverrideStateByGuestId.set(guest.id, state)
return this.sendViewportUserAgentOverride(guest, mobile, url, userAgent).then(
() => this.settleAuthUserAgentOverride(guest.id, state, operation, true),
() => {
this.settleAuthUserAgentOverride(guest.id, state, operation, false)
return false
}
)
}
private settleAuthUserAgentOverride(
guestId: number,
state: AuthUserAgentOverrideState,
operation: AuthUserAgentOverrideOperation,
succeeded: boolean
): boolean {
if (this.authUserAgentOverrideStateByGuestId.get(guestId) !== state) {
return false
}
if (succeeded && (state.confirmed?.sequence ?? -1) < operation.sequence) {
state.confirmed = operation
}
const pendingIndex = state.pending.indexOf(operation)
if (pendingIndex !== -1) {
state.pending.splice(pendingIndex, 1)
}
if (state.confirmed === null && state.pending.length === 0) {
this.authUserAgentOverrideStateByGuestId.delete(guestId)
}
return true
}
private startPendingNavigation(guestId: number, url: string): void {
const pending = this.pendingNavigationByGuestId.get(guestId)
this.pendingNavigationByGuestId.set(guestId, {
@@ -1044,7 +1154,8 @@ export class BrowserManager {
private async sendViewportUserAgentOverride(
guest: Electron.WebContents,
mobile: boolean,
url?: string
url?: string,
baseUserAgent?: string
): Promise<void> {
if (guest.isDestroyed() || !guest.debugger.isAttached()) {
return
@@ -1054,7 +1165,10 @@ export class BrowserManager {
buildViewportUserAgentOverride({
url: url ?? this.resolveTabNavigationUrl(guest),
mobile,
baseUserAgent: cleanElectronUserAgent(guest.getUserAgent())
// Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not:
// applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to
// the CDP override, so reading it back here would republish that identity on ordinary hosts.
baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent())
})
)
}
@@ -1107,6 +1221,7 @@ export class BrowserManager {
this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId)
this.offscreenGuestIds.delete(guestWebContentsId)
this.popupOwnerContextByGuestId.delete(guestWebContentsId)
this.authUserAgentOverrideStateByGuestId.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) {
@@ -1311,6 +1426,7 @@ export class BrowserManager {
this.sessionProfileIdByPageId.clear()
this.userAgentModeByPageId.clear()
this.viewportUaOverrideMobileByTabId.clear()
this.authUserAgentOverrideStateByGuestId.clear()
this.pendingNavigationByGuestId.clear()
this.pendingLoadFailuresByGuestId.clear()
this.loadErrorsByGuestId.clear()
@@ -1730,9 +1846,22 @@ export class BrowserManager {
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.
try {
await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
if (this.authUserAgentOverrideStateByGuestId.has(guest.id)) {
const url = this.resolveTabNavigationUrl(guest)
const restored = await this.applyAuthUserAgentOverrideOverCdp(
guest,
false,
url,
isGoogleAuthUrl(url) ? googleAuthUserAgent() : guest.session.getUserAgent()
)
if (!restored) {
throw new Error('Failed to preserve auth user agent')
}
} else {
// Why: passing an empty string restores the session default UA.
await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
}
} catch (error) {
if (trackedMobile !== undefined) {
this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile)