From 511580b249ef10941d1ab33a37ce038c6930aaf2 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 10 Sep 2026 21:57:31 -0700 Subject: [PATCH] fix(browser): enforce per-guest request user agent --- .../browser-manager-auth-user-agent.test.ts | 52 +++++++++- .../browser/browser-manager-navigation.ts | 57 ++++++++++- .../browser-session-partition-policies.ts | 8 +- ...owser-session-registry.persistence.test.ts | 5 +- src/main/browser/browser-session-ua.test.ts | 97 +++++++++++++++++++ src/main/browser/browser-session-ua.ts | 52 +++++++++- 6 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 src/main/browser/browser-session-ua.test.ts diff --git a/src/main/browser/browser-manager-auth-user-agent.test.ts b/src/main/browser/browser-manager-auth-user-agent.test.ts index 71e128a9c72..dc03362ad29 100644 --- a/src/main/browser/browser-manager-auth-user-agent.test.ts +++ b/src/main/browser/browser-manager-auth-user-agent.test.ts @@ -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) diff --git a/src/main/browser/browser-manager-navigation.ts b/src/main/browser/browser-manager-navigation.ts index c11626fd516..63e1514d92a 100644 --- a/src/main/browser/browser-manager-navigation.ts +++ b/src/main/browser/browser-manager-navigation.ts @@ -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[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. diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index b7022185174..c0f5a88ffcc 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -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) */ } diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index b6495d3d1e1..9165f4f0143 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -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 () => { diff --git a/src/main/browser/browser-session-ua.test.ts b/src/main/browser/browser-session-ua.test.ts new file mode 100644 index 00000000000..a08ae73f44f --- /dev/null +++ b/src/main/browser/browser-session-ua.test.ts @@ -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 +} + +type RequestListener = ( + details: RequestDetails, + callback: (response: { requestHeaders: Record }) => void +) => void + +function install(resolveRequestUserAgent?: Parameters[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 { + 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() + }) +}) diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index b7fd26449f5..dc9d80e2f09 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -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