sim: merge PR #17189

This commit is contained in:
Brennan Benson
2026-08-30 15:42:28 -07:00
9 changed files with 71 additions and 55 deletions
@@ -51,7 +51,7 @@ import {
import {
createViewportGuestFactory,
flushViewportOps,
GUEST_CLEAN_UA
GUEST_ELECTRON_UA
} from './browser-manager-viewport-test-fixtures'
const {
@@ -543,7 +543,7 @@ describe('browserManager', () => {
)
expect(uaWrites.length).toBeGreaterThan(0)
for (const [, params] of uaWrites) {
expect((params as { userAgent: string }).userAgent).toBe(GUEST_CLEAN_UA)
expect((params as { userAgent: string }).userAgent).toBe(GUEST_ELECTRON_UA)
}
})
})
@@ -49,7 +49,6 @@ import {
import {
createViewportGuestFactory,
flushViewportOps,
GUEST_CLEAN_UA,
GUEST_ELECTRON_UA
} from './browser-manager-viewport-test-fixtures'
@@ -207,7 +206,7 @@ describe('browserManager', () => {
mobile: false
})
expect(debuggerSendCommand).toHaveBeenLastCalledWith('Emulation.setUserAgentOverride', {
userAgent: GUEST_CLEAN_UA
userAgent: GUEST_ELECTRON_UA
})
// Navigating to the auth host must move the standing override to the Firefox identity.
@@ -222,7 +221,7 @@ describe('browserManager', () => {
debuggerSendCommand.mockClear()
willRedirect({ preventDefault: vi.fn() }, 'https://example.com/', false, true)
await flushViewportOps()
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
})
// Why: not an ordering race — debugger.sendCommand dispatches in call order over one channel, so
@@ -241,8 +240,8 @@ describe('browserManager', () => {
}
// Why mobile: on the desktop branch the break is masked by coincidence — applyGoogleAuthUserAgent
// has already switched the WebContents UA to Firefox, and cleanElectronUserAgent passes a Firefox
// UA through untouched, so the stale-URL desktop path happens to emit Firefox anyway. The mobile
// has already switched the WebContents UA to Firefox and the desktop branch republishes its base
// UA untouched, so the stale-URL desktop path happens to emit Firefox anyway. The mobile
// branch derives a Chrome-shaped iPhone UA from that same base and exposes the real defect.
it('does not leave the Chrome preset UA standing when a mobile preset lands mid-navigation onto an auth host', async () => {
const { guest, debuggerSendCommand } = makeGuest(4251, 'https://example.com/')
@@ -332,7 +331,7 @@ describe('browserManager', () => {
// Without the fix the resuming preset re-reads getURL() as the auth host and clobbers the
// navigation's correct write, stranding the Firefox UA on a non-auth page.
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
})
it('falls back to the committed URL once a navigation commits or fails', async () => {
@@ -378,7 +377,7 @@ describe('browserManager', () => {
await flushViewportOps()
expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA)
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
// A later preset must also resolve the committed, non-auth URL.
debuggerSendCommand.mockClear()
@@ -457,7 +456,7 @@ describe('browserManager', () => {
expect(guest.setUserAgent).not.toHaveBeenCalled()
expect(debuggerSendCommand).not.toHaveBeenCalledWith(
'Emulation.setUserAgentOverride',
expect.objectContaining({ userAgent: GUEST_CLEAN_UA })
expect.objectContaining({ userAgent: GUEST_ELECTRON_UA })
)
})
@@ -517,7 +516,7 @@ describe('browserManager', () => {
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/redirected', true)
await flushViewportOps()
expect(guest.setUserAgent).not.toHaveBeenCalled()
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
})
it('preserves the auth identity when a viewport preset is cleared after a redirect', async () => {
@@ -592,7 +591,7 @@ describe('browserManager', () => {
didStartNavigation(null, 'https://example.com/', false, true)
await flushViewportOps()
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_ELECTRON_UA })
})
it('reapplies a preset when navigation starts during its final UA write', async () => {
@@ -3,8 +3,6 @@ import type { BrowserManagerMocks } from './browser-manager-test-harness'
export const GUEST_ELECTRON_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36'
export const GUEST_CLEAN_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
// Why: viewport UA writes are queued on the per-tab chain, so draining it takes more than one
// microtask hop; loop until the chain is empty rather than guessing a tick count.
+1 -2
View File
@@ -48,7 +48,6 @@ import {
type PageInitiatedTabBudget
} from './browser-page-initiated-tab-budget'
import { isNewBrowserTabPopupIntent } from './browser-popup-new-tab-intent'
import { cleanElectronUserAgent } from './browser-session-ua'
import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua'
import { buildViewportUserAgentOverride } from './browser-viewport-user-agent'
@@ -1273,7 +1272,7 @@ export class BrowserManager {
// Why: the session UA is the profile's stable base identity. guest.getUserAgent() is not:
// applyGoogleAuthUserAgent leaves it pinned to the Firefox auth UA once a guest switches to
// the CDP override, so reading it back here would republish that identity on ordinary hosts.
baseUserAgent: cleanElectronUserAgent(baseUserAgent ?? guest.session.getUserAgent())
baseUserAgent: baseUserAgent ?? guest.session.getUserAgent()
})
)
}
@@ -5,7 +5,8 @@ const mocks = vi.hoisted(() => ({
handleGuestWillDownload: vi.fn(),
noticeDocPreviewDownloadBlocked: vi.fn(),
clearBrowserWebAuthnAccessHandlers: vi.fn(),
installBrowserWebAuthnAccessHandlers: vi.fn()
installBrowserWebAuthnAccessHandlers: vi.fn(),
setupClientHintsOverride: vi.fn()
}))
type WillDownloadListener = (
@@ -82,8 +83,7 @@ vi.mock('./browser-media-access', () => ({
requestSystemMediaAccess: async () => false
}))
vi.mock('./browser-session-ua', () => ({
cleanElectronUserAgent: (userAgent: string) => userAgent,
setupClientHintsOverride: vi.fn()
setupClientHintsOverride: mocks.setupClientHintsOverride
}))
vi.mock('./browser-session-user-agent-mode', () => ({
setBrowserSessionUserAgentMode: vi.fn()
@@ -232,4 +232,33 @@ describe('partition permission policy', () => {
})
expect(displayMediaDecision).toEqual({ video: undefined, audio: undefined })
})
// Why: stripping the engine tokens leaves a UA claiming Google Chrome that the engine cannot
// back — Electron emits no sec-ch-ua* client hints — and Cloudflare's managed challenge rejects
// exactly that combination. The untouched engine UA passes it (STA-3905).
it('leaves the engine user agent untouched on a clean-mode profile', async () => {
const install = await loadInstaller()
install(profileFor('orca-ua-clean'))
const sess = sessionsByPartition.get('orca-ua-clean')
if (!sess) {
throw new Error('Expected the clean-mode session')
}
expect(sess.setUserAgent).not.toHaveBeenCalled()
})
// Why: the auth-host Firefox switch lives inside the same installer, so dropping the UA rewrite
// must not take it down with it.
it('still installs the auth-host header switch for a clean-mode profile', async () => {
const install = await loadInstaller()
install(profileFor('orca-ua-hints'))
const sess = sessionsByPartition.get('orca-ua-hints')
expect(mocks.setupClientHintsOverride).toHaveBeenCalledWith(sess, 'Mozilla/5.0 Orca')
})
it('installs no header switch for a native-mode profile', async () => {
const install = await loadInstaller()
install({ ...profileFor('orca-ua-native'), userAgentMode: 'native' })
const sess = sessionsByPartition.get('orca-ua-native')
expect(sess?.setUserAgent).not.toHaveBeenCalled()
expect(mocks.setupClientHintsOverride).not.toHaveBeenCalledWith(sess, expect.anything())
})
})
@@ -9,7 +9,7 @@ import {
} from './browser-session-proxy'
import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access'
import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy'
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
import { setupClientHintsOverride } from './browser-session-ua'
import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
import {
allowsBrowserWebAuthnPermission,
@@ -93,9 +93,9 @@ export function installBrowserSessionPartitionPolicies(
browserManager.installCertificateRequestGuard(sess)
if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') {
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
// Why the engine UA stands as-is: see setupClientHintsOverride's header. The switch still
// installs here because it owns the Google auth-host Firefox identity, which is unrelated.
setupClientHintsOverride(sess, sess.getUserAgent())
}
if (options?.permissions === 'deny') {
sess.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false))
@@ -192,10 +192,7 @@ export function applyBrowserSessionUserAgentModes(profiles: BrowserSessionProfil
continue
}
// Why: the default Electron UA leaks "Electron/X.X.X" + app name, which trips Cloudflare Turnstile.
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
sess.setUserAgent(cleanUA)
setupClientHintsOverride(sess, cleanUA)
setupClientHintsOverride(sess, sess.getUserAgent())
} catch {
/* session not available yet (e.g. unit tests or pre-ready) */
}
@@ -119,7 +119,6 @@ function installModuleMocks(
requestSystemMediaAccess: requestSystemMediaAccessMock
}))
vi.doMock('./browser-session-ua', () => ({
cleanElectronUserAgent: vi.fn((ua: string) => ua.replace(/\s*Electron\/\S+/, '')),
setupClientHintsOverride: setupClientHintsOverrideMock
}))
// This suite models replay with an in-memory filesystem. The real file-backed SQLite merge has
@@ -234,7 +233,7 @@ describe('BrowserSessionRegistry persistence', () => {
})
})
it('keeps UA cleaning as the fallback for profiles without an override', async () => {
it('leaves the engine UA in place for profiles without an override', async () => {
const fsState = createFsState()
const { sessionFromPartitionMock, setupClientHintsOverrideMock } = installModuleMocks(fsState)
const { browserSessionRegistry } = await import('./browser-session-registry')
@@ -242,8 +241,14 @@ describe('BrowserSessionRegistry persistence', () => {
await browserSessionRegistry.createProfile('isolated', 'Default identity')
const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value
expect(profileSession.setUserAgent).toHaveBeenCalledWith('Mozilla/5.0 Orca')
expect(setupClientHintsOverrideMock).toHaveBeenCalledWith(profileSession, 'Mozilla/5.0 Orca')
// Why: a rewritten UA is what Cloudflare's managed challenge rejects (STA-3905); the engine's
// own identity stands, and only the auth-host header switch installs.
expect(profileSession.setUserAgent).not.toHaveBeenCalled()
// The Electron token now survives to the auth-host switch instead of being laundered out.
expect(setupClientHintsOverrideMock).toHaveBeenCalledWith(
profileSession,
'Mozilla/5.0 Electron/31 Orca'
)
})
it('leaves UA and client hints untouched for native-mode profiles', async () => {
@@ -379,7 +384,7 @@ describe('BrowserSessionRegistry persistence', () => {
// Why: imports before Aug 2026 persisted a synthesized source-browser UA
// (fork imports as a broken Chrome/1.x, Chrome imports as a valid version).
// Neither may ever be applied again — the engine-derived UA is the only one.
it('ignores legacy persisted UAs, valid or broken, and applies the engine UA', async () => {
it('ignores legacy persisted UAs, valid or broken, and writes no UA at all', async () => {
const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111'
const brokenUa =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/1.158.1 Safari/537.36'
@@ -415,9 +420,8 @@ describe('BrowserSessionRegistry persistence', () => {
)
expect(appliedUas).not.toContain(brokenUa)
expect(appliedUas).not.toContain(validUa)
// Why: every non-native profile falls to Orca's own cleaned engine UA.
expect(appliedUas.length).toBeGreaterThan(0)
expect(appliedUas.every((ua) => ua === 'Mozilla/5.0 Orca')).toBe(true)
// Why: no profile writes a UA at all now, which subsumes "never replays a persisted one".
expect(appliedUas).toEqual([])
expect(
setupClientHintsOverrideMock.mock.calls.every(
(c: unknown[]) => c[1] !== brokenUa && c[1] !== validUa
+8 -19
View File
@@ -8,25 +8,14 @@ import {
stripClientHints
} from './browser-google-auth-ua'
// Why: Electron's default UA includes "Electron/X.X.X" and the app name
// (e.g. "orca/1.2.3"), which Cloudflare Turnstile and other bot detectors
// flag as non-human traffic. Strip those tokens so the webview's UA and
// sec-ch-ua Client Hints look like standard Chrome.
export function cleanElectronUserAgent(ua: string): string {
return (
ua
.replace(/\s+Electron\/\S+/, '')
// Why: \S+ matches any non-whitespace token (e.g. "orca/1.3.8-rc.0")
// including pre-release semver strings that [\d.]+ would miss.
.replace(/(\)\s+)\S+\s+(Chrome\/)/, '$1$2')
)
}
// Why: Electron emits sec-ch-ua brands like "Not A(Brand" without a
// "Google Chrome" entry, which disagrees with the Chrome-shaped UA the session
// presents. Rewrite the hint headers to the brand set Chrome ships for the same
// engine version so the two surfaces tell one story. Also owns the Google
// auth-host Firefox switch, which must install even for a non-Chrome-shaped UA.
// Why the session UA is left alone (STA-3905): stripping "Electron/X" and the app name leaves a UA
// claiming Google Chrome, and Chromium under Electron sends no sec-ch-ua* client hints at all — a
// combination no real Chrome produces. Cloudflare's managed challenge rejects it ("There was a
// problem with verification"), while the untouched engine UA passes. Measured on
// dash.cloudflare.com/login: untouched 5/5 pass, every rewritten variant 12/12 fail.
//
// The brand rewrite below is kept for hosts that do send the hints, but its real job now is the
// Google auth-host Firefox switch, which must install regardless of the UA shape.
export function setupClientHintsOverride(
sess: Session,
ua: string,
@@ -44,7 +44,8 @@ export function buildViewportUserAgentOverride(args: {
return { userAgent: googleAuthUserAgent() }
}
if (!args.mobile) {
// Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session.
// Why: republish the session's own identity unchanged — a preset must not become a second place
// that reshapes the UA (STA-3905).
return { userAgent: args.baseUserAgent }
}
const chromeMajor = extractChromeMajor(args.baseUserAgent)