diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index 5e96931f06f..f54558bed97 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -21,6 +21,7 @@ interface MockView { setPermissionCheckHandler: ReturnType } on: ReturnType + setUserAgent: ReturnType setWindowOpenHandler: ReturnType loadURL: ReturnType reload: ReturnType @@ -169,6 +170,18 @@ describe('browser-agent session', () => { expect(onTabNavigated).toHaveBeenCalledWith(contents, true) }) + it('gives every tab a user agent with no Electron token in it', () => { + const first = session.ensureTab() + const second = session.addTab() + + for (const tab of [first, second]) { + const contents = (tab.view as unknown as MockView).webContents + const agent = contents.setUserAgent.mock.calls.at(-1)?.[0] as string | undefined + expect(agent).toMatch(/^Mozilla\/5\.0 \(.+\) .*Chrome\/\d+\.0\.0\.0 Safari\/537\.36$/) + expect(agent).not.toMatch(/Electron|Sim\//) + } + }) + it('settles the tab spinner when only subresources are still loading', () => { const tab = session.ensureTab() const contents = (tab.view as unknown as MockView).webContents diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 3d1b1589e14..79e7f57b25c 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -62,6 +62,7 @@ import { isBlockedSubresourceUrl, subresourceNeedsResolution, } from '@/main/browser-agent/url-guard' +import { browserUserAgent } from '@/main/browser-agent/user-agent' import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store' import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads' import { @@ -819,6 +820,11 @@ function configureAgentPartition(ses: Session): void { configuredPartitions.add(ses) ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false)) ses.setPermissionCheckHandler(() => false) + // Service workers do not inherit a tab's user agent. With only the tab's set, + // the document request carries the browser string while the worker's own + // script request still announces Electron — and on a site that routes its + // fetches through a worker, that is the one the server sees. + ses.setUserAgent(browserUserAgent()) // SSRF choke point for the agent partition. Document navigations (top-level + // iframes) get the full DNS-resolving check — the one seam every navigation // passes through, including page-initiated ones the driver never sees (server @@ -1101,6 +1107,10 @@ function createTabView(): WebContentsView { const contents = view.webContents registerAgentWebContents(contents) configureAgentPartition(contents.session) + // The session default does not reach a WebContents that already exists, and + // the first tab is what brings the session into being, so each tab sets its + // own as well — otherwise tab one browses as Electron and the rest as Chrome. + contents.setUserAgent(browserUserAgent()) attachAgentContextMenu(contents, { addToChat: (text) => withBrowserScope(scopeId, () => addPageSelectionToChat(contents, text)), openTab: (url) => withBrowserScope(scopeId, () => openTabWithUrl(url, false)), diff --git a/apps/desktop/src/main/browser-agent/user-agent.test.ts b/apps/desktop/src/main/browser-agent/user-agent.test.ts new file mode 100644 index 00000000000..db63efa7141 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/user-agent.test.ts @@ -0,0 +1,43 @@ +import { app } from 'electron' +import { describe, expect, it, vi } from 'vitest' +import { browserUserAgent, stockChromeUserAgent } from '@/main/browser-agent/user-agent' + +vi.mock('electron', () => import('@/test/electron-mock')) + +const ELECTRON_DEFAULT = + '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' + +describe('stockChromeUserAgent', () => { + it('drops the application and Electron tokens a browser allowlist rejects', () => { + const agent = stockChromeUserAgent(ELECTRON_DEFAULT) + expect(agent).not.toMatch(/Electron/) + expect(agent).not.toMatch(/Sim\//) + }) + + it('reproduces the desktop string Chrome sends under user-agent reduction', () => { + expect(stockChromeUserAgent(ELECTRON_DEFAULT)).toBe( + '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' + ) + }) + + it('keeps the platform token of the machine it is running on', () => { + const windowsDefault = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36' + expect(stockChromeUserAgent(windowsDefault)).toContain('(Windows NT 10.0; Win64; x64)') + }) + + it('passes through a string that is not a Chromium user agent', () => { + expect(stockChromeUserAgent('curl/8.4.0')).toBe('curl/8.4.0') + expect(stockChromeUserAgent('')).toBe('') + }) +}) + +describe('browserUserAgent', () => { + it('derives from the string Electron would otherwise have sent', () => { + app.userAgentFallback = ELECTRON_DEFAULT + + expect(browserUserAgent()).toBe( + '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' + ) + }) +}) diff --git a/apps/desktop/src/main/browser-agent/user-agent.ts b/apps/desktop/src/main/browser-agent/user-agent.ts new file mode 100644 index 00000000000..9e988c67987 --- /dev/null +++ b/apps/desktop/src/main/browser-agent/user-agent.ts @@ -0,0 +1,46 @@ +/** + * The user agent the browser resource presents to sites. + * + * Electron's default string carries two tokens no browser sends — + * `Sim/` and `Electron/`. Chromium's own token sits right + * beside them, but that does not save it: the detection libraries sites gate on + * test for Electron BEFORE Chrome (bowser matches `/electron/i` several + * descriptors ahead of its Chrome one, ua-parser-js reports `Electron` as the + * browser name), so the browser reads as "Electron", which is on nobody's + * supported list. Ashby warns "Ashby does not support this browser"; stricter + * sites refuse to render at all. + * + * Reporting stock Chrome is accurate rather than a disguise — the engine is the + * Chromium build the token already names, and Electron's user-agent client + * hints (`Sec-CH-UA`, `navigator.userAgentData`) only ever carried a Chromium + * brand, so dropping the token makes the header and the hints agree instead of + * contradicting each other. + */ +import { app } from 'electron' + +/** Platform token, then the Chromium major version, in the order a Chromium user agent lists them. */ +const CHROMIUM_USER_AGENT = /^Mozilla\/5\.0 \(([^)]*)\).* Chrome\/(\d+)\./ + +/** + * Rebuilds the default user agent as the string Chrome itself sends. Chrome's + * user-agent reduction fixes the desktop form at + * `Mozilla/5.0 () AppleWebKit/537.36 (KHTML, like Gecko) Chrome/.0.0.0 Safari/537.36`, + * so keeping the platform token and the Chromium major version — and zeroing + * the rest — reproduces it exactly, with no room left for an application or + * Electron token. A string that is not a Chromium user agent is returned + * unchanged rather than replaced with a guess. + */ +export function stockChromeUserAgent(defaultUserAgent: string): string { + const match = defaultUserAgent.match(CHROMIUM_USER_AGENT) + if (!match) return defaultUserAgent + const [, platform, chromeMajor] = match + return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeMajor}.0.0.0 Safari/537.36` +} + +/** + * Derived from the string Electron would otherwise have sent, so the reported + * Chromium version tracks whatever Chromium the app actually ships. + */ +export function browserUserAgent(): string { + return stockChromeUserAgent(app.userAgentFallback) +} diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index fafa34ee4ac..476e391d9bb 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -11,6 +11,8 @@ import { vi } from 'vitest' export const app = { name: 'Sim', isPackaged: false, + userAgentFallback: + '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', getVersion: vi.fn(() => '1.0.0'), getName: vi.fn(() => 'Sim'), setName: vi.fn(), @@ -152,6 +154,7 @@ function createWebContentsMock() { findInPage: vi.fn(() => 1), stopFindInPage: vi.fn(), setBackgroundThrottling: vi.fn(), + setUserAgent: vi.fn(), setIgnoreMenuShortcuts: vi.fn(), getZoomFactor: vi.fn(() => 1), setZoomFactor: vi.fn(), @@ -185,6 +188,7 @@ function createWebContentsMock() { session: { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), + setUserAgent: vi.fn(), webRequest: { onBeforeRequest: vi.fn() }, on: vi.fn(), },