diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 131adcca66b..e67c5470be8 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -74,6 +74,7 @@ import type { BrowserSessionProfileSource } from '../../shared/types' import { browserSessionRegistry } from './browser-session-registry' +import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' import { setupClientHintsOverride } from './browser-session-ua' import { isGoogleSourceBoundCookie, @@ -1876,7 +1877,9 @@ export async function importCookiesFromBrowser( const ua = getUserAgentForBrowser(browser.family) if (ua) { targetSession.setUserAgent(ua) - setupClientHintsOverride(targetSession, ua) + setupClientHintsOverride(targetSession, ua, { + googleAuthOverride: getBrowserSessionUserAgentMode(targetSession) !== 'native' + }) browserSessionRegistry.persistUserAgent(targetPartition, ua) diag(` set UA for partition: ${ua.substring(0, 80)}...`) } diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 1032fd2b68f..2119c1fe11c 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -56,6 +56,7 @@ vi.mock('./popup-origin-bar-window', () => ({ import { browserManager } from './browser-manager' import { googleAuthUserAgent } from './browser-google-auth-ua' +import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode' describe('browserManager', () => { const rendererWebContentsId = 5001 @@ -1910,6 +1911,34 @@ describe('browserManager', () => { expect(setUserAgent).not.toHaveBeenCalled() }) + it('honors native session mode before the guest registration IPC arrives', () => { + const nativeSession = { getUserAgent: vi.fn(() => guestBaseUserAgent) } + setBrowserSessionUserAgentMode(nativeSession as never, 'native') + const setUserAgent = vi.fn() + const guest = { + id: 417, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://accounts.google.com/'), + getUserAgent: vi.fn(() => guestBaseUserAgent), + setUserAgent, + session: nativeSession + } + + browserManager.attachGuestPolicies(guest as never) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true) + expect(setUserAgent).not.toHaveBeenCalled() + }) + // Why: popup child windows get attachGuestPolicies but are never entered into tabIdByWebContentsId, // so a direct lookup of the UA mode misses the native opt-out. That is worse than doing nothing — // native sessions skip setupClientHintsOverride, so the popup would send the raw Electron UA on the @@ -3332,10 +3361,10 @@ describe('browserManager', () => { didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/', true) await flushViewportOps() - // A mobile preset discriminates cleanly here: the auth branch would emit the Firefox UA, the - // non-auth branch an iPhone UA. Asserting the desktop branch instead would conflate this with - // applyGoogleAuthUserAgent leaving the WebContents UA on Firefox after an aborted auth nav, - // which is pre-existing behavior and out of scope for the ordering fix. + expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + + // A later preset must also resolve the committed, non-auth URL. debuggerSendCommand.mockClear() await browserManager.setViewportOverride('tab-pending-cleared', { width: 375, @@ -3365,6 +3394,198 @@ describe('browserManager', () => { }) }) + it('does not let a superseded navigation failure revert a newer target', async () => { + const { guest, debuggerSendCommand } = makeGuest(4255, 'https://example.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-overlapping-navs', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const willRedirect = guestOnMock.mock.calls.find( + ([event]) => event === 'will-redirect' + )?.[1] as ( + event: { preventDefault: () => void }, + url: string, + isInPlace: boolean, + isMainFrame: boolean + ) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => void + + await browserManager.setViewportOverride('tab-overlapping-navs', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + didStartNavigation(null, 'https://example.com/start', false, true) + willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/same', false, true) + didStartNavigation(null, 'https://accounts.google.com/same', false, true) + await flushViewportOps() + + debuggerSendCommand.mockClear() + didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/same', true) + await flushViewportOps() + + expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent()) + expect(debuggerSendCommand).not.toHaveBeenCalledWith( + 'Emulation.setUserAgentOverride', + expect.objectContaining({ userAgent: GUEST_CLEAN_UA }) + ) + }) + + it('switches identity for a server redirect and restores it if the redirect fails', async () => { + const { guest, debuggerSendCommand } = makeGuest(4256, 'https://example.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-auth-redirect', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const willRedirect = guestOnMock.mock.calls.find( + ([event]) => event === 'will-redirect' + )?.[1] as ( + event: { preventDefault: () => void }, + url: string, + isInPlace: boolean, + isMainFrame: boolean + ) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => void + + await browserManager.setViewportOverride('tab-auth-redirect', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + didStartNavigation(null, 'https://example.com/start', false, true) + willRedirect( + { preventDefault: vi.fn() }, + 'https://accounts.google.com/redirected', + false, + true + ) + await flushViewportOps() + + expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent()) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) + + didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/redirected', true) + await flushViewportOps() + expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA) + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA }) + }) + + it('reapplies a preset when navigation starts during its final UA write', async () => { + const { guest, debuggerSendCommand } = makeGuest(4257, 'https://example.com/') + let releaseFirstUa = (): void => {} + const firstUaGate = new Promise((resolve) => { + releaseFirstUa = resolve + }) + let uaWrites = 0 + debuggerSendCommand.mockImplementation((method: string) => { + if (method === 'Emulation.setUserAgentOverride' && uaWrites++ === 0) { + return firstUaGate + } + return Promise.resolve(undefined) + }) + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-final-apply-race', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + const presetDone = browserManager.setViewportOverride('tab-final-apply-race', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + await flushViewportOps() + didStartNavigation(null, 'https://accounts.google.com/', false, true) + await flushViewportOps() + + expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ + userAgent: googleAuthUserAgent() + }) + releaseFirstUa() + await presetDone + }) + + it('does not reinstall a preset while its final UA clear is in flight', async () => { + const { guest, debuggerSendCommand } = makeGuest(4258, 'https://example.com/') + webContentsFromIdMock.mockReturnValue(guest) + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'tab-final-clear-race', + webContentsId: guest.id as number, + rendererWebContentsId + }) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + await browserManager.setViewportOverride('tab-final-clear-race', { + width: 1024, + height: 768, + deviceScaleFactor: 1, + mobile: false + }) + let releaseClearUa = (): void => {} + const clearUaGate = new Promise((resolve) => { + releaseClearUa = resolve + }) + debuggerSendCommand.mockImplementation( + (method: string, params: { userAgent?: string } | undefined) => + method === 'Emulation.setUserAgentOverride' && params?.userAgent === '' + ? clearUaGate + : Promise.resolve(undefined) + ) + const clearDone = browserManager.setViewportOverride('tab-final-clear-race', null) + await flushViewportOps() + + debuggerSendCommand.mockClear() + didStartNavigation(null, 'https://accounts.google.com/', false, true) + await flushViewportOps() + expect(debuggerSendCommand).not.toHaveBeenCalledWith('Emulation.setUserAgentOverride', { + userAgent: googleAuthUserAgent() + }) + + releaseClearUa() + await clearDone + }) + // Why: a failed clear leaves the CDP override standing on the target. Dropping the tracking entry // first makes it untracked, so the navigation path can never correct it again and the tab carries // a Chrome-shaped navigator.userAgent onto the auth hosts — the exact failure this PR prevents. @@ -3389,11 +3610,12 @@ describe('browserManager', () => { }) await flushViewportOps() - // The clear fails partway — e.g. DevTools took the debugger, or the guest is tearing down. - debuggerSendCommand.mockImplementation((method: string) => - method === 'Emulation.clearDeviceMetricsOverride' - ? Promise.reject(new Error('debugger detached')) - : Promise.resolve(undefined) + // The final UA clear fails after tracking was optimistically removed. + debuggerSendCommand.mockImplementation( + (method: string, params: { userAgent?: string } | undefined) => + method === 'Emulation.setUserAgentOverride' && params?.userAgent === '' + ? Promise.reject(new Error('debugger detached')) + : Promise.resolve(undefined) ) await expect(browserManager.setViewportOverride('tab-failed-clear', null)).resolves.toBe( false diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 11b767588a5..fbcac378412 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -43,6 +43,7 @@ import { buildBrowserIframeClickedLinkRoutingScript } from './browser-clicked-link-routing' 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' import type { @@ -150,6 +151,10 @@ type PopupOwnerContext = { browserTabId: string rootGuestWebContentsId: number } +type PendingMainFrameNavigation = { + currentUrl: string + supersededUrls: string[] +} const SAFE_POPUP_WINDOW_OPTIONS = { alwaysOnTop: false, closable: true, @@ -223,12 +228,12 @@ export class BrowserManager { private readonly rendererWebContentsIdByTabId = new Map() // Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state. private readonly viewportOpsByTabId = new Map>() - // Why: presence means a CDP UA override is standing for the tab (value = the preset's mobile flag), - // so navigation can re-issue it against the target URL's identity. + // Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation + // can re-issue it against the target URL's identity. private readonly viewportUaOverrideMobileByTabId = new Map() // Why: the in-flight main-frame navigation target, held only until commit or failure — getURL() // still reports the outgoing page until then. See resolveTabNavigationUrl. - private readonly pendingNavigationUrlByTabId = new Map() + private readonly pendingNavigationByGuestId = new Map() private readonly contextMenuCleanupByTabId = new Map void>() private readonly grabShortcutCleanupByTabId = new Map void>() private readonly shortcutForwardingCleanupByTabId = new Map void>() @@ -779,20 +784,35 @@ export class BrowserManager { return { action: 'deny' } }) - const navigationGuard = (event: Electron.Event, url: string): void => { + const navigationGuard = (event: Electron.Event, url: string): boolean => { // Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones. if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) { - return + return true } // Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS. if (url.startsWith('file:')) { event.preventDefault() - return + return false } if (!normalizeBrowserNavigationUrl(url)) { // Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs. event.preventDefault() + return false } + return true + } + + const willRedirectHandler = ( + event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + this.updatePendingNavigationForRedirect(guest.id, url) + this.applyGoogleAuthUserAgent(guest, url) } const didFailLoadHandler = ( @@ -806,7 +826,11 @@ export class BrowserManager { return } // Why: a nav that never committed must not leave its target standing as the tab's host. - this.clearPendingNavigationUrl(guest.id) + const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL) + if (failedNavigationWasCurrent) { + // The attempted host never committed, so restore every UA layer to the document that remains. + this.applyGoogleAuthUserAgent(guest, guest.getURL()) + } const browserPageId = this.tabIdByWebContentsId.get(guest.id) const certificateFailure = browserPageId ? this.certificateTrustController?.getFailure(browserPageId) @@ -850,12 +874,9 @@ export class BrowserManager { if (!isMainFrame || isChromiumInternalErrorUrl(url)) { return } - const navigatingTabId = this.tabIdByWebContentsId.get(guest.id) - if (navigatingTabId) { - // Why: getURL() still reports the previous committed URL until this navigation commits, so - // every UA writer must read the in-flight target or they disagree about the tab's host. - this.pendingNavigationUrlByTabId.set(navigatingTabId, url) - } + // Why: getURL() still reports the previous committed URL until this navigation commits, so + // every UA writer must read the in-flight target or they disagree about the tab's host. + this.startPendingNavigation(guest.id, url) this.applyGoogleAuthUserAgent(guest, url) this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) // Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it. @@ -873,14 +894,14 @@ export class BrowserManager { const didNavigateHandler = (_event: Electron.Event, url: string): void => { // Why: once committed, getURL() reports this url, so the pending target is redundant. - this.clearPendingNavigationUrl(guest.id) + this.pendingNavigationByGuestId.delete(guest.id) // Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it. this.clearedLoadErrorsByGuestId.delete(guest.id) this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) } guest.on('will-navigate', navigationGuard) - guest.on('will-redirect', navigationGuard) + guest.on('will-redirect', willRedirectHandler) guest.on('did-start-navigation', didStartNavigationHandler) guest.on('did-navigate', didNavigateHandler) guest.on('did-fail-load', didFailLoadHandler) @@ -914,7 +935,7 @@ export class BrowserManager { } if (!guest.isDestroyed()) { guest.off('will-navigate', navigationGuard) - guest.off('will-redirect', navigationGuard) + guest.off('will-redirect', willRedirectHandler) guest.off('did-start-navigation', didStartNavigationHandler) guest.off('did-navigate', didNavigateHandler) guest.off('did-fail-load', didFailLoadHandler) @@ -934,7 +955,10 @@ export class BrowserManager { // That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so // the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox. const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) - const mode = ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined + // Session state is authoritative before renderer registration and after a native profile imports a source UA. + const mode = + getBrowserSessionUserAgentMode(guest.session) ?? + (ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined) if (mode === 'native') { return } @@ -956,18 +980,48 @@ export class BrowserManager { } } - private clearPendingNavigationUrl(guestId: number): void { - const browserTabId = this.tabIdByWebContentsId.get(guestId) - if (browserTabId) { - this.pendingNavigationUrlByTabId.delete(browserTabId) + private startPendingNavigation(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : [] + }) + } + + private updatePendingNavigationForRedirect(guestId: number, url: string): void { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + this.pendingNavigationByGuestId.set(guestId, { + currentUrl: url, + supersededUrls: [] + }) + return } + pending.currentUrl = url + } + + private failPendingNavigation(guestId: number, failedUrl: string): boolean { + const pending = this.pendingNavigationByGuestId.get(guestId) + if (!pending) { + return false + } + const supersededIndex = pending.supersededUrls.indexOf(failedUrl) + if (supersededIndex !== -1) { + pending.supersededUrls.splice(supersededIndex, 1) + return false + } + if (pending.currentUrl !== failedUrl) { + return false + } + this.pendingNavigationByGuestId.delete(guestId) + return true } // Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host // the tab is leaving, not the one it is entering. Every UA writer must resolve the host through // here or two writers racing the same navigation will pick opposite identities. - private resolveTabNavigationUrl(guest: Electron.WebContents, browserTabId: string): string { - return this.pendingNavigationUrlByTabId.get(browserTabId) ?? guest.getURL() + private resolveTabNavigationUrl(guest: Electron.WebContents): string { + return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL() } // Why: Emulation.setUserAgentOverride is set once and stands across every later navigation, @@ -986,12 +1040,11 @@ export class BrowserManager { // Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the // later-issued write wins. What matters is that both writers resolve the SAME host, which they // now do via the navigation target rather than the stale committed URL. - void this.sendViewportUserAgentOverride(guest, browserTabId, mobile, url).catch(() => {}) + void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {}) } private async sendViewportUserAgentOverride( guest: Electron.WebContents, - browserTabId: string, mobile: boolean, url?: string ): Promise { @@ -1001,7 +1054,7 @@ export class BrowserManager { await guest.debugger.sendCommand( 'Emulation.setUserAgentOverride', buildViewportUserAgentOverride({ - url: url ?? this.resolveTabNavigationUrl(guest, browserTabId), + url: url ?? this.resolveTabNavigationUrl(guest), mobile, baseUserAgent: cleanElectronUserAgent(guest.getUserAgent()) }) @@ -1056,6 +1109,7 @@ export class BrowserManager { this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) this.offscreenGuestIds.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) + this.pendingNavigationByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child. if (isPrimaryGuest) { for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) { @@ -1190,7 +1244,9 @@ export class BrowserManager { // Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest. this.viewportOpsByTabId.delete(browserTabId) this.viewportUaOverrideMobileByTabId.delete(browserTabId) - this.pendingNavigationUrlByTabId.delete(browserTabId) + if (wcId !== undefined) { + this.pendingNavigationByGuestId.delete(wcId) + } this.annotationViewportBridgeOpsByTabId.delete(browserTabId) } @@ -1257,7 +1313,7 @@ export class BrowserManager { this.sessionProfileIdByPageId.clear() this.userAgentModeByPageId.clear() this.viewportUaOverrideMobileByTabId.clear() - this.pendingNavigationUrlByTabId.clear() + this.pendingNavigationByGuestId.clear() this.pendingLoadFailuresByGuestId.clear() this.loadErrorsByGuestId.clear() this.clearedLoadErrorsByGuestId.clear() @@ -1651,11 +1707,10 @@ export class BrowserManager { }) // Why: viewport sizing must not override a profile's explicit native-UA identity. if (this.userAgentModeByPageId.get(browserTabId) !== 'native') { - // Why: same sender as the navigation path, so both resolve the tab's host identically. - await this.sendViewportUserAgentOverride(guest, browserTabId, override.mobile) - // Why: only claim a standing override once the CDP write actually landed — recording it - // first would leave the map asserting an override that was never installed. + // Navigation must see the preset intent while the final CDP command is in flight. this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile) + // Why: same sender as the navigation path, so both resolve the tab's host identically. + await this.sendViewportUserAgentOverride(guest, override.mobile) } } else { await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {}) @@ -1663,11 +1718,18 @@ export class BrowserManager { enabled: false, maxTouchPoints: 0 }) - // Why: passing an empty string restores the session default UA. - await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) - // Why: drop tracking only after the clear landed. Deleting first strands a still-standing - // CDP UA override with no map entry, so the navigation path can never correct it again. + const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId) + // A navigation after this point must not re-install the override behind the clear. this.viewportUaOverrideMobileByTabId.delete(browserTabId) + // Why: passing an empty string restores the session default UA. + try { + await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' }) + } catch (error) { + if (trackedMobile !== undefined) { + this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile) + } + throw error + } } return true } catch { diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 76ddaf4fee8..f7792903bac 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -230,8 +230,10 @@ describe('BrowserSessionRegistry persistence', () => { browserSessionRegistry.createProfile('isolated', 'Google', { userAgentMode: 'native' }) const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value + const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') expect(profileSession.setUserAgent).not.toHaveBeenCalled() expect(setupClientHintsOverrideMock).not.toHaveBeenCalled() + expect(getBrowserSessionUserAgentMode(profileSession as never)).toBe('native') }) it('merges partition-keyed pending entries without clobbering unrelated entries', async () => { @@ -391,7 +393,14 @@ describe('BrowserSessionRegistry persistence', () => { setupClientHintsOverrideMock.mock.calls.some( (c: unknown[]) => (c[0] as { partition?: string } | undefined)?.partition === importedPartition && - c[1] === importedUa + c[1] === importedUa && + (c[2] as { googleAuthOverride?: boolean } | undefined)?.googleAuthOverride === false + ) + ).toBe(true) + const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') + expect( + importedSessions.every( + (session) => getBrowserSessionUserAgentMode(session as never) === 'native' ) ).toBe(true) }) @@ -432,6 +441,12 @@ describe('BrowserSessionRegistry persistence', () => { ([sess]) => (sess as { partition?: string }).partition === importedPartition ) ).toBe(false) + const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode') + expect( + importedSessions.every( + (session) => getBrowserSessionUserAgentMode(session as never) === 'native' + ) + ).toBe(true) }) it('sets up default-partition policies on restore', async () => { diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 175f80dec56..44e1b6b8e92 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -457,6 +457,27 @@ describe('BrowserSessionRegistry', () => { expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome') }) + it('keeps an imported native UA on auth hosts while aligning its Chrome hints', () => { + const onBeforeSendHeaders = vi.fn() + const mockSess = { webRequest: { onBeforeSendHeaders } } as never + const importedUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36' + setupClientHintsOverride(mockSess, importedUa, { googleAuthOverride: false }) + + const callback = vi.fn() + const listener = onBeforeSendHeaders.mock.calls[0][1] + listener( + { + url: 'https://accounts.google.com/v3/signin/identifier', + requestHeaders: { 'User-Agent': importedUa, 'sec-ch-ua': 'old' } + }, + callback + ) + const modified = callback.mock.calls[0][0].requestHeaders + expect(modified['User-Agent']).toBe(importedUa) + expect(modified['sec-ch-ua']).toContain('Google Chrome') + }) + it('leaves non-Client-Hints headers unchanged', () => { const onBeforeSendHeaders = vi.fn() const mockSess = { webRequest: { onBeforeSendHeaders } } as never diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index e30c3a0230d..d6188bf88b7 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -26,6 +26,10 @@ import type { import { browserManager } from './browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' +import { + clearBrowserSessionUserAgentMode, + setBrowserSessionUserAgentMode +} from './browser-session-user-agent-mode' import { resolveChromiumCookiesPath } from './chromium-cookie-path' import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy' import { @@ -190,10 +194,14 @@ class BrowserSessionRegistry { const partition = profile.partition try { const sess = session.fromPartition(partition) + const userAgentMode = profile.userAgentMode ?? 'clean' + setBrowserSessionUserAgentMode(sess, userAgentMode) const persistedUa = meta.userAgentByPartition[partition] if (persistedUa) { sess.setUserAgent(persistedUa) - setupClientHintsOverride(sess, persistedUa) + setupClientHintsOverride(sess, persistedUa, { + googleAuthOverride: userAgentMode !== 'native' + }) continue } @@ -435,6 +443,7 @@ class BrowserSessionRegistry { // Why: clear the partition's storage so deleting a profile doesn't leave orphaned cookies/cache behind. try { const sess = session.fromPartition(profile.partition) + clearBrowserSessionUserAgentMode(sess) this.clearSessionPolicies(profile.partition, sess) await sess.clearStorageData() await sess.clearCache() @@ -523,11 +532,12 @@ class BrowserSessionRegistry { private setupSessionPolicies(profile: BrowserSessionProfile): void { const { partition } = profile + const sess = session.fromPartition(partition) + setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean') if (this.configuredPartitions.has(partition)) { return } - const sess = session.fromPartition(partition) browserManager.installCertificateRequestGuard(sess) if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') { const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) diff --git a/src/main/browser/browser-session-ua.ts b/src/main/browser/browser-session-ua.ts index e14f7e541db..081e1a5deba 100644 --- a/src/main/browser/browser-session-ua.ts +++ b/src/main/browser/browser-session-ua.ts @@ -26,7 +26,11 @@ export function cleanElectronUserAgent(ua: string): string { // reveal the real version, creating a mismatch that Google's anti-fraud // detection flags as CookieMismatch on accounts.google.com. Override Client // Hints on outgoing requests to match the source browser's UA. -export function setupClientHintsOverride(sess: Session, ua: string): void { +export function setupClientHintsOverride( + sess: Session, + ua: string, + options: { googleAuthOverride?: boolean } = {} +): void { // Why: only Chrome-shaped base UAs carry sec-ch-ua hints to rewrite, but the // Google-auth Firefox switch below must install regardless, so keep the hints // optional rather than bailing out of the whole handler. @@ -35,7 +39,7 @@ export function setupClientHintsOverride(sess: Session, ua: string): void { sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => { const headers = details.requestHeaders - if (isGoogleAuthUrl(details.url)) { + if (options.googleAuthOverride !== false && 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 // sec-ch-ua* because real Firefox sends none. diff --git a/src/main/browser/browser-session-user-agent-mode.ts b/src/main/browser/browser-session-user-agent-mode.ts new file mode 100644 index 00000000000..8fa8f71d1a9 --- /dev/null +++ b/src/main/browser/browser-session-user-agent-mode.ts @@ -0,0 +1,22 @@ +import type { Session } from 'electron' + +import type { BrowserSessionUserAgentMode } from '../../shared/types' + +const userAgentModeBySession = new WeakMap() + +export function setBrowserSessionUserAgentMode( + session: Session, + mode: BrowserSessionUserAgentMode +): void { + userAgentModeBySession.set(session, mode) +} + +export function getBrowserSessionUserAgentMode( + session: Session +): BrowserSessionUserAgentMode | undefined { + return userAgentModeBySession.get(session) +} + +export function clearBrowserSessionUserAgentMode(session: Session): void { + userAgentModeBySession.delete(session) +}