fix(browser): enforce per-guest request user agent

This commit is contained in:
Merge Sim
2026-09-11 19:44:44 -07:00
parent 556a7772ed
commit 511580b249
6 changed files with 262 additions and 9 deletions
@@ -51,7 +51,8 @@ import {
import {
createViewportGuestFactory,
flushViewportOps,
GUEST_CLEAN_UA
GUEST_CLEAN_UA,
GUEST_ELECTRON_UA
} from './browser-manager-viewport-test-fixtures'
const {
@@ -63,6 +64,12 @@ const {
webContentsFromIdMock
} = browserMocks
const makeViewportGuest = createViewportGuestFactory(browserMocks)
const MOBILE_VIEWPORT_OVERRIDE = {
width: 375,
height: 667,
deviceScaleFactor: 2,
mobile: true
} as const
describe('browserManager', () => {
beforeEach(() => {
@@ -74,6 +81,49 @@ describe('browserManager', () => {
vi.useRealTimers()
})
it('resolves request identity from the guest viewport and auth state', async () => {
const { guest } = makeViewportGuest(4245)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-request-identity',
webContentsId: guest.id as number,
rendererWebContentsId
})
// Ablation: with no mobile preset, the clean session identity is resolved.
expect(
browserManager.resolveBrowserGuestRequestUserAgent({
url: 'https://example.com/logo.png',
webContentsId: guest.id as number,
currentUserAgent: GUEST_CLEAN_UA,
baseUserAgent: GUEST_ELECTRON_UA
})
).toBe(GUEST_CLEAN_UA)
await browserManager.setViewportOverride('tab-request-identity', MOBILE_VIEWPORT_OVERRIDE)
await flushViewportOps()
const mobile = browserManager.resolveBrowserGuestRequestUserAgent({
url: 'https://example.com/logo.png',
webContentsId: guest.id as number,
currentUserAgent: GUEST_CLEAN_UA,
baseUserAgent: GUEST_ELECTRON_UA
})
expect(mobile).toContain('CriOS/134')
expect(mobile).toContain('iPhone')
// Negative control: auth-document fan-out remains Firefox even while mobile emulation is active.
expect(
browserManager.resolveBrowserGuestRequestUserAgent({
url: 'https://www.gstatic.com/_/signin/log',
webContentsId: guest.id as number,
currentUserAgent: GUEST_ELECTRON_UA,
effectiveUserAgent: googleAuthUserAgent(),
baseUserAgent: GUEST_ELECTRON_UA
})
).toBe(googleAuthUserAgent())
})
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)
+56 -1
View File
@@ -1,5 +1,8 @@
import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window'
import { cleanElectronUserAgent } from './browser-session-ua'
import {
cleanElectronUserAgent,
type BrowserSessionRequestUserAgentResolver
} 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'
@@ -11,6 +14,58 @@ import {
import { BrowserManagerVisibility } from './browser-manager-visibility'
export abstract class BrowserManagerNavigation extends BrowserManagerVisibility {
/** Resolve the one legacy User-Agent value the session hook must enforce for this guest request. */
resolveBrowserGuestRequestUserAgent(
request: Parameters<BrowserSessionRequestUserAgentResolver>[0]
): string {
const firefoxUa = googleAuthUserAgent()
const pendingNavigation =
request.webContentsId === undefined
? undefined
: this.pendingNavigationByGuestId.get(request.webContentsId)
// A navigation on the auth document fans out to non-auth hosts. Its current request identity
// is authoritative for that flow and must not be replaced by the profile/mobile default.
if (
request.currentUserAgent === firefoxUa &&
(!pendingNavigation || isGoogleAuthUrl(pendingNavigation.currentUrl))
) {
return firefoxUa
}
const overrideState =
request.webContentsId === undefined
? undefined
: this.authUserAgentOverrideStateByGuestId.get(request.webContentsId)
const currentOverride =
overrideState?.pending.at(-1) &&
overrideState.pending.at(-1)!.sequence > (overrideState.confirmed?.sequence ?? -1)
? overrideState.pending.at(-1)
: overrideState?.confirmed
if (
!currentOverride &&
request.effectiveUserAgent === firefoxUa &&
(!pendingNavigation || isGoogleAuthUrl(pendingNavigation.currentUrl))
) {
// Direct auth navigations use WebContents.setUserAgent, which Electron fails to carry onto
// image/XHR/fetch requests. Read that effective guest identity so those paths stay Firefox.
return firefoxUa
}
if (currentOverride?.userAgent === firefoxUa) {
return firefoxUa
}
const browserPageId =
request.webContentsId === undefined
? undefined
: this.tabIdByWebContentsId.get(request.webContentsId)
const mobile = browserPageId
? (this.viewportUaOverrideMobileByTabId.get(browserPageId) ?? false)
: false
return buildViewportUserAgentOverride({
url: request.url,
mobile,
baseUserAgent: cleanElectronUserAgent(request.baseUserAgent)
}).userAgent
}
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
// not the request header, so the header-level Firefox switch in setupGoogleAuthUserAgentOverride
// must be matched here per navigation or the two layers disagree — itself a bot tell.
@@ -95,7 +95,9 @@ export function installBrowserSessionPartitionPolicies(
if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') {
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
sess.setUserAgent(cleanUA)
setupGoogleAuthUserAgentOverride(sess)
setupGoogleAuthUserAgentOverride(sess, (request) =>
browserManager.resolveBrowserGuestRequestUserAgent(request)
)
}
if (options?.permissions === 'deny') {
sess.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false))
@@ -195,7 +197,9 @@ export function applyBrowserSessionUserAgentModes(profiles: BrowserSessionProfil
// Why: imported sessions need the same Chrome-shaped identity after app restart.
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
sess.setUserAgent(cleanUA)
setupGoogleAuthUserAgentOverride(sess)
setupGoogleAuthUserAgentOverride(sess, (request) =>
browserManager.resolveBrowserGuestRequestUserAgent(request)
)
} catch {
/* session not available yet (e.g. unit tests or pre-ready) */
}
@@ -255,7 +255,10 @@ describe('BrowserSessionRegistry persistence', () => {
const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value
expect(cleanElectronUserAgentMock).toHaveBeenCalledWith(RAW_ELECTRON_USER_AGENT)
expect(profileSession.setUserAgent).toHaveBeenCalledWith(CLEAN_USER_AGENT)
expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalledWith(profileSession)
expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalledWith(
profileSession,
expect.any(Function)
)
})
it('leaves UA and client hints untouched for native-mode profiles', async () => {
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from 'vitest'
import { googleAuthUserAgent } from './browser-google-auth-ua'
import { cleanElectronUserAgent, setupGoogleAuthUserAgentOverride } from './browser-session-ua'
type RequestDetails = {
url: string
webContentsId?: number
webContents?: { getUserAgent: () => string }
requestHeaders: Record<string, string>
}
type RequestListener = (
details: RequestDetails,
callback: (response: { requestHeaders: Record<string, string> }) => void
) => void
function install(resolveRequestUserAgent?: Parameters<typeof setupGoogleAuthUserAgentOverride>[1]) {
const onBeforeSendHeaders = vi.fn()
const sess = {
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'
),
webRequest: { onBeforeSendHeaders }
}
setupGoogleAuthUserAgentOverride(sess as never, resolveRequestUserAgent)
return onBeforeSendHeaders.mock.calls[0][1] as RequestListener
}
function runRequest(listener: RequestListener, details: RequestDetails): Record<string, string> {
const callback = vi.fn()
listener(details, callback)
return callback.mock.calls[0][0].requestHeaders
}
describe('browser session request identity', () => {
it('ablates the resolver on an image request while enforcing its per-guest value when enabled', () => {
const mobileUa =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/134.0.0.0 Mobile/15E148 Safari/604.1'
const request = {
url: 'https://example.com/logo.png',
webContentsId: 44,
requestHeaders: {
'User-Agent': 'Electron/30 Chrome/134',
'sec-ch-ua': 'browser-owned'
}
}
// Ablation: with the new resolver disabled, the clean session identity is the only fallback.
const disabled = runRequest(install(), structuredClone(request))
expect(disabled['User-Agent']).toBe(
cleanElectronUserAgent(
'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 resolver = vi.fn(() => mobileUa)
const enabled = runRequest(install(resolver), structuredClone(request))
expect(enabled['User-Agent']).toBe(mobileUa)
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ url: request.url, webContentsId: request.webContentsId })
)
// Negative control: the resolver does not alter Chromium-owned client hints.
expect(enabled['sec-ch-ua']).toBe('browser-owned')
})
it('keeps Firefox across auth-document cross-host requests and strips its hints', () => {
const listener = install(({ effectiveUserAgent }) => effectiveUserAgent)
const headers = runRequest(listener, {
url: 'https://www.gstatic.com/_/signin/log',
webContentsId: 7,
requestHeaders: {
'User-Agent': googleAuthUserAgent(),
'sec-ch-ua': 'browser-owned',
'sec-ch-ua-platform': '"macOS"'
},
webContents: { getUserAgent: () => googleAuthUserAgent() } as never
})
expect(headers['User-Agent']).toBe(googleAuthUserAgent())
expect(headers['sec-ch-ua']).toBeUndefined()
expect(headers['sec-ch-ua-platform']).toBeUndefined()
})
it('keeps the Firefox auth-host branch independent of the resolver', () => {
const resolver = vi.fn(() => 'unexpected Chrome identity')
const headers = runRequest(install(resolver), {
url: 'https://accounts.google.com/v3/signin/identifier',
requestHeaders: {
'User-Agent': 'Chrome/134',
'sec-ch-ua': 'browser-owned'
}
})
expect(headers['User-Agent']).toBe(googleAuthUserAgent())
expect(headers['sec-ch-ua']).toBeUndefined()
expect(resolver).not.toHaveBeenCalled()
})
})
+48 -4
View File
@@ -22,14 +22,38 @@ export function cleanElectronUserAgent(ua: string): string {
)
}
export type BrowserSessionRequestUserAgentResolver = (args: {
url: string
webContentsId?: number
currentUserAgent?: string
effectiveUserAgent?: string
baseUserAgent: string
}) => string | undefined
// Why: Chromium already publishes one internally consistent client-hint identity through both
// request headers and navigator.userAgentData. This handler only owns the host-scoped Firefox
// exception; synthesizing Chrome brands here would make those two browser-owned surfaces disagree.
export function setupGoogleAuthUserAgentOverride(sess: Session): void {
// request headers and navigator.userAgentData. This handler owns the legacy User-Agent header on
// the wire; synthesizing Chrome brands here would make those browser-owned surfaces disagree.
export function setupGoogleAuthUserAgentOverride(
sess: Session,
resolveRequestUserAgent?: BrowserSessionRequestUserAgentResolver
): void {
const firefoxUa = googleAuthUserAgent()
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
const headers = details.requestHeaders
const requestUserAgent = currentUserAgent(headers)
let effectiveUserAgent: string | undefined
try {
effectiveUserAgent = details.webContents?.getUserAgent()
} catch {
// The request can race guest teardown; the header and manager state still provide a fallback.
}
// The resolver is supplied by the browser manager so this layer can enforce viewport and
// auth identities without importing manager state into the session policy (which would cycle).
const baseUserAgent =
typeof sess.getUserAgent === 'function'
? cleanElectronUserAgent(sess.getUserAgent())
: (requestUserAgent ?? '')
if (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
@@ -39,7 +63,27 @@ export function setupGoogleAuthUserAgentOverride(sess: Session): void {
callback({ requestHeaders: headers })
return
}
if (currentUserAgent(headers) === firefoxUa) {
const userAgent =
// Requests from an auth document fan out to gstatic and other non-auth hosts. Preserve the
// Firefox identity already placed on those requests instead of switching them to Chrome.
requestUserAgent === firefoxUa
? firefoxUa
: resolveRequestUserAgent
? (resolveRequestUserAgent({
url: details.url,
webContentsId: details.webContentsId,
currentUserAgent: requestUserAgent,
effectiveUserAgent,
baseUserAgent
}) ??
(baseUserAgent || requestUserAgent))
: effectiveUserAgent === firefoxUa
? firefoxUa
: baseUserAgent || requestUserAgent
if (userAgent) {
setUserAgentHeader(headers, userAgent)
}
if (userAgent === firefoxUa) {
// Why: while the auth document is on screen the WebContents UA is Firefox,
// so its cross-host subresource/XHR requests (gstatic, play.google.com, the
// sign-in challenge endpoints) reach here carrying the Firefox UA yet still