From d0fe5c0b92c3e623c5867ec30f52a7761477ab6c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:02:14 -0700 Subject: [PATCH] fix(browser): stop rewriting the engine user agent so Cloudflare challenges pass (STA-3905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orca replaced the browser session's User-Agent with a Chrome-shaped string — "Electron/X" and the app token stripped — to stop Cloudflare Turnstile flagging the session. Measured on dash.cloudflare.com/login, that rewrite is what makes the managed challenge fail: the untouched engine UA issues a token 5/5, and every rewritten variant fails 12/12 with "There was a problem with verification." The rewrite leaves a UA claiming Google Chrome that the engine cannot back — Chromium under Electron emits no sec-ch-ua* client hints at all, a combination no real Chrome produces. Adding the hints does not rescue it; only the engine's own identity passes. Present accurately instead of disguising: leave the session UA alone. The Google auth-host Firefox switch still installs, since it rides on the same handler but is unrelated to the UA shape. --- .../browser-manager-auth-user-agent.test.ts | 4 +-- .../browser-manager-viewport-override.test.ts | 19 +++++----- .../browser-manager-viewport-test-fixtures.ts | 2 -- src/main/browser/browser-manager.ts | 3 +- ...browser-session-partition-policies.test.ts | 35 +++++++++++++++++-- .../browser-session-partition-policies.ts | 13 +++---- ...owser-session-registry.persistence.test.ts | 20 ++++++----- src/main/browser/browser-session-ua.ts | 27 +++++--------- .../browser/browser-viewport-user-agent.ts | 3 +- 9 files changed, 71 insertions(+), 55 deletions(-) diff --git a/src/main/browser/browser-manager-auth-user-agent.test.ts b/src/main/browser/browser-manager-auth-user-agent.test.ts index 405b689e850..914a1216972 100644 --- a/src/main/browser/browser-manager-auth-user-agent.test.ts +++ b/src/main/browser/browser-manager-auth-user-agent.test.ts @@ -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) } }) }) diff --git a/src/main/browser/browser-manager-viewport-override.test.ts b/src/main/browser/browser-manager-viewport-override.test.ts index b7d3bbabe0a..71228f7a941 100644 --- a/src/main/browser/browser-manager-viewport-override.test.ts +++ b/src/main/browser/browser-manager-viewport-override.test.ts @@ -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 () => { diff --git a/src/main/browser/browser-manager-viewport-test-fixtures.ts b/src/main/browser/browser-manager-viewport-test-fixtures.ts index 8d68977f62e..2b505539121 100644 --- a/src/main/browser/browser-manager-viewport-test-fixtures.ts +++ b/src/main/browser/browser-manager-viewport-test-fixtures.ts @@ -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. diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 2b43e17a371..3f4a04537ba 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -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() }) ) } diff --git a/src/main/browser/browser-session-partition-policies.test.ts b/src/main/browser/browser-session-partition-policies.test.ts index 78ce34d95fd..6bc88c75a61 100644 --- a/src/main/browser/browser-session-partition-policies.test.ts +++ b/src/main/browser/browser-session-partition-policies.test.ts @@ -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()) + }) }) diff --git a/src/main/browser/browser-session-partition-policies.ts b/src/main/browser/browser-session-partition-policies.ts index 11f1f741e5e..667446d4549 100644 --- a/src/main/browser/browser-session-partition-policies.ts +++ b/src/main/browser/browser-session-partition-policies.ts @@ -4,7 +4,7 @@ import type { BrowserSessionProfile } from '../../shared/browser-workspace-types import { browserManager } from './browser-manager' 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, @@ -75,9 +75,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)) @@ -172,10 +172,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) */ } diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index a3caa67b19e..e481928e59c 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -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', () => { 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 diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index 96c55cf5ac9..79d901d9e81 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -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, diff --git a/src/main/browser/browser-viewport-user-agent.ts b/src/main/browser/browser-viewport-user-agent.ts index 7dedf8d8a6e..d7872f16d58 100644 --- a/src/main/browser/browser-viewport-user-agent.ts +++ b/src/main/browser/browser-viewport-user-agent.ts @@ -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)