fix(browser): close Google auth UA navigation races

This commit is contained in:
Brennan Benson
2026-08-07 01:25:34 -07:00
parent 0cb77edaa3
commit 2363c3a04f
8 changed files with 410 additions and 51 deletions
+4 -1
View File
@@ -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)}...`)
}
+231 -9
View File
@@ -56,6 +56,7 @@ 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
@@ -1910,6 +1911,34 @@ describe('browserManager', () => {
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
@@ -3332,10 +3361,10 @@ describe('browserManager', () => {
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/', true)
await flushViewportOps()
// A mobile preset discriminates cleanly here: the auth branch would emit the Firefox UA, the
// non-auth branch an iPhone UA. Asserting the desktop branch instead would conflate this with
// applyGoogleAuthUserAgent leaving the WebContents UA on Firefox after an aborted auth nav,
// which is pre-existing behavior and out of scope for the ordering fix.
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,
@@ -3365,6 +3394,198 @@ describe('browserManager', () => {
})
})
it('does not let a superseded navigation failure revert a newer target', async () => {
const { guest, debuggerSendCommand } = makeGuest(4255, 'https://example.com/')
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-overlapping-navs',
webContentsId: guest.id as number,
rendererWebContentsId
})
const didStartNavigation = guestOnMock.mock.calls.find(
([event]) => event === 'did-start-navigation'
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
const willRedirect = guestOnMock.mock.calls.find(
([event]) => event === 'will-redirect'
)?.[1] as (
event: { preventDefault: () => void },
url: string,
isInPlace: boolean,
isMainFrame: boolean
) => void
const didFailLoad = guestOnMock.mock.calls.find(
([event]) => event === 'did-fail-load'
)?.[1] as (
event: unknown,
errorCode: number,
errorDescription: string,
validatedURL: string,
isMainFrame: boolean
) => void
await browserManager.setViewportOverride('tab-overlapping-navs', {
width: 1024,
height: 768,
deviceScaleFactor: 1,
mobile: false
})
didStartNavigation(null, 'https://example.com/start', false, true)
willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/same', false, true)
didStartNavigation(null, 'https://accounts.google.com/same', false, true)
await flushViewportOps()
debuggerSendCommand.mockClear()
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/same', true)
await flushViewportOps()
expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent())
expect(debuggerSendCommand).not.toHaveBeenCalledWith(
'Emulation.setUserAgentOverride',
expect.objectContaining({ userAgent: GUEST_CLEAN_UA })
)
})
it('switches identity for a server redirect and restores it if the redirect fails', async () => {
const { guest, debuggerSendCommand } = makeGuest(4256, 'https://example.com/')
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-auth-redirect',
webContentsId: guest.id as number,
rendererWebContentsId
})
const didStartNavigation = guestOnMock.mock.calls.find(
([event]) => event === 'did-start-navigation'
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
const willRedirect = guestOnMock.mock.calls.find(
([event]) => event === 'will-redirect'
)?.[1] as (
event: { preventDefault: () => void },
url: string,
isInPlace: boolean,
isMainFrame: boolean
) => void
const didFailLoad = guestOnMock.mock.calls.find(
([event]) => event === 'did-fail-load'
)?.[1] as (
event: unknown,
errorCode: number,
errorDescription: string,
validatedURL: string,
isMainFrame: boolean
) => void
await browserManager.setViewportOverride('tab-auth-redirect', {
width: 1024,
height: 768,
deviceScaleFactor: 1,
mobile: false
})
didStartNavigation(null, 'https://example.com/start', false, true)
willRedirect(
{ preventDefault: vi.fn() },
'https://accounts.google.com/redirected',
false,
true
)
await flushViewportOps()
expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent())
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
userAgent: googleAuthUserAgent()
})
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/redirected', true)
await flushViewportOps()
expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA)
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
})
it('reapplies a preset when navigation starts during its final UA write', async () => {
const { guest, debuggerSendCommand } = makeGuest(4257, 'https://example.com/')
let releaseFirstUa = (): void => {}
const firstUaGate = new Promise<void>((resolve) => {
releaseFirstUa = resolve
})
let uaWrites = 0
debuggerSendCommand.mockImplementation((method: string) => {
if (method === 'Emulation.setUserAgentOverride' && uaWrites++ === 0) {
return firstUaGate
}
return Promise.resolve(undefined)
})
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-final-apply-race',
webContentsId: guest.id as number,
rendererWebContentsId
})
const didStartNavigation = guestOnMock.mock.calls.find(
([event]) => event === 'did-start-navigation'
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
const presetDone = browserManager.setViewportOverride('tab-final-apply-race', {
width: 1024,
height: 768,
deviceScaleFactor: 1,
mobile: false
})
await flushViewportOps()
didStartNavigation(null, 'https://accounts.google.com/', false, true)
await flushViewportOps()
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
userAgent: googleAuthUserAgent()
})
releaseFirstUa()
await presetDone
})
it('does not reinstall a preset while its final UA clear is in flight', async () => {
const { guest, debuggerSendCommand } = makeGuest(4258, 'https://example.com/')
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-final-clear-race',
webContentsId: guest.id as number,
rendererWebContentsId
})
const didStartNavigation = guestOnMock.mock.calls.find(
([event]) => event === 'did-start-navigation'
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
await browserManager.setViewportOverride('tab-final-clear-race', {
width: 1024,
height: 768,
deviceScaleFactor: 1,
mobile: false
})
let releaseClearUa = (): void => {}
const clearUaGate = new Promise<void>((resolve) => {
releaseClearUa = resolve
})
debuggerSendCommand.mockImplementation(
(method: string, params: { userAgent?: string } | undefined) =>
method === 'Emulation.setUserAgentOverride' && params?.userAgent === ''
? clearUaGate
: Promise.resolve(undefined)
)
const clearDone = browserManager.setViewportOverride('tab-final-clear-race', null)
await flushViewportOps()
debuggerSendCommand.mockClear()
didStartNavigation(null, 'https://accounts.google.com/', false, true)
await flushViewportOps()
expect(debuggerSendCommand).not.toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
userAgent: googleAuthUserAgent()
})
releaseClearUa()
await clearDone
})
// Why: a failed clear leaves the CDP override standing on the target. Dropping the tracking entry
// first makes it untracked, so the navigation path can never correct it again and the tab carries
// a Chrome-shaped navigator.userAgent onto the auth hosts — the exact failure this PR prevents.
@@ -3389,11 +3610,12 @@ describe('browserManager', () => {
})
await flushViewportOps()
// The clear fails partway — e.g. DevTools took the debugger, or the guest is tearing down.
debuggerSendCommand.mockImplementation((method: string) =>
method === 'Emulation.clearDeviceMetricsOverride'
? Promise.reject(new Error('debugger detached'))
: Promise.resolve(undefined)
// 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
+98 -36
View File
@@ -43,6 +43,7 @@ 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 {
@@ -150,6 +151,10 @@ type PopupOwnerContext = {
browserTabId: string
rootGuestWebContentsId: number
}
type PendingMainFrameNavigation = {
currentUrl: string
supersededUrls: string[]
}
const SAFE_POPUP_WINDOW_OPTIONS = {
alwaysOnTop: false,
closable: true,
@@ -223,12 +228,12 @@ export class BrowserManager {
private readonly rendererWebContentsIdByTabId = new Map<string, number>()
// Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state.
private readonly viewportOpsByTabId = new Map<string, Promise<unknown>>()
// Why: presence means 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.
// Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation
// can re-issue it against the target URL's identity.
private readonly viewportUaOverrideMobileByTabId = new Map<string, boolean>()
// Why: the in-flight main-frame navigation target, held only until commit or failure — getURL()
// still reports the outgoing page until then. See resolveTabNavigationUrl.
private readonly pendingNavigationUrlByTabId = new Map<string, string>()
private readonly pendingNavigationByGuestId = new Map<number, PendingMainFrameNavigation>()
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
private readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
private readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
@@ -779,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 = (
@@ -806,7 +826,11 @@ export class BrowserManager {
return
}
// Why: a nav that never committed must not leave its target standing as the tab's host.
this.clearPendingNavigationUrl(guest.id)
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,12 +874,9 @@ export class BrowserManager {
if (!isMainFrame || isChromiumInternalErrorUrl(url)) {
return
}
const navigatingTabId = this.tabIdByWebContentsId.get(guest.id)
if (navigatingTabId) {
// 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.pendingNavigationUrlByTabId.set(navigatingTabId, url)
}
// 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.
@@ -873,14 +894,14 @@ 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.clearPendingNavigationUrl(guest.id)
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)
@@ -914,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)
@@ -934,7 +955,10 @@ export class BrowserManager {
// 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)
const mode = ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined
// 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
}
@@ -956,18 +980,48 @@ export class BrowserManager {
}
}
private clearPendingNavigationUrl(guestId: number): void {
const browserTabId = this.tabIdByWebContentsId.get(guestId)
if (browserTabId) {
this.pendingNavigationUrlByTabId.delete(browserTabId)
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, browserTabId: string): string {
return this.pendingNavigationUrlByTabId.get(browserTabId) ?? guest.getURL()
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,
@@ -986,12 +1040,11 @@ export class BrowserManager {
// 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, browserTabId, mobile, url).catch(() => {})
void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {})
}
private async sendViewportUserAgentOverride(
guest: Electron.WebContents,
browserTabId: string,
mobile: boolean,
url?: string
): Promise<void> {
@@ -1001,7 +1054,7 @@ export class BrowserManager {
await guest.debugger.sendCommand(
'Emulation.setUserAgentOverride',
buildViewportUserAgentOverride({
url: url ?? this.resolveTabNavigationUrl(guest, browserTabId),
url: url ?? this.resolveTabNavigationUrl(guest),
mobile,
baseUserAgent: cleanElectronUserAgent(guest.getUserAgent())
})
@@ -1056,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) {
@@ -1190,7 +1244,9 @@ export class BrowserManager {
// 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.pendingNavigationUrlByTabId.delete(browserTabId)
if (wcId !== undefined) {
this.pendingNavigationByGuestId.delete(wcId)
}
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
}
@@ -1257,7 +1313,7 @@ export class BrowserManager {
this.sessionProfileIdByPageId.clear()
this.userAgentModeByPageId.clear()
this.viewportUaOverrideMobileByTabId.clear()
this.pendingNavigationUrlByTabId.clear()
this.pendingNavigationByGuestId.clear()
this.pendingLoadFailuresByGuestId.clear()
this.loadErrorsByGuestId.clear()
this.clearedLoadErrorsByGuestId.clear()
@@ -1651,11 +1707,10 @@ export class BrowserManager {
})
// Why: viewport sizing must not override a profile's explicit native-UA identity.
if (this.userAgentModeByPageId.get(browserTabId) !== 'native') {
// Why: same sender as the navigation path, so both resolve the tab's host identically.
await this.sendViewportUserAgentOverride(guest, browserTabId, override.mobile)
// Why: only claim a standing override once the CDP write actually landed — recording it
// first would leave the map asserting an override that was never installed.
// 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', {})
@@ -1663,11 +1718,18 @@ export class BrowserManager {
enabled: false,
maxTouchPoints: 0
})
// Why: passing an empty string restores the session default UA.
await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
// Why: drop tracking only after the clear landed. Deleting first strands a still-standing
// CDP UA override with no map entry, so the navigation path can never correct it again.
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: '' })
} catch (error) {
if (trackedMobile !== undefined) {
this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile)
}
throw error
}
}
return true
} catch {
@@ -230,8 +230,10 @@ describe('BrowserSessionRegistry persistence', () => {
browserSessionRegistry.createProfile('isolated', 'Google', { userAgentMode: 'native' })
const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value
const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode')
expect(profileSession.setUserAgent).not.toHaveBeenCalled()
expect(setupClientHintsOverrideMock).not.toHaveBeenCalled()
expect(getBrowserSessionUserAgentMode(profileSession as never)).toBe('native')
})
it('merges partition-keyed pending entries without clobbering unrelated entries', async () => {
@@ -391,7 +393,14 @@ describe('BrowserSessionRegistry persistence', () => {
setupClientHintsOverrideMock.mock.calls.some(
(c: unknown[]) =>
(c[0] as { partition?: string } | undefined)?.partition === importedPartition &&
c[1] === importedUa
c[1] === importedUa &&
(c[2] as { googleAuthOverride?: boolean } | undefined)?.googleAuthOverride === false
)
).toBe(true)
const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode')
expect(
importedSessions.every(
(session) => getBrowserSessionUserAgentMode(session as never) === 'native'
)
).toBe(true)
})
@@ -432,6 +441,12 @@ describe('BrowserSessionRegistry persistence', () => {
([sess]) => (sess as { partition?: string }).partition === importedPartition
)
).toBe(false)
const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode')
expect(
importedSessions.every(
(session) => getBrowserSessionUserAgentMode(session as never) === 'native'
)
).toBe(true)
})
it('sets up default-partition policies on restore', async () => {
@@ -457,6 +457,27 @@ describe('BrowserSessionRegistry', () => {
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome')
})
it('keeps an imported native UA on auth hosts while aligning its Chrome hints', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
const importedUa =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
setupClientHintsOverride(mockSess, importedUa, { googleAuthOverride: false })
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
listener(
{
url: 'https://accounts.google.com/v3/signin/identifier',
requestHeaders: { 'User-Agent': importedUa, 'sec-ch-ua': 'old' }
},
callback
)
const modified = callback.mock.calls[0][0].requestHeaders
expect(modified['User-Agent']).toBe(importedUa)
expect(modified['sec-ch-ua']).toContain('Google Chrome')
})
it('leaves non-Client-Hints headers unchanged', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
+12 -2
View File
@@ -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())
+6 -2
View File
@@ -26,7 +26,11 @@ 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.
@@ -35,7 +39,7 @@ export function setupClientHintsOverride(sess: Session, ua: string): void {
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
const headers = details.requestHeaders
if (isGoogleAuthUrl(details.url)) {
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.
@@ -0,0 +1,22 @@
import type { Session } from 'electron'
import type { BrowserSessionUserAgentMode } from '../../shared/types'
const userAgentModeBySession = new WeakMap<Session, BrowserSessionUserAgentMode>()
export function setBrowserSessionUserAgentMode(
session: Session,
mode: BrowserSessionUserAgentMode
): void {
userAgentModeBySession.set(session, mode)
}
export function getBrowserSessionUserAgentMode(
session: Session
): BrowserSessionUserAgentMode | undefined {
return userAgentModeBySession.get(session)
}
export function clearBrowserSessionUserAgentMode(session: Session): void {
userAgentModeBySession.delete(session)
}