From a92a1eb29af784def26199151a400a499739fcac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:29:51 -0700 Subject: [PATCH] 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. --- .../browser/browser-identity-mode-record.ts | 75 +++ .../browser/browser-process-user-agent.ts | 50 ++ .../browser-session-ua-cdp-collector.ts | 252 ++++++++ ...ession-ua-cloudflare-live.electron.test.ts | 402 ++++++++++++ ...-session-ua-wire-identity.electron.test.ts | 608 ++++++++++++------ .../browser-session-ua-wire-probe-server.ts | 256 ++++++++ 6 files changed, 1431 insertions(+), 212 deletions(-) create mode 100644 src/main/browser/browser-identity-mode-record.ts create mode 100644 src/main/browser/browser-process-user-agent.ts create mode 100644 src/main/browser/browser-session-ua-cdp-collector.ts create mode 100644 src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts create mode 100644 src/main/browser/browser-session-ua-wire-probe-server.ts diff --git a/src/main/browser/browser-identity-mode-record.ts b/src/main/browser/browser-identity-mode-record.ts new file mode 100644 index 00000000000..67459dd48d2 --- /dev/null +++ b/src/main/browser/browser-identity-mode-record.ts @@ -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 + 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` + ) +} diff --git a/src/main/browser/browser-process-user-agent.ts b/src/main/browser/browser-process-user-agent.ts new file mode 100644 index 00000000000..ed1af68a3ba --- /dev/null +++ b/src/main/browser/browser-process-user-agent.ts @@ -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 +} diff --git a/src/main/browser/browser-session-ua-cdp-collector.ts b/src/main/browser/browser-session-ua-cdp-collector.ts new file mode 100644 index 00000000000..7df8e5afe55 --- /dev/null +++ b/src/main/browser/browser-session-ua-cdp-collector.ts @@ -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> +}> + +type PendingRequest = { + targetType: string + resourceType?: string + url?: string + headers?: Record +} + +// 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 | 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 +} + +function readString(record: Record | undefined, key: string): string | undefined { + const value = record?.[key] + return typeof value === 'string' ? value : undefined +} + +function readStringRecord(value: unknown): Record | undefined { + const record = readRecord(value) + if (!record) { + return undefined + } + const strings: Record = {} + 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 + 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() + private readonly requests = new Map() + private readonly webSockets = new Map() + 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 { + 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((resolve, reject) => { + socket.once('open', resolve) + socket.once('error', reject) + }) + return new BrowserSessionUaCdpCollector(socket) + } + + async installAutoAttach(): Promise { + 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 { + if (this.socket.readyState === WebSocket.CLOSED) { + return + } + await new Promise((resolve) => { + this.socket.once('close', () => resolve()) + this.socket.close() + }) + } + + private send( + method: string, + params: Record, + sessionId?: string + ): Promise { + const id = this.nextCommandId++ + const promise = new Promise((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 { + // 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 { + 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') +} diff --git a/src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts b/src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts new file mode 100644 index 00000000000..7daf121fb84 --- /dev/null +++ b/src/main/browser/browser-session-ua-cloudflare-live.electron.test.ts @@ -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 + 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 { + 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> | 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 { + 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> = {} + for (const request of run.requests) { + const userAgent = request.userAgent ?? '' + 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 = {} + 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 { + const server = createServer() + await new Promise((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((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 })) + }) +} diff --git a/src/main/browser/browser-session-ua-wire-identity.electron.test.ts b/src/main/browser/browser-session-ua-wire-identity.electron.test.ts index 9fc1c760643..bd7bc3031e0 100644 --- a/src/main/browser/browser-session-ua-wire-identity.electron.test.ts +++ b/src/main/browser/browser-session-ua-wire-identity.electron.test.ts @@ -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 -} - -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('identity') - } - ) - 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 { + 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> | 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 { - 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 { 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 { + const server = createServer() + await new Promise((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((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 })) + }) +} diff --git a/src/main/browser/browser-session-ua-wire-probe-server.ts b/src/main/browser/browser-session-ua-wire-probe-server.ts new file mode 100644 index 00000000000..9a1012491c4 --- /dev/null +++ b/src/main/browser/browser-session-ua-wire-probe-server.ts @@ -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 +}> + +// 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 { + const receipts: WireProbeReceipt[] = [] + const identities: WireProbeJavaScriptIdentity[] = [] + const upgradedSockets = new Set() + let origins: { http: string; https: string } | null = null + const respond = + (protocol: 'http' | 'https') => + async (request: IncomingMessage, response: ServerResponse): Promise => { + 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 = `` + const serializedBlobDocument = JSON.stringify(blobDocument).replace('', '<\\/script>') + return `UA wire probe` +} + +function childPage(context: string, httpOrigin: string, popup = false): string { + const extra = popup ? `await fetch(${JSON.stringify(httpOrigin)} + '/popup-fetch')` : '' + return `` +} + +function desktopPeerPage(httpOrigin: string): string { + return `` +} + +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 | ReturnType, + protocol: 'ws' | 'wss', + receipts: WireProbeReceipt[], + upgradedSockets: Set +): 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): Promise { + 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): Promise { + server.closeAllConnections() + return new Promise((resolve) => server.close(() => resolve())) +} + +function readBody(request: IncomingMessage): Promise { + 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) +}