Skip to content

Commit 230e42a

Browse files
authored
fix(desktop): set the browser user agent process-wide so Cloudflare Turnstile passes (#8192)
* fix(desktop): set the browser user agent process-wide so Cloudflare Turnstile passes * test(desktop): cover the process-wide user agent from the first launched request
1 parent 1c597fe commit 230e42a

6 files changed

Lines changed: 43 additions & 28 deletions

File tree

‎apps/desktop/e2e/smoke.spec.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@ const PAGES: Record<string, string> = {
2121
'/login': '<!doctype html><html><body><h1 id="login">fixture-login</h1></body></html>',
2222
}
2323

24+
/** `User-Agent` of every request the fixture origin has served, in arrival order. */
25+
const requestUserAgents: string[] = []
26+
2427
function startFixtureServer(): Promise<{ server: Server; origin: string }> {
2528
return new Promise((resolvePromise) => {
2629
const server = createServer((request, response) => {
30+
requestUserAgents.push(request.headers['user-agent'] ?? '')
2731
const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
2832
const sessionCookie = request.headers.cookie
2933
?.split(';')
@@ -85,6 +89,21 @@ test.describe('desktop shell smoke', () => {
8589
expect(window.url()).toBe(`${origin}/home`)
8690
})
8791

92+
test('presents one stock Chrome user agent on every request from the first load', async () => {
93+
requestUserAgents.length = 0
94+
app = await launchApp(origin)
95+
const window = await app.firstWindow()
96+
await expect(window.locator('#app')).toHaveText('fixture-app')
97+
await window.evaluate(() => fetch('/home').then((response) => response.text()))
98+
99+
const pageUserAgent = await window.evaluate(() => navigator.userAgent)
100+
expect(pageUserAgent).toMatch(
101+
/^Mozilla\/5\.0 \(.+\) AppleWebKit\/537\.36 \(KHTML, like Gecko\) Chrome\/\d+\.0\.0\.0 Safari\/537\.36$/
102+
)
103+
expect(requestUserAgents.length).toBeGreaterThanOrEqual(2)
104+
expect(new Set(requestUserAgents)).toEqual(new Set([pageUserAgent]))
105+
})
106+
88107
test('internal window.open creates an independent full Sim window', async () => {
89108
app = await launchApp(origin)
90109
const window = await app.firstWindow()

‎apps/desktop/src/main/browser-agent/session.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ interface MockView {
3737
session: {
3838
setPermissionRequestHandler: ReturnType<typeof vi.fn>
3939
setPermissionCheckHandler: ReturnType<typeof vi.fn>
40+
setUserAgent: ReturnType<typeof vi.fn>
4041
webRequest: { onBeforeRequest: ReturnType<typeof vi.fn> }
4142
}
4243
on: ReturnType<typeof vi.fn>
@@ -358,15 +359,14 @@ describe('browser-agent session', () => {
358359
expect(onTabNavigated).toHaveBeenCalledWith(contents, true)
359360
})
360361

361-
it('gives every tab a user agent with no Electron token in it', () => {
362+
it('leaves every tab on the process-wide user agent instead of overriding it', () => {
362363
const first = session.ensureTab()
363364
const second = session.addTab()
364365

365366
for (const tab of [first, second]) {
366367
const contents = (tab.view as unknown as MockView).webContents
367-
const agent = contents.setUserAgent.mock.calls.at(-1)?.[0] as string | undefined
368-
expect(agent).toMatch(/^Mozilla\/5\.0 \(.+\) .*Chrome\/\d+\.0\.0\.0 Safari\/537\.36$/)
369-
expect(agent).not.toMatch(/Electron|Sim\//)
368+
expect(contents.setUserAgent).not.toHaveBeenCalled()
369+
expect(contents.session.setUserAgent).not.toHaveBeenCalled()
370370
}
371371
})
372372

‎apps/desktop/src/main/browser-agent/session.ts‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ import {
6767
isBlockedSubresourceUrl,
6868
subresourceNeedsResolution,
6969
} from '@/main/browser-agent/url-guard'
70-
import { browserUserAgent } from '@/main/browser-agent/user-agent'
7170
import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store'
7271
import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads'
7372
import {
@@ -1413,11 +1412,6 @@ function configureAgentPartition(ses: Session): void {
14131412
}
14141413
return ALLOWED_SITE_PERMISSIONS.has(permission)
14151414
})
1416-
// Service workers do not inherit a tab's user agent. With only the tab's set,
1417-
// the document request carries the browser string while the worker's own
1418-
// script request still announces Electron — and on a site that routes its
1419-
// fetches through a worker, that is the one the server sees.
1420-
ses.setUserAgent(browserUserAgent())
14211415
// SSRF choke point for the agent partition. Document navigations (top-level +
14221416
// iframes) get the full DNS-resolving check — the one seam every navigation
14231417
// passes through, including page-initiated ones the driver never sees (server
@@ -1965,10 +1959,6 @@ function initializeTabView(view: WebContentsView, scopeId: string): WebContentsV
19651959
const contents = view.webContents
19661960
registerAgentWebContents(contents)
19671961
configureAgentPartition(contents.session)
1968-
// The session default does not reach a WebContents that already exists, and
1969-
// the first tab is what brings the session into being, so each tab sets its
1970-
// own as well — otherwise tab one browses as Electron and the rest as Chrome.
1971-
contents.setUserAgent(browserUserAgent())
19721962
attachAgentContextMenu(contents, {
19731963
addToChat: (text) => withBrowserScope(scopeId, () => addPageSelectionToChat(contents, text)),
19741964
openTab: (url) => withBrowserScope(scopeId, () => openTabWithUrl(url, { agentOwned: false })),

‎apps/desktop/src/main/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import { attachTelemetryPolicy } from '@/main/telemetry-policy'
8383
import { TerminalRegistry } from '@/main/terminal/registry'
8484
import { installTray, type TrayHandle } from '@/main/tray'
8585
import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/main/updater'
86+
import { installBrowserUserAgent } from '@/main/user-agent'
8687
import { createMainWindow, setupPermissionHandlers } from '@/main/window'
8788
import { attachWindowOpenPolicy, isPopupContents } from '@/main/windows'
8889

@@ -899,6 +900,7 @@ app.setName(APP_NAME_FOR_CHANNEL[channelForOrigin(DEFAULT_ORIGIN)])
899900
if (process.env.SIM_DESKTOP_USER_DATA) {
900901
app.setPath('userData', process.env.SIM_DESKTOP_USER_DATA)
901902
}
903+
installBrowserUserAgent()
902904

903905
// The scheme the offline page and server picker load from must be declared
904906
// before the app is ready; the per-session handlers attach later.
Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { app } from 'electron'
22
import { describe, expect, it, vi } from 'vitest'
3-
import { browserUserAgent, stockChromeUserAgent } from '@/main/browser-agent/user-agent'
3+
import { installBrowserUserAgent, stockChromeUserAgent } from '@/main/user-agent'
44

55
vi.mock('electron', () => import('@/test/electron-mock'))
66

77
const ELECTRON_DEFAULT =
88
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36'
9+
const STOCK_CHROME =
10+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
911

1012
describe('stockChromeUserAgent', () => {
1113
it('drops the application and Electron tokens a browser allowlist rejects', () => {
@@ -15,9 +17,7 @@ describe('stockChromeUserAgent', () => {
1517
})
1618

1719
it('reproduces the desktop string Chrome sends under user-agent reduction', () => {
18-
expect(stockChromeUserAgent(ELECTRON_DEFAULT)).toBe(
19-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
20-
)
20+
expect(stockChromeUserAgent(ELECTRON_DEFAULT)).toBe(STOCK_CHROME)
2121
})
2222

2323
it('keeps the platform token of the machine it is running on', () => {
@@ -32,12 +32,13 @@ describe('stockChromeUserAgent', () => {
3232
})
3333
})
3434

35-
describe('browserUserAgent', () => {
36-
it('derives from the string Electron would otherwise have sent', () => {
35+
describe('installBrowserUserAgent', () => {
36+
it('idempotently makes stock Chrome the process-wide fallback every request path uses', () => {
3737
app.userAgentFallback = ELECTRON_DEFAULT
3838

39-
expect(browserUserAgent()).toBe(
40-
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
41-
)
39+
installBrowserUserAgent()
40+
installBrowserUserAgent()
41+
42+
expect(app.userAgentFallback).toBe(STOCK_CHROME)
4243
})
4344
})

apps/desktop/src/main/browser-agent/user-agent.ts renamed to apps/desktop/src/main/user-agent.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/**
2-
* The user agent the browser resource presents to sites.
2+
* The user agent the whole desktop process presents: the embedded browser and
3+
* the app's own windows alike (desktop identity travels in `X-Sim-Client-Info`).
34
*
45
* Electron's default string carries two tokens no browser sends —
56
* `Sim/<version>` and `Electron/<version>`. Chromium's own token sits right
@@ -38,9 +39,11 @@ export function stockChromeUserAgent(defaultUserAgent: string): string {
3839
}
3940

4041
/**
41-
* Derived from the string Electron would otherwise have sent, so the reported
42-
* Chromium version tracks whatever Chromium the app actually ships.
42+
* Sets the stock Chrome identity as `app.userAgentFallback` before any session
43+
* exists. Session and per-tab overrides miss some request paths (a cross-origin
44+
* challenge frame still sends the process default), and a site that sees two
45+
* user agents in one challenge rejects it as a spoof. Idempotent.
4346
*/
44-
export function browserUserAgent(): string {
45-
return stockChromeUserAgent(app.userAgentFallback)
47+
export function installBrowserUserAgent(): void {
48+
app.userAgentFallback = stockChromeUserAgent(app.userAgentFallback)
4649
}

0 commit comments

Comments
 (0)