From f19ff5be68061ac35518d900ee74d24ef5c18318 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:37:53 -0700 Subject: [PATCH] fix(browser): keep one identity across hosts during Google sign-in (STA-3811) (#13667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) reached the header layer carrying the Firefox UA yet still bearing Chromium client hints, which the else-branch rewrote to Chrome. That paired a Firefox UA with Chrome client hints on every non-auth Google host — a sharper cross-host identity tell than either signal alone, and a plausible cause of the password-submit challenge greying out and stalling. Strip client hints on any request already carrying the Firefox auth UA so the UA and hint surfaces tell one Firefox story for the whole flow. Gated on the same googleAuthOverride flag as the auth-host switch, so imported-native profiles are unaffected and the clean-Chrome default for non-Google sites (Cloudflare) is untouched. Extends tests/tools/google-signin-ua-probe.cjs with app-current/app-fixed modes that mirror the shipped code and log per-request identity; on the real accounts.google.com load they show 18 firefox-ua-with-chrome-hints cross-host mismatches before and 0 after. --- src/main/browser/browser-google-auth-ua.ts | 8 ++ .../browser/browser-session-registry.test.ts | 76 +++++++++++++ src/main/browser/browser-session-ua.ts | 13 +++ tests/tools/google-signin-ua-probe.cjs | 106 +++++++++++++++++- 4 files changed, 201 insertions(+), 2 deletions(-) diff --git a/src/main/browser/browser-google-auth-ua.ts b/src/main/browser/browser-google-auth-ua.ts index 4392e6351c2..16ed14eb80f 100644 --- a/src/main/browser/browser-google-auth-ua.ts +++ b/src/main/browser/browser-google-auth-ua.ts @@ -49,3 +49,11 @@ export function setUserAgentHeader(headers: Record, value: strin const existing = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent') headers[existing ?? 'User-Agent'] = value } + +// Why: the outgoing header carries the WebContents UA override, so reading it +// (case-insensitively) tells us whether a request originated from the Firefox +// auth document even when its destination host isn't an auth host. +export function currentUserAgent(headers: Record): string | undefined { + const existing = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent') + return existing ? headers[existing] : undefined +} diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 44e1b6b8e92..9f32e4f379d 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -28,6 +28,7 @@ vi.mock('./browser-manager', () => ({ })) import { browserSessionRegistry } from './browser-session-registry' +import { googleAuthUserAgent } from './browser-google-auth-ua' import { setupClientHintsOverride } from './browser-session-ua' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' import { @@ -440,6 +441,81 @@ describe('BrowserSessionRegistry', () => { expect(modified['sec-ch-ua-platform']).toBeUndefined() }) + it('strips client hints on a cross-host request that carries the Firefox auth UA', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + setupClientHintsOverride( + mockSess, + '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' + ) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + // Subresource/XHR to a non-auth Google host while the auth document is on + // screen: the WebContents Firefox UA leaks onto the request header. + listener( + { + url: 'https://play.google.com/log', + requestHeaders: { + 'User-Agent': googleAuthUserAgent(), + 'sec-ch-ua': 'old', + 'sec-ch-ua-full-version-list': 'old', + 'sec-ch-ua-platform': '"macOS"', + 'sec-ch-ua-mobile': '?0' + } + }, + callback + ) + const modified = callback.mock.calls[0][0].requestHeaders + // UA stays Firefox and every client hint is dropped — one consistent identity. + expect(modified['User-Agent']).toBe(googleAuthUserAgent()) + expect(modified['sec-ch-ua']).toBeUndefined() + expect(modified['sec-ch-ua-full-version-list']).toBeUndefined() + expect(modified['sec-ch-ua-platform']).toBeUndefined() + expect(modified['sec-ch-ua-mobile']).toBeUndefined() + }) + + it('keeps the clean Chrome identity on cross-host requests that carry the Chrome UA', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + const chromeUa = + '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, chromeUa) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + // Regression guard: non-Google sites (Cloudflare) must keep Chrome hints. + listener( + { + url: 'https://example.com/api', + requestHeaders: { 'User-Agent': chromeUa, 'sec-ch-ua': 'old' } + }, + callback + ) + expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome') + }) + + it('does not strip hints for the Firefox UA when googleAuthOverride is disabled', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + const chromeUa = + '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, chromeUa, { googleAuthOverride: false }) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener( + { + url: 'https://play.google.com/log', + requestHeaders: { 'User-Agent': googleAuthUserAgent(), 'sec-ch-ua': 'old' } + }, + callback + ) + // Imported-native profiles never install the Firefox switch, so the strip + // branch stays inert and hints are aligned to Chrome instead. + expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome') + }) + it('keeps Chrome client hints on Google app subdomains (not auth hosts)', () => { const onBeforeSendHeaders = vi.fn() const mockSess = { webRequest: { onBeforeSendHeaders } } as never diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index 092b79995d8..96c55cf5ac9 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -1,6 +1,7 @@ import type { Session } from 'electron' import { + currentUserAgent, googleAuthUserAgent, isGoogleAuthUrl, setUserAgentHeader, @@ -48,6 +49,18 @@ export function setupClientHintsOverride( callback({ requestHeaders: headers }) return } + if (options.googleAuthOverride !== false && currentUserAgent(headers) === 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 + // bearing Chromium client hints. Rewriting those to Chrome pairs a Firefox + // UA with Chrome hints — a sharper cross-host identity tell than either + // alone, which can stall Google's password-submit challenge. Real Firefox + // sends no client hints, so strip them to keep one identity for the flow. + stripClientHints(headers) + callback({ requestHeaders: headers }) + return + } if (chromeHints) { for (const key of Object.keys(headers)) { const lower = key.toLowerCase() diff --git a/tests/tools/google-signin-ua-probe.cjs b/tests/tools/google-signin-ua-probe.cjs index 4fe50a2c122..7adf06bb7a0 100644 --- a/tests/tools/google-signin-ua-probe.cjs +++ b/tests/tools/google-signin-ua-probe.cjs @@ -9,13 +9,27 @@ const MODES = new Set([ 'electron-auth', 'electron-fixed', 'firefox-auth', - 'firefox-fixed' + 'firefox-fixed', + // Replicates the SHIPPED app exactly (setupClientHintsOverride + + // applyGoogleAuthUserAgent): Firefox UA is written to the WebContents on auth + // navs and to the request header only for auth-host URLs; every other request + // keeps whatever UA the WebContents carries. Logs incoming vs outgoing + // identity for ALL requests to expose cross-host mismatches during the flow. + 'app-current', + // Same, but with the STA-3811 fix: the header layer strips client hints on any + // request already carrying the Firefox auth UA, regardless of destination host. + 'app-fixed' ]) const mode = process.argv.find((arg) => arg.startsWith('--mode='))?.slice('--mode='.length) if (!mode || !MODES.has(mode)) { throw new Error(`Expected --mode=${[...MODES].join('|')}`) } +const headless = process.argv.includes('--headless') +const exitAfterMs = Number( + process.argv.find((arg) => arg.startsWith('--exit-after-ms='))?.slice('--exit-after-ms='.length) +) + const profileRoot = mkdtempSync(join(tmpdir(), `orca-google-signin-${mode}-`)) const partition = `persist:google-signin-${mode}` app.setPath('userData', profileRoot) @@ -58,6 +72,30 @@ function safeUrl(rawUrl) { } } +function hostOf(rawUrl) { + try { + return new URL(rawUrl).hostname.toLowerCase() + } catch { + return '' + } +} + +// A Firefox UA paired with any sec-ch-ua header is the cross-host tell we hunt: +// real Firefox emits no client hints, so the two surfaces contradict each other. +function detectUaHintMismatch(headers) { + const ua = headers['user-agent'] || '' + const isFirefox = /Firefox\/\d/.test(ua) && !/Chrome\//.test(ua) + const hasHints = Object.keys(headers).some((key) => key.startsWith('sec-ch-ua')) + if (isFirefox && hasHints) { + return 'firefox-ua-with-chrome-hints' + } + const isChrome = /Chrome\/\d/.test(ua) + if (isChrome && !hasHints) { + return 'chrome-ua-without-hints' + } + return null +} + function setHeader(headers, name, value) { const existing = Object.keys(headers).find((key) => key.toLowerCase() === name.toLowerCase()) headers[existing ?? name] = value @@ -126,8 +164,47 @@ app.whenReady().then(async () => { identities.cleaned = cleanElectronUserAgent(identities.native) browserSession.setUserAgent(identityForUrl('about:blank', identities)) + const isAppMode = mode === 'app-current' || mode === 'app-fixed' + browserSession.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => { const headers = details.requestHeaders + const incoming = relevantHeaders(headers) + + if (isAppMode) { + // Mirror setupClientHintsOverride: only auth-host URLs get the Firefox UA + // header + hint strip; every other request keeps its incoming UA (which is + // the WebContents UA — Firefox while the auth document is on screen). + if (isGoogleAuthUrl(details.url)) { + setHeader(headers, 'user-agent', identities.firefox) + removeClientHints(headers) + } else { + const currentUa = incoming['user-agent'] + // STA-3811 fix: a request already carrying the Firefox auth UA came from + // the auth document; real Firefox sends no client hints, so strip them + // instead of rewriting to Chrome — keeping UA and hints one story. + if (mode === 'app-fixed' && currentUa === identities.firefox) { + removeClientHints(headers) + } else { + // Real setupClientHintsOverride builds Chrome hints once from the + // session's cleaned UA (a closure), never from the per-request UA. + applyChromeClientHints(headers, identities.cleaned) + } + } + const outgoing = relevantHeaders(headers) + const uaMismatch = detectUaHintMismatch(outgoing) + log('request', { + url: safeUrl(details.url), + host: hostOf(details.url), + resourceType: details.resourceType, + isAuthHost: isGoogleAuthUrl(details.url), + incoming, + outgoing, + uaHintMismatch: uaMismatch + }) + callback({ requestHeaders: headers }) + return + } + const identity = identityForUrl(details.url, identities) setHeader(headers, 'user-agent', identity) if (identity === identities.firefox) { @@ -144,8 +221,15 @@ app.whenReady().then(async () => { callback({ requestHeaders: headers }) }) + if (Number.isFinite(exitAfterMs) && exitAfterMs > 0) { + setTimeout(() => { + log('exit', { reason: 'timeout', exitAfterMs }) + app.quit() + }, exitAfterMs) + } + const window = new BrowserWindow({ - show: true, + show: !headless, title: `Google sign-in UA probe: ${mode}`, width: 980, height: 840, @@ -161,6 +245,24 @@ app.whenReady().then(async () => { if (!isMainFrame) { return } + if (isAppMode) { + // Mirror applyGoogleAuthUserAgent: Firefox UA on auth navs, restore the + // cleaned session UA otherwise. This WebContents UA is what leaks onto + // cross-host subresource requests while the auth document is on screen. + const current = window.webContents.getUserAgent() + if (isGoogleAuthUrl(url)) { + if (current !== identities.firefox) { + window.webContents.setUserAgent(identities.firefox) + } + } else if (current === identities.firefox) { + window.webContents.setUserAgent(identities.cleaned) + } + log('main-frame-navigation', { + url: safeUrl(url), + appliedUserAgent: window.webContents.getUserAgent() + }) + return + } const identity = identityForUrl(url, identities) if (window.webContents.getUserAgent() !== identity) { window.webContents.setUserAgent(identity)