feat(browser): process-wide browser identity, chosen before ready

Electron resolves worker identity from a single process-global default, so two
coherent identities cannot coexist in one process. This makes clean/native one
app-wide decision read before `ready`, instead of a per-profile one that leaves
documents on one identity and every worker request on the other.

Both identities are load-bearing, measured across four origins at five reps:
the cleaned identity clears an embedded Turnstile widget and WhatsApp's browser
check where native is refused; native clears a full-page Cloudflare interstitial
that the cleaned identity never clears.

Base commit only: removing the per-profile field, its settings surface, and the
migration notice follow.
This commit is contained in:
Brennan Benson
2026-09-13 17:29:51 -07:00
parent 5e70014da8
commit a92a1eb29a
6 changed files with 1431 additions and 212 deletions
@@ -0,0 +1,75 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types'
/**
* The browser's identity is one process-wide decision, not a per-profile one.
*
* Electron resolves worker identity from a single process-global default, so two coherent
* identities cannot coexist in one process: a per-profile native mode leaves documents on one
* identity and every worker request on the other, which is a sharper bot signal than either
* alone. The choice therefore lives here, is read before `ready`, and applies to the whole app.
*
* Both identities are load-bearing, which is why this is a choice and not a constant. Measured
* across four origins, five repetitions each: the cleaned identity clears an embedded Turnstile
* widget and WhatsApp's browser check while the native identity is refused by both; the native
* identity clears a full-page Cloudflare interstitial that the cleaned identity never clears.
*
* Read with `readFileSync` rather than through the settings store because the store loads long
* after `ready`, and by then every session and worker has already taken its default.
*/
export const BROWSER_IDENTITY_MODE_FILE = 'browser-identity-mode.json'
export const BROWSER_IDENTITY_MODE_VERSION = 1
export type BrowserIdentityModeRecord = {
version: typeof BROWSER_IDENTITY_MODE_VERSION
mode: BrowserSessionUserAgentMode
/** Profiles that carried the retired per-profile `native` mode, so the browser can say so once. */
migratedNativeProfileIds?: string[]
/** Cleared once the user has been told their per-profile choice no longer exists. */
migrationNoticePending?: boolean
}
export function browserIdentityModeRecordPath(userDataPath: string): string {
return join(userDataPath, BROWSER_IDENTITY_MODE_FILE)
}
function parseRecord(raw: string): BrowserIdentityModeRecord | null {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse is untyped; every member is checked below and an unrecognised shape returns null.
const parsed = JSON.parse(raw) as Partial<BrowserIdentityModeRecord>
if (parsed.version !== BROWSER_IDENTITY_MODE_VERSION) {
return null
}
if (parsed.mode !== 'clean' && parsed.mode !== 'native') {
return null
}
return {
version: BROWSER_IDENTITY_MODE_VERSION,
mode: parsed.mode,
migratedNativeProfileIds: Array.isArray(parsed.migratedNativeProfileIds)
? parsed.migratedNativeProfileIds.filter((id): id is string => typeof id === 'string')
: undefined,
migrationNoticePending: parsed.migrationNoticePending === true ? true : undefined
}
}
/** Absent, unreadable, or unrecognised all mean `clean` — the default that keeps imported cookies alive. */
export function readBrowserIdentityModeRecord(userDataPath: string): BrowserIdentityModeRecord {
try {
const parsed = parseRecord(readFileSync(browserIdentityModeRecordPath(userDataPath), 'utf-8'))
return parsed ?? { version: BROWSER_IDENTITY_MODE_VERSION, mode: 'clean' }
} catch {
return { version: BROWSER_IDENTITY_MODE_VERSION, mode: 'clean' }
}
}
export function writeBrowserIdentityModeRecord(
userDataPath: string,
record: BrowserIdentityModeRecord
): void {
writeFileAtomically(
browserIdentityModeRecordPath(userDataPath),
`${JSON.stringify(record, null, 2)}\n`
)
}
@@ -0,0 +1,50 @@
import { app } from 'electron'
import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types'
export type BrowserProcessUserAgentIdentity = Readonly<{
mode: BrowserSessionUserAgentMode
/** What every document, frame and worker in this process presents. */
userAgent: string
}>
let identity: BrowserProcessUserAgentIdentity | null = null
// Why: Electron's default includes its runtime and app tokens, which invalidate Chrome-imported sessions.
export function cleanElectronUserAgent(userAgent: string): string {
return userAgent.replace(/\s+Electron\/\S+/, '').replace(/(\)\s+)\S+\s+(Chrome\/)/, '$1$2')
}
/**
* Fix the whole process's browser identity before anything can read it.
*
* `app.userAgentFallback` is the one default every renderer, frame and worker inherits, so this
* must land before `ready`: a session or WebContents created first keeps the old value, and
* workers would then disagree with documents. `native` deliberately leaves the fallback alone
* rather than assigning the raw string back, so the engine keeps its own untouched default.
*/
export function initializeBrowserProcessUserAgent(
mode: BrowserSessionUserAgentMode
): BrowserProcessUserAgentIdentity {
if (identity) {
throw new Error('Browser process user agent was already initialized')
}
if (app.isReady()) {
throw new Error('Browser process user agent must be initialized before Electron readiness')
}
if (mode === 'clean') {
app.userAgentFallback = cleanElectronUserAgent(app.userAgentFallback)
}
identity = Object.freeze({ mode, userAgent: app.userAgentFallback })
return identity
}
export function getBrowserProcessUserAgentIdentity(): BrowserProcessUserAgentIdentity {
if (!identity) {
throw new Error('Browser process user agent is not initialized')
}
return identity
}
export function resetBrowserProcessUserAgentForTests(): void {
identity = null
}
@@ -0,0 +1,252 @@
import WebSocket from 'ws'
import { cancelUnreadResponseBody } from '../lib/unread-response-body'
export type BrowserSessionUaCdpRequest = Readonly<{
targetType: string
resourceType: string
url: string
userAgent: string | null
clientHints: Readonly<Record<string, string>>
}>
type PendingRequest = {
targetType: string
resourceType?: string
url?: string
headers?: Record<string, string>
}
// CDP payloads are untyped JSON. Narrow once behind a runtime check instead of asserting a
// shape at each read, so a protocol change surfaces as a missing value rather than a lie.
function readRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== 'object' || value === null) {
return undefined
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: guarded by the object/null check above; every member is read back through its own typeof check.
return value as Record<string, unknown>
}
function readString(record: Record<string, unknown> | undefined, key: string): string | undefined {
const value = record?.[key]
return typeof value === 'string' ? value : undefined
}
function readStringRecord(value: unknown): Record<string, string> | undefined {
const record = readRecord(value)
if (!record) {
return undefined
}
const strings: Record<string, string> = {}
for (const [key, entry] of Object.entries(record)) {
if (typeof entry === 'string') {
strings[key] = entry
}
}
return strings
}
type CdpMessage = {
id?: number
method?: string
params?: Record<string, unknown>
result?: unknown
error?: { message?: string }
sessionId?: string
}
export class BrowserSessionUaCdpCollector {
readonly diagnostics: string[] = []
private readonly pendingCommands = new Map<
number,
{ resolve: (value: unknown) => void; reject: (error: Error) => void }
>()
private readonly targetsBySessionId = new Map<string, string>()
private readonly requests = new Map<string, PendingRequest[]>()
private readonly webSockets = new Map<string, PendingRequest>()
private nextCommandId = 1
private constructor(private readonly socket: WebSocket) {
socket.on('message', (data) =>
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse is untyped; CdpMessage is all-optional, so every member is still guarded before use in handleMessage.
this.handleMessage(JSON.parse(data.toString()) as CdpMessage)
)
}
static async connect(port: number): Promise<BrowserSessionUaCdpCollector> {
const version = readRecord(
await fetch(`http://127.0.0.1:${port}/json/version`).then((response) => response.json())
)
const webSocketDebuggerUrl = readString(version, 'webSocketDebuggerUrl')
if (!webSocketDebuggerUrl) {
throw new Error('cdp_version_missing_websocket_debugger_url')
}
const socket = new WebSocket(webSocketDebuggerUrl)
await new Promise<void>((resolve, reject) => {
socket.once('open', resolve)
socket.once('error', reject)
})
return new BrowserSessionUaCdpCollector(socket)
}
async installAutoAttach(): Promise<void> {
await this.send('Target.setDiscoverTargets', { discover: true })
await this.send('Target.setAutoAttach', {
autoAttach: true,
waitForDebuggerOnStart: true,
flatten: true
})
}
snapshot(): BrowserSessionUaCdpRequest[] {
const result: BrowserSessionUaCdpRequest[] = []
const requests = [...this.requests.values()].flat()
for (const request of [...requests, ...this.webSockets.values()]) {
if (!request.url || !request.headers) {
continue
}
const normalizedHeaders = Object.fromEntries(
Object.entries(request.headers).map(([key, value]) => [key.toLowerCase(), String(value)])
)
result.push({
targetType: request.targetType,
resourceType: request.resourceType ?? 'Other',
url: request.url,
userAgent: normalizedHeaders['user-agent'] ?? null,
clientHints: Object.fromEntries(
Object.entries(normalizedHeaders).filter(([key]) => key.startsWith('sec-ch-ua'))
)
})
}
return result
}
async close(): Promise<void> {
if (this.socket.readyState === WebSocket.CLOSED) {
return
}
await new Promise<void>((resolve) => {
this.socket.once('close', () => resolve())
this.socket.close()
})
}
private send(
method: string,
params: Record<string, unknown>,
sessionId?: string
): Promise<unknown> {
const id = this.nextCommandId++
const promise = new Promise<unknown>((resolve, reject) => {
this.pendingCommands.set(id, { resolve, reject })
})
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }))
return promise
}
private handleMessage(message: CdpMessage): void {
if (this.diagnostics.length < 50 && message.method) {
this.diagnostics.push(`event:${message.method}:${message.sessionId ?? 'root'}`)
}
if (message.id !== undefined) {
const pending = this.pendingCommands.get(message.id)
if (!pending) {
return
}
this.pendingCommands.delete(message.id)
if (message.error) {
pending.reject(new Error(message.error.message ?? 'CDP command failed'))
} else {
pending.resolve(message.result)
}
return
}
if (message.method === 'Target.attachedToTarget') {
const params = readRecord(message.params)
const attachedSessionId = readString(params, 'sessionId')
if (attachedSessionId) {
const targetType = readString(readRecord(params?.targetInfo), 'type') ?? 'unknown'
this.diagnostics.push(`attached:${targetType}:${attachedSessionId}`)
this.targetsBySessionId.set(attachedSessionId, targetType)
void this.prepareTarget(attachedSessionId)
}
return
}
const sessionId = message.sessionId ?? 'browser'
const params = message.params ?? {}
if (message.method === 'Runtime.exceptionThrown') {
this.diagnostics.push(`exception:${JSON.stringify(params)}`)
return
}
const requestId = typeof params.requestId === 'string' ? params.requestId : undefined
if (!requestId) {
return
}
const key = `${sessionId}:${requestId}`
if (message.method === 'Network.requestWillBeSent') {
const request = readRecord(params.request)
const hops = this.requests.get(key) ?? []
const pending = hops.find((candidate) => candidate.url === undefined)
const hop = pending ?? this.createPending(sessionId)
if (!pending) {
hops.push(hop)
}
hop.url = readString(request, 'url')
hop.resourceType = typeof params.type === 'string' ? params.type : 'Other'
this.requests.set(key, hops)
} else if (message.method === 'Network.requestWillBeSentExtraInfo') {
const hops = this.requests.get(key) ?? []
const pending = hops.find((candidate) => candidate.headers === undefined)
const hop = pending ?? this.createPending(sessionId)
if (!pending) {
hops.push(hop)
}
hop.headers = readStringRecord(params.headers) ?? {}
this.requests.set(key, hops)
} else if (message.method === 'Network.webSocketCreated') {
const pending = this.webSockets.get(key) ?? this.createPending(sessionId)
pending.url = typeof params.url === 'string' ? params.url : undefined
pending.resourceType = 'WebSocket'
this.webSockets.set(key, pending)
} else if (message.method === 'Network.webSocketWillSendHandshakeRequest') {
const pending = this.webSockets.get(key) ?? this.createPending(sessionId)
pending.headers = readStringRecord(readRecord(params.request)?.headers) ?? {}
this.webSockets.set(key, pending)
}
}
private createPending(sessionId: string): PendingRequest {
return { targetType: this.targetsBySessionId.get(sessionId) ?? 'unknown' }
}
private async prepareTarget(sessionId: string): Promise<void> {
// Paused Electron targets acknowledge queued domain enables only after Runtime resumes them.
const network = this.send('Network.enable', {}, sessionId)
const runtime = this.send('Runtime.enable', {}, sessionId)
await this.send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch((error: unknown) => {
this.diagnostics.push(`resume-error:${sessionId}:${String(error)}`)
})
const enabled = await Promise.allSettled([network, runtime])
this.diagnostics.push(
`enabled:${sessionId}:${enabled.map((result) => result.status).join(',')}`
)
this.diagnostics.push(`resumed:${sessionId}`)
}
}
export async function waitForBrowserCdpEndpoint(port: number): Promise<void> {
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
try {
const targets = await fetch(`http://127.0.0.1:${port}/json/version`)
// The probe only needs the status; an unread body can crash the process (orca#8695).
await cancelUnreadResponseBody(targets)
if (targets.ok) {
return
}
} catch {
// Electron has not opened the debugger endpoint yet.
}
await new Promise((resolve) => setTimeout(resolve, 25))
}
throw new Error('browser_cdp_endpoint_timeout')
}
@@ -0,0 +1,402 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { createServer } from 'node:net'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { chromium } from 'playwright'
import { afterAll, describe, expect, it } from 'vitest'
import { build as buildVite } from 'vite'
import {
BrowserSessionUaCdpCollector,
waitForBrowserCdpEndpoint
} from './browser-session-ua-cdp-collector'
const electronBinary = createRequire(import.meta.url)('electron') as string
const fixtureRoots: string[] = []
const enabled = process.env.ORCA_UA_CLOUDFLARE_LIVE === '1'
let liveTargetUrl = 'https://dash.cloudflare.com/login'
const repetitions = Number(process.env.ORCA_UA_CLOUDFLARE_REPETITIONS ?? 5)
const failureText = 'There was a problem with verification. Please reload and try again.'
// Several independent challenge deployments, not one origin. `native` runs on every site as a
// positive control: if it fails too, that site proves nothing and its rows are void.
const LIVE_SITES: { key: string; url: string }[] = [
{ key: 'cf-dash', url: 'https://dash.cloudflare.com/login' },
{ key: 'cf-nopecha', url: 'https://nopecha.com/demo/cloudflare' },
{ key: 'cf-scrapingcourse', url: 'https://www.scrapingcourse.com/cloudflare-challenge' },
{ key: 'ua-sniff-whatsapp', url: 'https://web.whatsapp.com/' }
]
// Why signal matching instead of one hardcoded failure string: each deployment words its block
// differently, and inventing per-site strings is how a rig silently reports garbage. Capture the
// evidence and compare arms.
const BLOCK_SIGNALS = [
'problem with verification',
'just a moment',
'verify you are human',
'verifying you are human',
'checking your browser',
'enable javascript and cookies',
'unsupported browser',
'update your browser',
'is not supported'
]
function blockSignals(bodyText: string): string[] {
const haystack = bodyText.toLowerCase()
return BLOCK_SIGNALS.filter((signal) => haystack.includes(signal))
}
type LiveArm = 'origin-main' | 'branch' | 'native'
type LiveSite = string
type LiveRun = Readonly<{
arm: LiveArm
site: LiveSite
repetition: number
cleanUserAgent: string
nativeUserAgent: string
firefoxUserAgent: string
navigatorUserAgent: string | null
bodyText: string
requests: ReturnType<BrowserSessionUaCdpCollector['snapshot']>
diagnostics: readonly string[]
}>
afterAll(() => {
for (const root of fixtureRoots) {
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
})
describe.skipIf(!enabled)('Cloudflare live user-agent compatibility', () => {
it('interleaves origin/main and branch with isolated profiles', async () => {
expect(Number.isInteger(repetitions) && repetitions >= 5).toBe(true)
const results: LiveRun[] = []
for (const { key, url } of LIVE_SITES) {
liveTargetUrl = url
for (let repetition = 1; repetition <= repetitions; repetition += 1) {
// Rotate so no arm always runs first: IP reputation and challenge state drift within a run.
const rotations: LiveArm[][] = [
['origin-main', 'branch', 'native'],
['branch', 'native', 'origin-main'],
['native', 'origin-main', 'branch']
]
const arms: LiveArm[] = rotations[(repetition - 1) % rotations.length]!
for (const arm of arms) {
results.push(await runLiveProbe(arm, repetition, key))
}
}
}
const report = results.map(summarizeLiveRun)
console.info(`ORCA_UA_CLOUDFLARE_REPORT=${JSON.stringify(report)}`)
const reportPath = process.env.ORCA_UA_CLOUDFLARE_REPORT_PATH
if (reportPath) {
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`)
}
for (const run of results.filter(({ arm }) => arm === 'branch')) {
const userAgents = distinctUserAgents(run.requests)
expect(userAgents, JSON.stringify(summarizeLiveRun(run))).toEqual([run.cleanUserAgent])
expect(
run.requests.filter(({ userAgent }) => userAgent === run.nativeUserAgent)
).toHaveLength(0)
}
}, 3_600_000)
it.skip('compares the Google auth document and cross-host resources', async () => {
const results = await Promise.all([
runLiveProbe('origin-main', 1, 'google-auth'),
runLiveProbe('branch', 1, 'google-auth')
])
const report = results.map(summarizeGoogleAuthRun)
console.info(`ORCA_UA_GOOGLE_AUTH_REPORT=${JSON.stringify(report)}`)
const reportPath = process.env.ORCA_UA_GOOGLE_AUTH_REPORT_PATH
if (reportPath) {
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`)
}
const branch = results.find(({ arm }) => arm === 'branch')!
const relevant = googleAuthRequests(branch)
expect(relevant.length).toBeGreaterThan(0)
expect(distinctUserAgents(relevant)).toEqual([branch.firefoxUserAgent])
expect(relevant.filter(({ userAgent }) => userAgent === branch.cleanUserAgent)).toHaveLength(0)
expect(branch.navigatorUserAgent).toBe(branch.firefoxUserAgent)
}, 90_000)
})
async function runLiveProbe(arm: LiveArm, repetition: number, site: LiveSite): Promise<LiveRun> {
const root = mkdtempSync(join(tmpdir(), `orca-cloudflare-${arm}-${repetition}-`))
fixtureRoots.push(root)
const processIdentityModulePath = join(root, 'browser-process-user-agent.cjs')
const exceptionModulePath = join(root, 'browser-session-ua.cjs')
await Promise.all([
buildModule('src/main/browser/browser-process-user-agent.ts', processIdentityModulePath),
buildModule('src/main/browser/browser-session-ua.ts', exceptionModulePath)
])
const barrierPath = join(root, 'continue')
const resultPath = join(root, 'result.json')
const fixturePath = join(root, 'main.cjs')
const cdpPort = await reservePort()
writeFileSync(
fixturePath,
fixtureMain({
arm,
barrierPath,
exceptionModulePath,
processIdentityModulePath,
resultPath,
site,
targetUrl: liveTargetUrl
})
)
let child: ChildProcess | null = null
let collector: BrowserSessionUaCdpCollector | null = null
let browser: Awaited<ReturnType<typeof chromium.connectOverCDP>> | null = null
try {
child = launchFixture(fixturePath, root, cdpPort)
await waitForBrowserCdpEndpoint(cdpPort)
browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`)
collector = await BrowserSessionUaCdpCollector.connect(cdpPort)
await collector.installAutoAttach()
writeFileSync(barrierPath, '')
const processResult = await waitForProcess(child)
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
expect(processResult.code, `${fixtureResult}\n${processResult.stderr}`).toBe(0)
await new Promise((resolve) => setTimeout(resolve, 100))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse is untyped; the fixture writes exactly this shape with JSON.stringify, and the assertions below fail loudly on a missing member.
const parsed = JSON.parse(fixtureResult) as Omit<
LiveRun,
'arm' | 'site' | 'repetition' | 'requests' | 'diagnostics'
>
return {
arm,
site,
repetition,
...parsed,
requests: collector.snapshot().filter(({ url, userAgent }) => {
if (!userAgent) {
return false
}
try {
return new URL(url).protocol.startsWith('http')
} catch {
return false
}
}),
diagnostics: [...collector.diagnostics]
}
} finally {
await collector?.close().catch(() => {})
await browser?.close().catch(() => {})
if (child && child.exitCode === null) {
child.kill('SIGTERM')
}
}
}
async function buildModule(entry: string, outputPath: string): Promise<void> {
await buildVite({
configFile: false,
logLevel: 'silent',
build: {
emptyOutDir: false,
lib: {
entry: join(process.cwd(), entry),
formats: ['cjs'],
fileName: () => basename(outputPath)
},
outDir: join(outputPath, '..'),
target: 'node20',
rollupOptions: { external: ['electron', /^node:/] }
}
})
}
function fixtureMain(options: {
arm: LiveArm
barrierPath: string
exceptionModulePath: string
processIdentityModulePath: string
resultPath: string
site: LiveSite
targetUrl: string
}): string {
return String.raw`
const { app, BrowserWindow, session } = require('electron')
const { existsSync, writeFileSync } = require('node:fs')
const processIdentity = require(${JSON.stringify(options.processIdentityModulePath)})
const { installBrowserSessionUserAgentExceptions } = require(${JSON.stringify(options.exceptionModulePath)})
const arm = ${JSON.stringify(options.arm)}
const site = ${JSON.stringify(options.site)}
app.setName('OrcaCloudflareLiveProbe')
const clean = userAgent => userAgent.replace(/\s+Electron\/\S+/, '').replace(/(\)\s+)\S+\s+(Chrome\/)/, '$1$2')
let identity
if (arm === 'branch') identity = processIdentity.initializeBrowserProcessUserAgent()
const waitForBarrier = async () => {
const deadline = Date.now() + 15000
while (!existsSync(${JSON.stringify(options.barrierPath)})) {
if (Date.now() >= deadline) throw new Error('startup barrier timeout')
await new Promise(resolve => setTimeout(resolve, 20))
}
}
async function run() {
await app.whenReady()
await waitForBarrier()
const sess = session.fromPartition('persist:cloudflare-live-probe')
const nativeUserAgent = identity?.nativeUserAgent ?? sess.getUserAgent()
const cleanUserAgent = identity?.cleanUserAgent ?? clean(nativeUserAgent)
const firefoxUserAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:140.0) Gecko/20100101 Firefox/140.0'
if (arm === 'origin-main') sess.setUserAgent(cleanUserAgent)
if (arm === 'branch') {
installBrowserSessionUserAgentExceptions(sess, request => {
if (request.resourceType !== 'mainFrame' && (request.currentUserAgent === firefoxUserAgent || request.effectiveUserAgent === firefoxUserAgent)) {
return { userAgent: firefoxUserAgent }
}
if (request.resourceType === 'mainFrame' && request.currentUserAgent === firefoxUserAgent) {
return { userAgent: cleanUserAgent }
}
return undefined
})
} else if (arm === 'origin-main') {
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
const headers = details.requestHeaders
const key = Object.keys(headers).find(candidate => candidate.toLowerCase() === 'user-agent') || 'User-Agent'
const auth = (() => { try { const url = new URL(details.url); return url.protocol === 'https:' && (url.hostname === 'accounts.google.com' || url.hostname === 'accounts.youtube.com') } catch { return false } })()
if (auth) headers[key] = firefoxUserAgent
if (auth || headers[key] === firefoxUserAgent) {
for (const candidate of Object.keys(headers)) if (candidate.toLowerCase().startsWith('sec-ch-ua')) delete headers[candidate]
}
callback({ requestHeaders: headers })
})
}
const window = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:cloudflare-live-probe', sandbox: true } })
if (site === 'google-auth') window.webContents.setUserAgent(firefoxUserAgent)
let loadError = null
const targetUrl = ${JSON.stringify(options.targetUrl)}
await window.loadURL(targetUrl).catch(error => { loadError = String(error?.message || error) })
await new Promise(resolve => setTimeout(resolve, 12000))
const bodyText = await window.webContents.executeJavaScript('document.body?.innerText || ""').catch(() => '')
const navigatorUserAgent = await window.webContents.executeJavaScript('navigator.userAgent').catch(() => null)
writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ nativeUserAgent, cleanUserAgent, firefoxUserAgent, navigatorUserAgent, bodyText, loadError }))
window.destroy()
app.exit(0)
}
run().catch(error => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: String(error?.stack || error) })); app.exit(1) })
`
}
function summarizeLiveRun(run: LiveRun) {
const byResourceType: Record<string, Record<string, number>> = {}
for (const request of run.requests) {
const userAgent = request.userAgent ?? '<missing>'
byResourceType[request.resourceType] ??= {}
byResourceType[request.resourceType]![userAgent] =
(byResourceType[request.resourceType]![userAgent] ?? 0) + 1
}
return {
arm: run.arm,
site: run.site,
repetition: run.repetition,
requestCount: run.requests.length,
distinctUserAgents: distinctUserAgents(run.requests),
nativeLeakCount: run.requests.filter(({ userAgent }) => userAgent === run.nativeUserAgent)
.length,
navigatorUserAgent: run.navigatorUserAgent,
verificationFailure: run.bodyText.includes(failureText),
blockSignals: blockSignals(run.bodyText),
bodySnippet: run.bodyText.replace(/\s+/g, ' ').slice(0, 220),
byResourceType,
attachedTargetTypes: run.diagnostics
.filter((message) => message.startsWith('attached:'))
.map((message) => message.split(':')[1])
}
}
function summarizeGoogleAuthRun(run: LiveRun) {
const relevant = googleAuthRequests(run)
const byHost: Record<string, number> = {}
for (const request of relevant) {
const host = new URL(request.url).hostname
byHost[host] = (byHost[host] ?? 0) + 1
}
return {
arm: run.arm,
requestCount: relevant.length,
distinctUserAgents: distinctUserAgents(relevant),
cleanChromeCount: relevant.filter(({ userAgent }) => userAgent === run.cleanUserAgent).length,
firefoxCount: relevant.filter(({ userAgent }) => userAgent === run.firefoxUserAgent).length,
navigatorUserAgent: run.navigatorUserAgent,
byHost
}
}
function googleAuthRequests(run: LiveRun) {
const hosts = new Set([
'accounts.google.com',
'accounts.youtube.com',
'www.gstatic.com',
'fonts.gstatic.com',
'play.google.com'
])
return run.requests.filter(({ url }) => {
try {
return hosts.has(new URL(url).hostname)
} catch {
return false
}
})
}
function distinctUserAgents(records: readonly { userAgent: string | null }[]): (string | null)[] {
return [...new Set(records.map(({ userAgent }) => userAgent))].sort()
}
function launchFixture(fixturePath: string, root: string, cdpPort: number): ChildProcess {
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
return spawn(
process.platform === 'linux' ? 'xvfb-run' : electronBinary,
process.platform === 'linux'
? [
'--auto-servernum',
electronBinary,
fixturePath,
`--user-data-dir=${join(root, 'profile')}`,
`--remote-debugging-port=${cdpPort}`,
'--no-sandbox'
]
: [
fixturePath,
`--user-data-dir=${join(root, 'profile')}`,
`--remote-debugging-port=${cdpPort}`
],
{ env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' }, stdio: ['ignore', 'pipe', 'pipe'] }
)
}
async function reservePort(): Promise<number> {
const server = createServer()
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('cdp port unavailable')
}
await new Promise<void>((resolve) => server.close(() => resolve()))
return address.port
}
function waitForProcess(child: ChildProcess): Promise<{ code: number | null; stderr: string }> {
let stderr = ''
child.stderr?.setEncoding('utf8')
child.stderr?.on('data', (chunk: string) => {
stderr += chunk
})
return new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code) => resolve({ code, stderr }))
})
}
@@ -1,23 +1,22 @@
import { spawnSync } from 'node:child_process'
import { spawn, type ChildProcess } from 'node:child_process'
import { createServer } from 'node:net'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { basename, join } from 'node:path'
import { chromium } from 'playwright'
import { afterAll, describe, expect, it } from 'vitest'
import { build as buildVite } from 'vite'
import {
LOCAL_HTTPS_TEST_CERTIFICATE,
LOCAL_HTTPS_TEST_PRIVATE_KEY
} from './browser-local-https-test-certificate'
// Why this runs a real Electron: sites that hold a transplanted session re-check the browser
// identity that minted it, and an `Orca/x.y.z … Electron/x.y.z` UA is not one any browser sends —
// LinkedIn and x.com revoked live sessions over it (STA-7147). The header layer is the only place
// that identity can be proven, and the vm-based unit tests cannot see Chromium's header emission
// at all. Every clean-mode partition must therefore strip the Electron and app tokens on the
// wire for ordinary hosts and present the Firefox identity on Google's sign-in hosts only. This
// focused revocation fix does not claim full Chrome fingerprint parity; native mode remains the
// fallback for sites that reject the cleaned identity, including some Turnstile deployments.
BrowserSessionUaCdpCollector,
type BrowserSessionUaCdpRequest,
waitForBrowserCdpEndpoint
} from './browser-session-ua-cdp-collector'
import {
startBrowserSessionUaWireProbeServer,
type WireProbeJavaScriptIdentity,
type WireProbeReceipt
} from './browser-session-ua-wire-probe-server'
const electronBinary = createRequire(import.meta.url)('electron') as string
const fixtureRoots: string[] = []
@@ -28,241 +27,426 @@ afterAll(() => {
}
})
// Retry once when Electron startup times out before `ready`; keep later failures fatal.
const FIXTURE_LAUNCH_ATTEMPTS = 2
type ProbeArm = 'clean' | 'late-session-setter' | 'mobile' | 'mixed-mobile' | 'native'
type CapturedRequest = {
url: string
userAgent: string | null
clientHints: Record<string, string>
}
type UserAgentBrand = {
brand: string
version: string
}
type NavigatorUserAgentData = {
brands: UserAgentBrand[]
highEntropy: { fullVersionList?: UserAgentBrand[] }
}
type FixtureResult = {
type ProbeResult = Readonly<{
arm: ProbeArm
rawUserAgent: string
cleanUserAgent: string
mobileUserAgent: string
sessionUserAgent: string
navigatorUserAgent: string
navigatorUserAgentData: NavigatorUserAgentData | null
requests: CapturedRequest[]
}
fallbackAfterReadyNameChange: string
startupMarks: readonly string[]
receipts: readonly WireProbeReceipt[]
identities: readonly WireProbeJavaScriptIdentity[]
cdpRequests: readonly BrowserSessionUaCdpRequest[]
cdpDiagnostics: readonly string[]
}>
function neverReachedElectronReady(fixtureResult: string): boolean {
try {
return (JSON.parse(fixtureResult) as { step?: string }).step === 'timed out after starting'
} catch {
return false
}
}
const requiredPaths = [
'/',
'/document-fetch',
'/document-xhr',
'/document-image',
'/frame',
'/blob-fetch',
'/blob-xhr',
'/blob-image',
'/shared-worker-fetch-a',
'/shared-worker-fetch-b',
'/service-worker-fetch',
'/popup',
'/popup-fetch',
'/no-header-fill',
'/default-session-fill',
'/isolated-session-fill',
'/default-window',
'/isolated-window',
'/plain-ws',
'/secure-ws'
] as const
function buildFixtureMain(modulePath: string, resultPath: string): string {
return `
const { app, BrowserWindow, session } = require('electron')
const { createServer } = require('node:https')
const { writeFileSync } = require('node:fs')
const { cleanElectronUserAgent, setupGoogleAuthUserAgentOverride } = require(${JSON.stringify(modulePath)})
const resultPath = ${JSON.stringify(resultPath)}
// Why: production's UA carries an app token ("Orca/1.4.198") between the engine comment and
// Chrome/, and an unnamed fixture emits none — which would leave half of cleanElectronUserAgent
// unexercised while the test still passed.
app.setName('OrcaWireIdentityFixture')
let currentStep = 'starting'
const mark = (step) => {
currentStep = step
writeFileSync(resultPath, JSON.stringify({ step }))
}
describe('browser session wire identity under Electron', () => {
it('uses one process-clean identity for documents, blob frames, workers, HTTP, and WebSockets', async () => {
const result = await runProbe('clean')
assertCoverage(result)
expect(result.rawUserAgent).toMatch(/ Electron\/\d/)
expect(result.rawUserAgent).toMatch(/\(KHTML, like Gecko\) \S+ Chrome\//)
expect(result.cleanUserAgent).not.toContain('Electron/')
expect(result.startupMarks).toEqual(['fallback', 'ready', 'session', 'webContents'])
expect(result.fallbackAfterReadyNameChange).toBe(result.cleanUserAgent)
expect(distinctUserAgents(result.receipts)).toEqual([result.cleanUserAgent])
expect(distinctUserAgents(result.cdpRequests)).toEqual([result.cleanUserAgent])
expect(distinctJavaScriptUserAgents(result.identities)).toEqual([result.cleanUserAgent])
}, 40_000)
async function run() {
const timeout = setTimeout(() => {
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
app.exit(1)
}, 15000)
await app.whenReady()
mark('ready')
const partition = 'persist:wire-identity-test'
const sess = session.fromPartition(partition)
// Mirrors installBrowserSessionPartitionPolicies for a non-native profile.
const rawUserAgent = sess.getUserAgent()
const cleanUa = cleanElectronUserAgent(rawUserAgent)
sess.setUserAgent(cleanUa)
setupGoogleAuthUserAgentOverride(sess)
mark('clean identity installed')
it('goes red without the pre-ready process fallback even when the Session setter is restored', async () => {
const result = await runProbe('late-session-setter')
assertCoverage(result)
expect(distinctUserAgents(result.receipts)).toContain(result.rawUserAgent)
expect(distinctUserAgents(result.receipts)).toContain(result.cleanUserAgent)
expect(identityViolations(result)).not.toEqual([])
expect(result.receipts.some(({ userAgent }) => /Firefox\//.test(userAgent ?? ''))).toBe(false)
}, 40_000)
sess.setCertificateVerifyProc((_request, callback) => callback(0))
const requests = []
sess.webRequest.onSendHeaders({ urls: ['https://*/*'] }, (details) => {
const headers = details.requestHeaders || {}
const uaKey = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent')
const clientHints = {}
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase().startsWith('sec-ch-ua')) {
clientHints[key.toLowerCase()] = value
}
}
requests.push({
url: details.url,
userAgent: uaKey ? headers[uaKey] : null,
clientHints
})
})
it('keeps mobile traffic CriOS while an unmapped numeric popup stays desktop-clean', async () => {
const result = await runProbe('mobile')
assertCoverage(result)
const mobilePaths = [
'/',
'/blob-fetch',
'/blob-xhr',
'/blob-image',
'/shared-worker-fetch-a',
'/shared-worker-fetch-b',
'/service-worker-fetch',
'/plain-ws',
'/secure-ws'
]
expect(distinctUserAgents(receiptsForPaths(result.receipts, mobilePaths))).toEqual([
result.mobileUserAgent
])
expect(userAgentForPath(result.receipts, '/popup')).toBe(result.cleanUserAgent)
expect(identityForContext(result.identities, 'popup').userAgent).toBe(result.cleanUserAgent)
expect(identityForContext(result.identities, 'document').userAgent).toBe(result.mobileUserAgent)
}, 40_000)
const server = createServer(
{
cert: ${JSON.stringify(LOCAL_HTTPS_TEST_CERTIFICATE)},
key: ${JSON.stringify(LOCAL_HTTPS_TEST_PRIVATE_KEY)}
},
(_request, response) => {
response.setHeader('Accept-CH', 'Sec-CH-UA-Full-Version-List')
response.end('<!doctype html><title>identity</title>')
}
)
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const origin = 'https://127.0.0.1:' + server.address().port
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
mark('window created')
let navigatorUserAgent
let navigatorUserAgentData
try {
await window.loadURL(origin + '/')
navigatorUserAgent = await window.webContents.executeJavaScript('navigator.userAgent')
navigatorUserAgentData = await window.webContents.executeJavaScript(
"(async () => { const data = navigator.userAgentData; return data ? { brands: data.brands, highEntropy: await data.getHighEntropyValues(['fullVersionList']) } : null })()"
it('makes the shared-session mobile worker contract explicit with a desktop peer', async () => {
const result = await runProbe('mixed-mobile')
assertCoverage(result)
expect(identityForContext(result.identities, 'document').userAgent).toBe(result.mobileUserAgent)
expect(identityForContext(result.identities, 'desktop-peer').userAgent).toBe(
result.cleanUserAgent
)
await window.webContents.executeJavaScript(
'fetch("/hints").then((response) => response.text())'
expect(userAgentForPath(result.receipts, '/desktop-peer')).toBe(result.cleanUserAgent)
expect(
distinctUserAgents(
receiptsForPaths(result.receipts, ['/shared-worker-fetch-a', '/shared-worker-fetch-b'])
)
).toEqual([result.mobileUserAgent])
expect(
result.identities
.filter(({ context }) => context === 'shared-worker')
.map(({ userAgent }) => userAgent)
).toEqual([result.cleanUserAgent, result.cleanUserAgent])
}, 40_000)
it('makes the B1 native split deterministic', async () => {
const result = await runProbe('native')
assertCoverage(result)
expect(identityForContext(result.identities, 'document').userAgent).toBe(result.rawUserAgent)
expect(identityForContext(result.identities, 'blob').userAgent).toBe(result.rawUserAgent)
expect(identityForContext(result.identities, 'shared-worker').userAgent).toBe(
result.cleanUserAgent
)
} finally {
await new Promise((resolve) => server.close(resolve))
}
// Dispatch a real auth-host request without allowing it to reach the Internet.
await sess.setProxy({ proxyRules: 'http://127.0.0.1:9', proxyBypassRules: '<-loopback>' })
await window.loadURL('https://accounts.google.com/v3/signin/identifier').catch(() => {})
mark('navigations attempted')
clearTimeout(timeout)
writeFileSync(resultPath, JSON.stringify({
rawUserAgent,
sessionUserAgent: sess.getUserAgent(),
navigatorUserAgent,
navigatorUserAgentData,
requests
}))
window.destroy()
app.exit(0)
}
run().catch((error) => {
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
app.exit(1)
expect(identityForContext(result.identities, 'service-worker').userAgent).toBe(
result.cleanUserAgent
)
expect(userAgentForPath(result.receipts, '/')).toBe(result.rawUserAgent)
expect(userAgentForPath(result.receipts, '/blob-fetch')).toBe(result.rawUserAgent)
expect(userAgentForPath(result.receipts, '/shared-worker-fetch-a')).toBe(result.cleanUserAgent)
expect(userAgentForPath(result.receipts, '/service-worker-fetch')).toBe(result.cleanUserAgent)
expect(userAgentForPath(result.receipts, '/no-header-fill')).toBe(result.cleanUserAgent)
}, 40_000)
})
`
async function runProbe(arm: ProbeArm): Promise<ProbeResult> {
const root = mkdtempSync(join(tmpdir(), `orca-wire-identity-${arm}-`))
fixtureRoots.push(root)
const processIdentityModulePath = join(root, 'browser-process-user-agent.cjs')
const exceptionModulePath = join(root, 'browser-session-ua.cjs')
await Promise.all([
buildModule('src/main/browser/browser-process-user-agent.ts', processIdentityModulePath),
buildModule('src/main/browser/browser-session-ua.ts', exceptionModulePath)
])
const server = await startBrowserSessionUaWireProbeServer()
const resultPath = join(root, 'result.json')
const barrierPath = join(root, 'continue')
const fixturePath = join(root, 'main.cjs')
const cdpPort = await reservePort()
writeFileSync(
fixturePath,
fixtureMain({
arm,
barrierPath,
exceptionModulePath,
httpOrigin: server.httpOrigin,
processIdentityModulePath,
resultPath
})
)
let process: ChildProcess | null = null
let collector: BrowserSessionUaCdpCollector | null = null
let browser: Awaited<ReturnType<typeof chromium.connectOverCDP>> | null = null
try {
process = launchFixture(fixturePath, root, cdpPort)
await waitForBrowserCdpEndpoint(cdpPort)
browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`)
collector = await BrowserSessionUaCdpCollector.connect(cdpPort)
await collector.installAutoAttach()
writeFileSync(barrierPath, '')
const processResult = await waitForProcess(process)
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
expect(
processResult.code,
`${fixtureResult}\n${processResult.stderr}\n${JSON.stringify({ diagnostics: collector.diagnostics, receipts: server.receipts, identities: server.identities })}`
).toBe(0)
await new Promise((resolve) => setTimeout(resolve, 100))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse is untyped; the fixture writes exactly this shape with JSON.stringify, and the assertions below fail loudly on a missing member.
const parsed = JSON.parse(fixtureResult) as Omit<
ProbeResult,
'receipts' | 'identities' | 'cdpRequests' | 'cdpDiagnostics'
>
return {
...parsed,
receipts: [...server.receipts],
identities: [...server.identities],
cdpDiagnostics: [...collector.diagnostics],
cdpRequests: collector
.snapshot()
.filter(
({ url }) => url.startsWith(server.httpOrigin) || url.startsWith(server.httpsOrigin)
)
}
} finally {
await collector?.close().catch(() => {})
await browser?.close().catch(() => {})
await server.close()
if (process && process.exitCode === null) {
process.kill('SIGTERM')
}
}
}
async function runFixture(): Promise<FixtureResult> {
const root = mkdtempSync(join(tmpdir(), 'orca-wire-identity-'))
fixtureRoots.push(root)
const modulePath = join(root, 'browser-session-ua.cjs')
const resultPath = join(root, 'result.json')
const fixturePath = join(root, 'main.cjs')
async function buildModule(entry: string, outputPath: string): Promise<void> {
await buildVite({
configFile: false,
logLevel: 'silent',
build: {
emptyOutDir: false,
lib: {
entry: join(process.cwd(), 'src/main/browser/browser-session-ua.ts'),
entry: join(process.cwd(), entry),
formats: ['cjs'],
fileName: () => 'browser-session-ua.cjs'
fileName: () => basename(outputPath)
},
outDir: root,
outDir: join(outputPath, '..'),
target: 'node20',
rollupOptions: { external: ['electron', /^node:/] }
}
})
writeFileSync(fixturePath, buildFixtureMain(modulePath, resultPath))
}
function launchFixture(fixturePath: string, root: string, cdpPort: number): ChildProcess {
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
for (let attempt = 1; ; attempt += 1) {
rmSync(resultPath, { force: true })
// Why a fresh profile per attempt: a launch that never reached `ready` may have left the
// Chromium profile mid-initialization, and reusing it would bias the retry.
const electronArgs = [fixturePath, `--user-data-dir=${join(root, `profile-${attempt}`)}`]
const run = spawnSync(
executable,
process.platform === 'linux'
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
: electronArgs,
{ encoding: 'utf8', env, timeout: 60_000 }
)
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
if (attempt < FIXTURE_LAUNCH_ATTEMPTS && neverReachedElectronReady(fixtureResult)) {
continue
return spawn(
process.platform === 'linux' ? 'xvfb-run' : electronBinary,
process.platform === 'linux'
? [
'--auto-servernum',
electronBinary,
fixturePath,
`--user-data-dir=${join(root, 'profile')}`,
`--remote-debugging-port=${cdpPort}`,
'--no-sandbox'
]
: [
fixturePath,
`--user-data-dir=${join(root, 'profile')}`,
`--remote-debugging-port=${cdpPort}`
],
{
env: { ...env, ORCA_BACKGROUND_LAUNCH: '1' },
stdio: ['ignore', 'pipe', 'pipe']
}
expect(run.error).toBeUndefined()
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
return JSON.parse(fixtureResult) as FixtureResult
)
}
function fixtureMain(options: {
arm: ProbeArm
barrierPath: string
exceptionModulePath: string
httpOrigin: string
processIdentityModulePath: string
resultPath: string
}): string {
return String.raw`
const { app, BrowserWindow, net, session } = require('electron')
const { existsSync, writeFileSync } = require('node:fs')
const processIdentity = require(${JSON.stringify(options.processIdentityModulePath)})
const { cleanElectronUserAgent, installBrowserSessionUserAgentExceptions } = require(${JSON.stringify(options.exceptionModulePath)})
const arm = ${JSON.stringify(options.arm)}
const startupMarks = []
app.setName('OrcaWireIdentityFixture')
let identity
if (arm !== 'late-session-setter') {
identity = processIdentity.initializeBrowserProcessUserAgent()
startupMarks.push('fallback')
}
const waitForBarrier = async () => {
const deadline = Date.now() + 15000
while (!existsSync(${JSON.stringify(options.barrierPath)})) {
if (Date.now() >= deadline) throw new Error('startup barrier timeout')
await new Promise(resolve => setTimeout(resolve, 20))
}
}
function parseClientHintBrands(value: string): UserAgentBrand[] {
return [...value.matchAll(/"([^"]+)";v="([^"]+)"/g)].map((match) => ({
brand: match[1],
version: match[2]
const requestWithoutUserAgent = (sess, url) => new Promise((resolve, reject) => {
const request = net.request({ session: sess, url })
request.on('response', response => { response.on('data', () => {}); response.on('end', resolve) })
request.on('error', reject)
request.end()
})
async function run() {
const timeout = setTimeout(() => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: 'timeout', startupMarks })); app.exit(2) }, 10000)
await app.whenReady()
startupMarks.push('ready')
app.setName('OrcaWireIdentityFixtureAfterReady')
const fallbackAfterReadyNameChange = app.userAgentFallback
await waitForBarrier()
const sess = session.fromPartition('persist:wire-identity-test')
startupMarks.push('session')
const rawUserAgent = identity?.nativeUserAgent ?? sess.getUserAgent()
const cleanUserAgent = identity?.cleanUserAgent ?? cleanElectronUserAgent(rawUserAgent)
if (arm === 'late-session-setter') sess.setUserAgent(cleanUserAgent)
sess.setCertificateVerifyProc((_request, callback) => callback(0))
const chromeVersion = cleanUserAgent.match(/Chrome\/([\d.]+)/)?.[1] || process.versions.chrome
const major = chromeVersion.split('.')[0]
const mobileUserAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/' + chromeVersion + ' Mobile/15E148 Safari/604.1'
let mainWebContentsId
if (arm === 'clean' || arm === 'mobile' || arm === 'mixed-mobile') {
installBrowserSessionUserAgentExceptions(sess, request => {
if (arm !== 'mobile' && arm !== 'mixed-mobile') return undefined
if (request.webContentsId !== undefined && request.webContentsId !== mainWebContentsId) return undefined
return { userAgent: mobileUserAgent, userAgentMetadata: { brands: [{ brand: 'Chromium', version: major }, { brand: 'Google Chrome', version: major }, { brand: 'Not/A)Brand', version: '24' }], fullVersionList: [{ brand: 'Chromium', version: chromeVersion }, { brand: 'Google Chrome', version: chromeVersion }, { brand: 'Not/A)Brand', version: '24.0.0.0' }], fullVersion: chromeVersion, platform: 'iOS', platformVersion: '18.5.0', architecture: '', model: 'iPhone', mobile: true } }
})
}
const windows = []
const window = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:wire-identity-test', sandbox: true } })
windows.push(window)
startupMarks.push('webContents')
mainWebContentsId = window.webContents.id
const pageIdentity = arm === 'native' ? rawUserAgent : arm === 'mobile' || arm === 'mixed-mobile' ? mobileUserAgent : cleanUserAgent
if (arm === 'native' || arm === 'mobile' || arm === 'mixed-mobile') window.webContents.setUserAgent(pageIdentity)
window.webContents.setWindowOpenHandler(() => ({
action: 'allow',
createWindow: options => {
const popup = new BrowserWindow({ ...options, show: false })
popup.webContents.setUserAgent(arm === 'native' ? rawUserAgent : cleanUserAgent)
windows.push(popup)
return popup.webContents
}
}))
await window.loadURL(${JSON.stringify(options.httpOrigin)} + '/')
const [navigatorUserAgent] = await Promise.all([
window.webContents.executeJavaScript('navigator.userAgent'),
window.webContents.executeJavaScript('window.probePromise'),
requestWithoutUserAgent(sess, ${JSON.stringify(options.httpOrigin)} + '/no-header-fill')
])
if (arm === 'mixed-mobile') {
const peer = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:wire-identity-test', sandbox: true } })
windows.push(peer)
await peer.loadURL(${JSON.stringify(options.httpOrigin)} + '/desktop-peer')
await peer.webContents.executeJavaScript('window.peerProbePromise')
}
const defaultWindow = new BrowserWindow({ show: false, webPreferences: { sandbox: true } })
windows.push(defaultWindow)
await defaultWindow.loadURL(${JSON.stringify(options.httpOrigin)} + '/default-window')
const appIsolatedWindow = new BrowserWindow({ show: false, webPreferences: { partition: 'persist:app-surface', sandbox: true } })
windows.push(appIsolatedWindow)
await appIsolatedWindow.loadURL(${JSON.stringify(options.httpOrigin)} + '/isolated-window')
await Promise.all([
requestWithoutUserAgent(session.defaultSession, ${JSON.stringify(options.httpOrigin)} + '/default-session-fill'),
requestWithoutUserAgent(session.fromPartition('persist:app-surface'), ${JSON.stringify(options.httpOrigin)} + '/isolated-session-fill')
])
await new Promise(resolve => setTimeout(resolve, 250))
clearTimeout(timeout)
writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ arm, rawUserAgent, cleanUserAgent, mobileUserAgent, sessionUserAgent: sess.getUserAgent(), navigatorUserAgent, fallbackAfterReadyNameChange, startupMarks }))
for (const candidate of windows) if (!candidate.isDestroyed()) candidate.destroy()
app.exit(0)
}
run().catch(error => { writeFileSync(${JSON.stringify(options.resultPath)}, JSON.stringify({ error: String(error?.stack || error), startupMarks })); app.exit(1) })
`
}
describe('browser session wire identity under Electron', () => {
it('strips the Electron and app tokens for ordinary hosts and sends Firefox to Google auth hosts', async () => {
const result = await runFixture()
function assertCoverage(result: ProbeResult): void {
const paths = new Set(result.receipts.map(({ path }) => path))
for (const path of requiredPaths) {
expect(
paths,
`${result.arm} omitted ${path}: ${JSON.stringify(result.cdpDiagnostics)}`
).toContain(path)
}
const cdpUrls = result.cdpRequests.map(({ url }) => new URL(url).pathname)
expect(cdpUrls).toContain('/blob-fetch')
expect(result.cdpDiagnostics.some((message) => message.includes('attached:shared_worker:'))).toBe(
true
)
const expectedContexts = ['blob', 'document', 'frame', 'popup', 'service-worker', 'shared-worker']
if (result.arm === 'mixed-mobile') {
expectedContexts.push('desktop-peer', 'shared-worker')
}
expect(result.identities.map(({ context }) => context).sort()).toEqual(expectedContexts.sort())
}
// Presence precondition: the raw identity really does carry the tokens, so the absence
// assertions below cannot pass vacuously on an empty or already-clean UA.
expect(result.rawUserAgent).toMatch(/ Electron\/\d/)
expect(result.rawUserAgent).toMatch(/\(KHTML, like Gecko\) \S+ Chrome\//)
function identityViolations(result: ProbeResult): string[] {
return result.receipts
.filter(({ userAgent }) => userAgent !== result.cleanUserAgent)
.map(({ protocol, path }) => `${protocol}:${path}`)
}
// The whole point of STA-7147: nothing between the engine comment and Chrome/, and no
// Electron token anywhere — the shape a real Chrome sends.
expect(result.sessionUserAgent).not.toContain('Electron/')
expect(result.sessionUserAgent).toMatch(/\(KHTML, like Gecko\) Chrome\/[\d.]+ Safari\/537\.36$/)
function distinctUserAgents(records: readonly { userAgent: string | null }[]): (string | null)[] {
return [...new Set(records.map(({ userAgent }) => userAgent))].sort()
}
const ordinary = result.requests.find((request) => request.url.endsWith('/hints'))
expect(ordinary, JSON.stringify(result.requests)).toBeDefined()
expect(ordinary?.userAgent).toBe(result.sessionUserAgent)
expect(result.navigatorUserAgent).toBe(result.sessionUserAgent)
expect(result.navigatorUserAgentData).not.toBeNull()
function distinctJavaScriptUserAgents(records: readonly WireProbeJavaScriptIdentity[]): string[] {
return [...new Set(records.map(({ userAgent }) => userAgent))].sort()
}
// Chromium owns both client-hint surfaces. Rewriting only the request headers would make this
// comparison fail while leaving the legacy UA assertions above green.
const wireBrands = parseClientHintBrands(ordinary?.clientHints['sec-ch-ua'] ?? '')
expect(wireBrands).toEqual(result.navigatorUserAgentData?.brands)
expect(wireBrands.some(({ brand }) => /Electron|Orca/i.test(brand))).toBe(false)
const chromeMajor = result.sessionUserAgent.match(/Chrome\/(\d+)/)?.[1]
expect(wireBrands.find(({ brand }) => brand === 'Chromium')?.version).toBe(chromeMajor)
function receiptsForPaths(
receipts: readonly WireProbeReceipt[],
paths: readonly string[]
): WireProbeReceipt[] {
const selected = new Set(paths)
return receipts.filter(({ path }) => selected.has(path))
}
const fullVersionList = ordinary?.clientHints['sec-ch-ua-full-version-list']
if (fullVersionList) {
expect(parseClientHintBrands(fullVersionList)).toEqual(
result.navigatorUserAgentData?.highEntropy.fullVersionList
)
}
function userAgentForPath(receipts: readonly WireProbeReceipt[], path: string): string | null {
const values = distinctUserAgents(receipts.filter((receipt) => receipt.path === path))
expect(values, path).toHaveLength(1)
return values[0] ?? null
}
const auth = result.requests.find((request) =>
request.url.startsWith('https://accounts.google.com/')
)
expect(auth, JSON.stringify(result.requests)).toBeDefined()
expect(auth?.userAgent).toMatch(/Firefox\/\d/)
expect(auth?.userAgent).not.toContain('Chrome')
expect(auth?.clientHints).toEqual({})
function identityForContext(
identities: readonly WireProbeJavaScriptIdentity[],
context: string
): WireProbeJavaScriptIdentity {
const matches = identities.filter((identity) => identity.context === context)
expect(matches, context).toHaveLength(1)
return matches[0]!
}
async function reservePort(): Promise<number> {
const server = createServer()
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
})
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('cdp port unavailable')
}
await new Promise<void>((resolve) => server.close(() => resolve()))
return address.port
}
function waitForProcess(process: ChildProcess): Promise<{ code: number | null; stderr: string }> {
let stderr = ''
process.stderr?.setEncoding('utf8')
process.stderr?.on('data', (chunk: string) => {
stderr += chunk
})
return new Promise((resolve, reject) => {
process.once('error', reject)
process.once('exit', (code) => resolve({ code, stderr }))
})
}
@@ -0,0 +1,256 @@
import { createHash } from 'node:crypto'
import {
createServer as createHttpServer,
type IncomingMessage,
type ServerResponse
} from 'node:http'
import { createServer as createHttpsServer } from 'node:https'
import type { AddressInfo } from 'node:net'
import type { Duplex } from 'node:stream'
import {
LOCAL_HTTPS_TEST_CERTIFICATE,
LOCAL_HTTPS_TEST_PRIVATE_KEY
} from './browser-local-https-test-certificate'
export type WireProbeReceipt = Readonly<{
protocol: 'http' | 'https' | 'ws' | 'wss'
path: string
userAgent: string | null
}>
export type WireProbeJavaScriptIdentity = Readonly<{
context: string
userAgent: string
userAgentData: unknown
}>
export type BrowserSessionUaWireProbeServer = Readonly<{
httpOrigin: string
httpsOrigin: string
receipts: WireProbeReceipt[]
identities: WireProbeJavaScriptIdentity[]
close: () => Promise<void>
}>
// A server listening on a TCP port always reports an AddressInfo; a string or null means the
// listen never took effect, which is worth failing on loudly rather than building a bad origin.
function boundPort(server: { address: () => AddressInfo | string | null }): number {
const address = server.address()
if (address === null || typeof address === 'string') {
throw new Error('wire_probe_server_not_listening_on_tcp')
}
return address.port
}
export async function startBrowserSessionUaWireProbeServer(): Promise<BrowserSessionUaWireProbeServer> {
const receipts: WireProbeReceipt[] = []
const identities: WireProbeJavaScriptIdentity[] = []
const upgradedSockets = new Set<Duplex>()
let origins: { http: string; https: string } | null = null
const respond =
(protocol: 'http' | 'https') =>
async (request: IncomingMessage, response: ServerResponse): Promise<void> => {
const path = new URL(request.url ?? '/', 'http://probe.invalid').pathname
receipts.push({
protocol,
path,
userAgent:
typeof request.headers['user-agent'] === 'string' ? request.headers['user-agent'] : null
})
if (path.startsWith('/report/')) {
const body = await readBody(request)
identities.push({ context: path.slice('/report/'.length), ...JSON.parse(body) })
respondText(response, 'ok')
return
}
if (path === '/shared-worker.js') {
respondScript(response, sharedWorkerScript(origins?.http ?? ''))
return
}
if (path === '/service-worker.js') {
response.setHeader('Service-Worker-Allowed', '/')
respondScript(response, serviceWorkerScript(origins?.http ?? ''))
return
}
if (path === '/frame') {
respondHtml(response, childPage('frame', origins?.http ?? ''))
return
}
if (path === '/popup') {
respondHtml(response, childPage('popup', origins?.http ?? '', true))
return
}
if (path === '/desktop-peer') {
respondHtml(response, desktopPeerPage(origins?.http ?? ''))
return
}
if (path.endsWith('-image')) {
response.writeHead(200, { 'Cache-Control': 'no-store', 'Content-Type': 'image/gif' })
response.end(Buffer.from('R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', 'base64'))
return
}
if (path === '/') {
respondHtml(response, probePage(origins?.http ?? '', origins?.https ?? ''))
return
}
respondText(response, path)
}
const http = createHttpServer((request, response) => void respond('http')(request, response))
const https = createHttpsServer(
{ cert: LOCAL_HTTPS_TEST_CERTIFICATE, key: LOCAL_HTTPS_TEST_PRIVATE_KEY },
(request, response) => void respond('https')(request, response)
)
installWebSocketResponder(http, 'ws', receipts, upgradedSockets)
installWebSocketResponder(https, 'wss', receipts, upgradedSockets)
await Promise.all([listen(http), listen(https)])
origins = {
http: `http://127.0.0.1:${boundPort(http)}`,
https: `https://127.0.0.1:${boundPort(https)}`
}
return {
httpOrigin: origins.http,
httpsOrigin: origins.https,
receipts,
identities,
close: async () => {
for (const socket of upgradedSockets) {
socket.destroy()
}
await Promise.all([closeServer(http), closeServer(https)])
}
}
}
function probePage(httpOrigin: string, httpsOrigin: string): string {
const blobScript = contextScript('blob', httpOrigin, ['/blob-fetch', '/blob-xhr', '/blob-image'])
const blobDocument = `<!doctype html><script>${blobScript}</script>`
const serializedBlobDocument = JSON.stringify(blobDocument).replace('</script>', '<\\/script>')
return `<!doctype html><title>UA wire probe</title><script>
const identity = () => ({ userAgent: navigator.userAgent, userAgentData: navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform } : null })
const report = context => fetch(${JSON.stringify(httpOrigin)} + '/report/' + context, { method: 'POST', body: JSON.stringify(identity()) })
const fetchRoute = path => fetch(${JSON.stringify(httpOrigin)} + path).then(response => response.text())
const xhrRoute = path => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', ${JSON.stringify(httpOrigin)} + path); xhr.onload = resolve; xhr.onerror = reject; xhr.send() })
const imageRoute = path => new Promise((resolve, reject) => { const image = new Image(); image.onload = resolve; image.onerror = reject; image.src = ${JSON.stringify(httpOrigin)} + path })
const message = context => new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error(context + ' timeout')), 10000); const listener = event => { if (event.data?.context !== context) return; clearTimeout(timeout); removeEventListener('message', listener); resolve(event.data) }; addEventListener('message', listener) })
const socket = url => new Promise((resolve, reject) => { const ws = new WebSocket(url); ws.onopen = () => { ws.close(); resolve() }; ws.onerror = reject })
window.probePromise = (async () => {
await report('document')
const frameDone = message('frame'); const frame = document.createElement('iframe'); frame.src = ${JSON.stringify(httpOrigin)} + '/frame'; document.body.append(frame)
const blobDone = message('blob'); const blob = document.createElement('iframe'); blob.src = URL.createObjectURL(new Blob([${serializedBlobDocument}], { type: 'text/html' })); document.body.append(blob)
const sharedDone = message('shared-worker'); const shared = new SharedWorker(${JSON.stringify(httpOrigin)} + '/shared-worker.js'); shared.port.start(); shared.port.onmessage = event => postMessage(event.data, '*')
const serviceDone = message('service-worker'); const registration = await navigator.serviceWorker.register('/service-worker.js'); await navigator.serviceWorker.ready; navigator.serviceWorker.addEventListener('message', event => postMessage(event.data, '*')); (navigator.serviceWorker.controller || registration.active).postMessage('probe')
const popupDone = message('popup'); window.open(${JSON.stringify(httpOrigin)} + '/popup', '_blank')
await Promise.all([
fetchRoute('/document-fetch'), xhrRoute('/document-xhr'), imageRoute('/document-image'),
socket('ws://' + new URL(${JSON.stringify(httpOrigin)}).host + '/plain-ws'),
socket('wss://' + new URL(${JSON.stringify(httpsOrigin)}).host + '/secure-ws'),
frameDone, blobDone, sharedDone, serviceDone, popupDone
])
return true
})()
</script>`
}
function childPage(context: string, httpOrigin: string, popup = false): string {
const extra = popup ? `await fetch(${JSON.stringify(httpOrigin)} + '/popup-fetch')` : ''
return `<!doctype html><script>(async () => { const identity = { userAgent: navigator.userAgent, userAgentData: navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform } : null }; await fetch(${JSON.stringify(httpOrigin)} + '/report/${context}', { method: 'POST', body: JSON.stringify(identity) }); ${extra}; (opener || parent).postMessage({ context: '${context}' }, '*') })()</script>`
}
function desktopPeerPage(httpOrigin: string): string {
return `<!doctype html><script>
const identity = { userAgent: navigator.userAgent, userAgentData: navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform } : null }
window.peerProbePromise = (async () => {
await fetch(${JSON.stringify(httpOrigin)} + '/report/desktop-peer', { method: 'POST', body: JSON.stringify(identity) })
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('desktop peer shared-worker timeout')), 10000)
const shared = new SharedWorker(${JSON.stringify(httpOrigin)} + '/shared-worker.js')
shared.port.start()
shared.port.onmessage = () => { clearTimeout(timeout); resolve() }
})
return true
})()
</script>`
}
function contextScript(context: string, httpOrigin: string, routes: string[]): string {
return `(async () => { const identity = { userAgent: navigator.userAgent, userAgentData: navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform } : null }; await fetch(${JSON.stringify(httpOrigin)} + '/report/${context}', { method: 'POST', body: JSON.stringify(identity) }); await fetch(${JSON.stringify(httpOrigin + routes[0])}); await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('GET', ${JSON.stringify(httpOrigin + routes[1])}); xhr.onload = resolve; xhr.onerror = reject; xhr.send() }); await new Promise((resolve, reject) => { const image = new Image(); image.onload = resolve; image.onerror = reject; image.src = ${JSON.stringify(httpOrigin + routes[2])} }); parent.postMessage({ context: '${context}' }, '*') })()`
}
function sharedWorkerScript(httpOrigin: string): string {
return `onconnect = event => { const port = event.ports[0]; (async () => { const identity = { userAgent: navigator.userAgent, userAgentData: navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform } : null }; await fetch(${JSON.stringify(httpOrigin)} + '/report/shared-worker', { method: 'POST', body: JSON.stringify(identity) }); await fetch(${JSON.stringify(httpOrigin)} + '/shared-worker-fetch-a'); await fetch(${JSON.stringify(httpOrigin)} + '/shared-worker-fetch-b'); port.postMessage({ context: 'shared-worker' }) })() }`
}
function serviceWorkerScript(httpOrigin: string): string {
return `addEventListener('install', event => event.waitUntil(skipWaiting())); addEventListener('activate', event => event.waitUntil(clients.claim())); addEventListener('message', event => { if (event.data !== 'probe') return; event.waitUntil((async () => { const identity = { userAgent: navigator.userAgent, userAgentData: navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform } : null }; await fetch(${JSON.stringify(httpOrigin)} + '/report/service-worker', { method: 'POST', body: JSON.stringify(identity) }); await fetch(${JSON.stringify(httpOrigin)} + '/service-worker-fetch'); event.source.postMessage({ context: 'service-worker' }) })()) })`
}
function installWebSocketResponder(
server: ReturnType<typeof createHttpServer> | ReturnType<typeof createHttpsServer>,
protocol: 'ws' | 'wss',
receipts: WireProbeReceipt[],
upgradedSockets: Set<Duplex>
): void {
server.on('upgrade', (request, socket) => {
upgradedSockets.add(socket)
socket.once('close', () => upgradedSockets.delete(socket))
const key = request.headers['sec-websocket-key']
receipts.push({
protocol,
path: new URL(request.url ?? '/', 'http://probe.invalid').pathname,
userAgent:
typeof request.headers['user-agent'] === 'string' ? request.headers['user-agent'] : null
})
if (typeof key !== 'string') {
socket.destroy()
return
}
const accept = createHash('sha1')
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
.digest('base64')
socket.end(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`
)
})
}
function listen(server: ReturnType<typeof createHttpServer>): Promise<void> {
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
}
function closeServer(server: ReturnType<typeof createHttpServer>): Promise<void> {
server.closeAllConnections()
return new Promise((resolve) => server.close(() => resolve()))
}
function readBody(request: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
request.on('data', (chunk: Buffer) => chunks.push(chunk))
request.once('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
request.once('error', reject)
})
}
function respondText(response: ServerResponse, body: string): void {
response.writeHead(200, { 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store' })
response.end(body)
}
function respondHtml(response: ServerResponse, body: string): void {
response.writeHead(200, { 'Cache-Control': 'no-store', 'Content-Type': 'text/html' })
response.end(body)
}
function respondScript(response: ServerResponse, body: string): void {
response.writeHead(200, {
'Cache-Control': 'no-store',
'Content-Type': 'application/javascript'
})
response.end(body)
}