mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user