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)