mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(browser): keep one identity across hosts during Google sign-in (STA-3811) (#13667)
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.
This commit is contained in:
@@ -49,3 +49,11 @@ export function setUserAgentHeader(headers: Record<string, string>, 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, string>): string | undefined {
|
||||
const existing = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent')
|
||||
return existing ? headers[existing] : undefined
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 '<invalid>'
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user