mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
fix(browser): enforce per-guest request user agent
This commit is contained in:
@@ -51,7 +51,8 @@ import {
|
||||
import {
|
||||
createViewportGuestFactory,
|
||||
flushViewportOps,
|
||||
GUEST_CLEAN_UA
|
||||
GUEST_CLEAN_UA,
|
||||
GUEST_ELECTRON_UA
|
||||
} from './browser-manager-viewport-test-fixtures'
|
||||
|
||||
const {
|
||||
@@ -63,6 +64,12 @@ const {
|
||||
webContentsFromIdMock
|
||||
} = browserMocks
|
||||
const makeViewportGuest = createViewportGuestFactory(browserMocks)
|
||||
const MOBILE_VIEWPORT_OVERRIDE = {
|
||||
width: 375,
|
||||
height: 667,
|
||||
deviceScaleFactor: 2,
|
||||
mobile: true
|
||||
} as const
|
||||
|
||||
describe('browserManager', () => {
|
||||
beforeEach(() => {
|
||||
@@ -74,6 +81,49 @@ describe('browserManager', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('resolves request identity from the guest viewport and auth state', async () => {
|
||||
const { guest } = makeViewportGuest(4245)
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-request-identity',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
|
||||
// Ablation: with no mobile preset, the clean session identity is resolved.
|
||||
expect(
|
||||
browserManager.resolveBrowserGuestRequestUserAgent({
|
||||
url: 'https://example.com/logo.png',
|
||||
webContentsId: guest.id as number,
|
||||
currentUserAgent: GUEST_CLEAN_UA,
|
||||
baseUserAgent: GUEST_ELECTRON_UA
|
||||
})
|
||||
).toBe(GUEST_CLEAN_UA)
|
||||
|
||||
await browserManager.setViewportOverride('tab-request-identity', MOBILE_VIEWPORT_OVERRIDE)
|
||||
await flushViewportOps()
|
||||
const mobile = browserManager.resolveBrowserGuestRequestUserAgent({
|
||||
url: 'https://example.com/logo.png',
|
||||
webContentsId: guest.id as number,
|
||||
currentUserAgent: GUEST_CLEAN_UA,
|
||||
baseUserAgent: GUEST_ELECTRON_UA
|
||||
})
|
||||
expect(mobile).toContain('CriOS/134')
|
||||
expect(mobile).toContain('iPhone')
|
||||
|
||||
// Negative control: auth-document fan-out remains Firefox even while mobile emulation is active.
|
||||
expect(
|
||||
browserManager.resolveBrowserGuestRequestUserAgent({
|
||||
url: 'https://www.gstatic.com/_/signin/log',
|
||||
webContentsId: guest.id as number,
|
||||
currentUserAgent: GUEST_ELECTRON_UA,
|
||||
effectiveUserAgent: googleAuthUserAgent(),
|
||||
baseUserAgent: GUEST_ELECTRON_UA
|
||||
})
|
||||
).toBe(googleAuthUserAgent())
|
||||
})
|
||||
|
||||
it('presents the Firefox UA on Google auth hosts and restores the base UA off them', async () => {
|
||||
let currentUa = guestBaseUserAgent
|
||||
const sendCommand = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window'
|
||||
import { cleanElectronUserAgent } from './browser-session-ua'
|
||||
import {
|
||||
cleanElectronUserAgent,
|
||||
type BrowserSessionRequestUserAgentResolver
|
||||
} 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'
|
||||
@@ -11,6 +14,58 @@ import {
|
||||
import { BrowserManagerVisibility } from './browser-manager-visibility'
|
||||
|
||||
export abstract class BrowserManagerNavigation extends BrowserManagerVisibility {
|
||||
/** Resolve the one legacy User-Agent value the session hook must enforce for this guest request. */
|
||||
resolveBrowserGuestRequestUserAgent(
|
||||
request: Parameters<BrowserSessionRequestUserAgentResolver>[0]
|
||||
): string {
|
||||
const firefoxUa = googleAuthUserAgent()
|
||||
const pendingNavigation =
|
||||
request.webContentsId === undefined
|
||||
? undefined
|
||||
: this.pendingNavigationByGuestId.get(request.webContentsId)
|
||||
// A navigation on the auth document fans out to non-auth hosts. Its current request identity
|
||||
// is authoritative for that flow and must not be replaced by the profile/mobile default.
|
||||
if (
|
||||
request.currentUserAgent === firefoxUa &&
|
||||
(!pendingNavigation || isGoogleAuthUrl(pendingNavigation.currentUrl))
|
||||
) {
|
||||
return firefoxUa
|
||||
}
|
||||
const overrideState =
|
||||
request.webContentsId === undefined
|
||||
? undefined
|
||||
: this.authUserAgentOverrideStateByGuestId.get(request.webContentsId)
|
||||
const currentOverride =
|
||||
overrideState?.pending.at(-1) &&
|
||||
overrideState.pending.at(-1)!.sequence > (overrideState.confirmed?.sequence ?? -1)
|
||||
? overrideState.pending.at(-1)
|
||||
: overrideState?.confirmed
|
||||
if (
|
||||
!currentOverride &&
|
||||
request.effectiveUserAgent === firefoxUa &&
|
||||
(!pendingNavigation || isGoogleAuthUrl(pendingNavigation.currentUrl))
|
||||
) {
|
||||
// Direct auth navigations use WebContents.setUserAgent, which Electron fails to carry onto
|
||||
// image/XHR/fetch requests. Read that effective guest identity so those paths stay Firefox.
|
||||
return firefoxUa
|
||||
}
|
||||
if (currentOverride?.userAgent === firefoxUa) {
|
||||
return firefoxUa
|
||||
}
|
||||
const browserPageId =
|
||||
request.webContentsId === undefined
|
||||
? undefined
|
||||
: this.tabIdByWebContentsId.get(request.webContentsId)
|
||||
const mobile = browserPageId
|
||||
? (this.viewportUaOverrideMobileByTabId.get(browserPageId) ?? false)
|
||||
: false
|
||||
return buildViewportUserAgentOverride({
|
||||
url: request.url,
|
||||
mobile,
|
||||
baseUserAgent: cleanElectronUserAgent(request.baseUserAgent)
|
||||
}).userAgent
|
||||
}
|
||||
|
||||
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
|
||||
// not the request header, so the header-level Firefox switch in setupGoogleAuthUserAgentOverride
|
||||
// must be matched here per navigation or the two layers disagree — itself a bot tell.
|
||||
|
||||
@@ -95,7 +95,9 @@ export function installBrowserSessionPartitionPolicies(
|
||||
if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') {
|
||||
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
|
||||
sess.setUserAgent(cleanUA)
|
||||
setupGoogleAuthUserAgentOverride(sess)
|
||||
setupGoogleAuthUserAgentOverride(sess, (request) =>
|
||||
browserManager.resolveBrowserGuestRequestUserAgent(request)
|
||||
)
|
||||
}
|
||||
if (options?.permissions === 'deny') {
|
||||
sess.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false))
|
||||
@@ -195,7 +197,9 @@ export function applyBrowserSessionUserAgentModes(profiles: BrowserSessionProfil
|
||||
// Why: imported sessions need the same Chrome-shaped identity after app restart.
|
||||
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
|
||||
sess.setUserAgent(cleanUA)
|
||||
setupGoogleAuthUserAgentOverride(sess)
|
||||
setupGoogleAuthUserAgentOverride(sess, (request) =>
|
||||
browserManager.resolveBrowserGuestRequestUserAgent(request)
|
||||
)
|
||||
} catch {
|
||||
/* session not available yet (e.g. unit tests or pre-ready) */
|
||||
}
|
||||
|
||||
@@ -255,7 +255,10 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value
|
||||
expect(cleanElectronUserAgentMock).toHaveBeenCalledWith(RAW_ELECTRON_USER_AGENT)
|
||||
expect(profileSession.setUserAgent).toHaveBeenCalledWith(CLEAN_USER_AGENT)
|
||||
expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalledWith(profileSession)
|
||||
expect(setupGoogleAuthUserAgentOverrideMock).toHaveBeenCalledWith(
|
||||
profileSession,
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves UA and client hints untouched for native-mode profiles', async () => {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { googleAuthUserAgent } from './browser-google-auth-ua'
|
||||
import { cleanElectronUserAgent, setupGoogleAuthUserAgentOverride } from './browser-session-ua'
|
||||
|
||||
type RequestDetails = {
|
||||
url: string
|
||||
webContentsId?: number
|
||||
webContents?: { getUserAgent: () => string }
|
||||
requestHeaders: Record<string, string>
|
||||
}
|
||||
|
||||
type RequestListener = (
|
||||
details: RequestDetails,
|
||||
callback: (response: { requestHeaders: Record<string, string> }) => void
|
||||
) => void
|
||||
|
||||
function install(resolveRequestUserAgent?: Parameters<typeof setupGoogleAuthUserAgentOverride>[1]) {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const sess = {
|
||||
getUserAgent: vi.fn(
|
||||
() =>
|
||||
'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'
|
||||
),
|
||||
webRequest: { onBeforeSendHeaders }
|
||||
}
|
||||
setupGoogleAuthUserAgentOverride(sess as never, resolveRequestUserAgent)
|
||||
return onBeforeSendHeaders.mock.calls[0][1] as RequestListener
|
||||
}
|
||||
|
||||
function runRequest(listener: RequestListener, details: RequestDetails): Record<string, string> {
|
||||
const callback = vi.fn()
|
||||
listener(details, callback)
|
||||
return callback.mock.calls[0][0].requestHeaders
|
||||
}
|
||||
|
||||
describe('browser session request identity', () => {
|
||||
it('ablates the resolver on an image request while enforcing its per-guest value when enabled', () => {
|
||||
const mobileUa =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/134.0.0.0 Mobile/15E148 Safari/604.1'
|
||||
const request = {
|
||||
url: 'https://example.com/logo.png',
|
||||
webContentsId: 44,
|
||||
requestHeaders: {
|
||||
'User-Agent': 'Electron/30 Chrome/134',
|
||||
'sec-ch-ua': 'browser-owned'
|
||||
}
|
||||
}
|
||||
|
||||
// Ablation: with the new resolver disabled, the clean session identity is the only fallback.
|
||||
const disabled = runRequest(install(), structuredClone(request))
|
||||
expect(disabled['User-Agent']).toBe(
|
||||
cleanElectronUserAgent(
|
||||
'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'
|
||||
)
|
||||
)
|
||||
|
||||
const resolver = vi.fn(() => mobileUa)
|
||||
const enabled = runRequest(install(resolver), structuredClone(request))
|
||||
expect(enabled['User-Agent']).toBe(mobileUa)
|
||||
expect(resolver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: request.url, webContentsId: request.webContentsId })
|
||||
)
|
||||
// Negative control: the resolver does not alter Chromium-owned client hints.
|
||||
expect(enabled['sec-ch-ua']).toBe('browser-owned')
|
||||
})
|
||||
|
||||
it('keeps Firefox across auth-document cross-host requests and strips its hints', () => {
|
||||
const listener = install(({ effectiveUserAgent }) => effectiveUserAgent)
|
||||
const headers = runRequest(listener, {
|
||||
url: 'https://www.gstatic.com/_/signin/log',
|
||||
webContentsId: 7,
|
||||
requestHeaders: {
|
||||
'User-Agent': googleAuthUserAgent(),
|
||||
'sec-ch-ua': 'browser-owned',
|
||||
'sec-ch-ua-platform': '"macOS"'
|
||||
},
|
||||
webContents: { getUserAgent: () => googleAuthUserAgent() } as never
|
||||
})
|
||||
expect(headers['User-Agent']).toBe(googleAuthUserAgent())
|
||||
expect(headers['sec-ch-ua']).toBeUndefined()
|
||||
expect(headers['sec-ch-ua-platform']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the Firefox auth-host branch independent of the resolver', () => {
|
||||
const resolver = vi.fn(() => 'unexpected Chrome identity')
|
||||
const headers = runRequest(install(resolver), {
|
||||
url: 'https://accounts.google.com/v3/signin/identifier',
|
||||
requestHeaders: {
|
||||
'User-Agent': 'Chrome/134',
|
||||
'sec-ch-ua': 'browser-owned'
|
||||
}
|
||||
})
|
||||
expect(headers['User-Agent']).toBe(googleAuthUserAgent())
|
||||
expect(headers['sec-ch-ua']).toBeUndefined()
|
||||
expect(resolver).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -22,14 +22,38 @@ export function cleanElectronUserAgent(ua: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
export type BrowserSessionRequestUserAgentResolver = (args: {
|
||||
url: string
|
||||
webContentsId?: number
|
||||
currentUserAgent?: string
|
||||
effectiveUserAgent?: string
|
||||
baseUserAgent: string
|
||||
}) => string | undefined
|
||||
|
||||
// Why: Chromium already publishes one internally consistent client-hint identity through both
|
||||
// request headers and navigator.userAgentData. This handler only owns the host-scoped Firefox
|
||||
// exception; synthesizing Chrome brands here would make those two browser-owned surfaces disagree.
|
||||
export function setupGoogleAuthUserAgentOverride(sess: Session): void {
|
||||
// request headers and navigator.userAgentData. This handler owns the legacy User-Agent header on
|
||||
// the wire; synthesizing Chrome brands here would make those browser-owned surfaces disagree.
|
||||
export function setupGoogleAuthUserAgentOverride(
|
||||
sess: Session,
|
||||
resolveRequestUserAgent?: BrowserSessionRequestUserAgentResolver
|
||||
): void {
|
||||
const firefoxUa = googleAuthUserAgent()
|
||||
|
||||
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
|
||||
const headers = details.requestHeaders
|
||||
const requestUserAgent = currentUserAgent(headers)
|
||||
let effectiveUserAgent: string | undefined
|
||||
try {
|
||||
effectiveUserAgent = details.webContents?.getUserAgent()
|
||||
} catch {
|
||||
// The request can race guest teardown; the header and manager state still provide a fallback.
|
||||
}
|
||||
// The resolver is supplied by the browser manager so this layer can enforce viewport and
|
||||
// auth identities without importing manager state into the session policy (which would cycle).
|
||||
const baseUserAgent =
|
||||
typeof sess.getUserAgent === 'function'
|
||||
? cleanElectronUserAgent(sess.getUserAgent())
|
||||
: (requestUserAgent ?? '')
|
||||
if (isGoogleAuthUrl(details.url)) {
|
||||
// Why: present a Firefox identity on Google's sign-in hosts so the user logs
|
||||
// in inside the app and Google issues self-refreshing bound cookies. Strip
|
||||
@@ -39,7 +63,27 @@ export function setupGoogleAuthUserAgentOverride(sess: Session): void {
|
||||
callback({ requestHeaders: headers })
|
||||
return
|
||||
}
|
||||
if (currentUserAgent(headers) === firefoxUa) {
|
||||
const userAgent =
|
||||
// Requests from an auth document fan out to gstatic and other non-auth hosts. Preserve the
|
||||
// Firefox identity already placed on those requests instead of switching them to Chrome.
|
||||
requestUserAgent === firefoxUa
|
||||
? firefoxUa
|
||||
: resolveRequestUserAgent
|
||||
? (resolveRequestUserAgent({
|
||||
url: details.url,
|
||||
webContentsId: details.webContentsId,
|
||||
currentUserAgent: requestUserAgent,
|
||||
effectiveUserAgent,
|
||||
baseUserAgent
|
||||
}) ??
|
||||
(baseUserAgent || requestUserAgent))
|
||||
: effectiveUserAgent === firefoxUa
|
||||
? firefoxUa
|
||||
: baseUserAgent || requestUserAgent
|
||||
if (userAgent) {
|
||||
setUserAgentHeader(headers, userAgent)
|
||||
}
|
||||
if (userAgent === firefoxUa) {
|
||||
// Why: while the auth document is on screen the WebContents UA is Firefox,
|
||||
// so its cross-host subresource/XHR requests (gstatic, play.google.com, the
|
||||
// sign-in challenge endpoints) reach here carrying the Firefox UA yet still
|
||||
|
||||
Reference in New Issue
Block a user