Files
orca/src/main/browser/browser-session-ua.ts
T
Brennan Benson c03c01ad15 fix(browser): stop impersonating the source browser's UA on cookie import (STA-3514) (#12849)
* fix(browser): stop impersonating the source browser's UA on cookie import (STA-3514)

Delete the import-time UA synthesis entirely instead of patching its version
detection. The session now always keeps the engine-derived UA the registry
sets at startup (clean or native), for imported and non-imported profiles
alike.

Why deletion is the fix:
- The synthesis read CFBundleShortVersionString, which on forks is a
  marketing version — Arc imports presented Chrome/1.x and sites rejected
  the browser as ancient (STA-3514).
- Its stated purpose (keep Google-bound cookies valid) is handled by
  isGoogleSourceBoundCookie excluding those cookies since #736, and #12884
  measured that Google rejects re-transplants regardless of UA identity.
- #12608 measured the synthesized Chrome-shaped UA reaching
  /v3/signin/rejected while the engine UA reached account lookup.

Persisted userAgent/userAgentByPartition meta is no longer read; legacy keys
drop off on the next meta write. The #12811 gate existed only to catch bad
synthesized values, so it leaves with the synthesis.

* test(browser): catch source UA impersonation regression
2026-08-09 17:05:00 -07:00

88 lines
3.2 KiB
TypeScript

import type { Session } from 'electron'
import {
googleAuthUserAgent,
isGoogleAuthUrl,
setUserAgentHeader,
stripClientHints
} from './browser-google-auth-ua'
// Why: Electron's default UA includes "Electron/X.X.X" and the app name
// (e.g. "orca/1.2.3"), which Cloudflare Turnstile and other bot detectors
// flag as non-human traffic. Strip those tokens so the webview's UA and
// sec-ch-ua Client Hints look like standard Chrome.
export function cleanElectronUserAgent(ua: string): string {
return (
ua
.replace(/\s+Electron\/\S+/, '')
// Why: \S+ matches any non-whitespace token (e.g. "orca/1.3.8-rc.0")
// including pre-release semver strings that [\d.]+ would miss.
.replace(/(\)\s+)\S+\s+(Chrome\/)/, '$1$2')
)
}
// Why: Electron emits sec-ch-ua brands like "Not A(Brand" without a
// "Google Chrome" entry, which disagrees with the Chrome-shaped UA the session
// presents. Rewrite the hint headers to the brand set Chrome ships for the same
// engine version so the two surfaces tell one story. Also owns the Google
// auth-host Firefox switch, which must install even for a non-Chrome-shaped UA.
export function setupClientHintsOverride(
sess: Session,
ua: string,
options: { googleAuthOverride?: boolean } = {}
): void {
// Why: only Chrome-shaped base UAs carry sec-ch-ua hints to rewrite, but the
// Google-auth Firefox switch below must install regardless, so keep the hints
// optional rather than bailing out of the whole handler.
const chromeHints = buildChromeClientHints(ua)
const firefoxUa = googleAuthUserAgent()
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
const headers = details.requestHeaders
if (options.googleAuthOverride !== false && 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
// sec-ch-ua* because real Firefox sends none.
setUserAgentHeader(headers, firefoxUa)
stripClientHints(headers)
callback({ requestHeaders: headers })
return
}
if (chromeHints) {
for (const key of Object.keys(headers)) {
const lower = key.toLowerCase()
if (lower === 'sec-ch-ua') {
headers[key] = chromeHints.secChUa
} else if (lower === 'sec-ch-ua-full-version-list') {
headers[key] = chromeHints.secChUaFull
}
}
}
callback({ requestHeaders: headers })
})
}
function buildChromeClientHints(ua: string): { secChUa: string; secChUaFull: string } | null {
const chromeMatch = ua.match(/Chrome\/([\d.]+)/)
if (!chromeMatch) {
return null
}
const fullChromeVersion = chromeMatch[1]
const majorVersion = fullChromeVersion.split('.')[0]
let brand = 'Google Chrome'
let brandFullVersion = fullChromeVersion
const edgeMatch = ua.match(/Edg\/([\d.]+)/)
if (edgeMatch) {
brand = 'Microsoft Edge'
brandFullVersion = edgeMatch[1]
}
const brandMajor = brandFullVersion.split('.')[0]
return {
secChUa: `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`,
secChUaFull: `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"`
}
}