From 384e091a34d8b8f460dd55824ea1396df90279c8 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:39:28 -0700 Subject: [PATCH] fix: host browser popups in an Orca origin-bar window instead of chrome-less child windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guest-opened popup previously became a default Electron child window with no address bar, so users could not verify a popup's origin — a phishing surface flagged in the prod-release scan of #7392. The reverted #8332 tried gating on disposition, which is bypassable and breaks featureless window.open() OAuth flows. Instead, keep hosting popups in-app (preserving the shared session partition and live window.opener handle OAuth depends on) but build the child window ourselves via setWindowOpenHandler's createWindow callback: a BaseWindow with an Orca-controlled origin-bar WebContentsView on top and a content WebContentsView that adopts the pre-created popup WebContents. The bar shows only the destination origin (never path or query), updates on navigation, and flags plain http to remote hosts. Also pins secure webPreferences (contextIsolation, no nodeIntegration, sandbox, no webviewTag) on popup children via SAFE_POPUP_WINDOW_OPTIONS, attaches guest policies to popup contents directly (did-create-window does not fire for createWindow children), emits the existing opened-in-orca renderer notice, and closes popups with their opener guest. Co-authored-by: Orca --- src/main/browser/browser-manager.test.ts | 203 +++++++++++-- src/main/browser/browser-manager.ts | 52 +++- .../browser/popup-origin-bar-window.test.ts | 276 ++++++++++++++++++ src/main/browser/popup-origin-bar-window.ts | 202 +++++++++++++ 4 files changed, 707 insertions(+), 26 deletions(-) create mode 100644 src/main/browser/popup-origin-bar-window.test.ts create mode 100644 src/main/browser/popup-origin-bar-window.ts diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index c854d1f0acb..29d2c8f8edd 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { + openPopupWithOriginBarMock, appGetPathMock, shellOpenExternalMock, browserWindowFromWebContentsMock, @@ -25,7 +26,8 @@ const { guestSetWindowOpenHandlerMock: vi.fn(), guestOpenDevToolsMock: vi.fn(), webContentsFromIdMock: vi.fn(), - screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })) + screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })), + openPopupWithOriginBarMock: vi.fn() })) vi.mock('electron', () => ({ @@ -48,6 +50,10 @@ vi.mock('electron', () => ({ } })) +vi.mock('./popup-origin-bar-window', () => ({ + openPopupWithOriginBar: openPopupWithOriginBarMock +})) + import { browserManager } from './browser-manager' describe('browserManager', () => { @@ -96,6 +102,7 @@ describe('browserManager', () => { guestSetWindowOpenHandlerMock.mockReset() guestOpenDevToolsMock.mockReset() webContentsFromIdMock.mockReset() + openPopupWithOriginBarMock.mockReset() browserManager.unregisterAll() browserManager.setDictationShortcutForwardingPredicate(null) browserManager.setSettingsResolver(() => ({})) @@ -162,37 +169,185 @@ describe('browserManager', () => { const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { url: string features?: string - }) => { action: 'allow' | 'deny' } + }) => { + action: 'allow' | 'deny' + overrideBrowserWindowOptions?: unknown + createWindow?: unknown + } expect(handler({ url: 'about:blank' })).toMatchObject({ action: 'allow' }) - expect( - handler({ - url: 'https://example.com/login', - features: 'alwaysOnTop=yes,frame=no,fullscreen=yes,kiosk=yes,modal=yes,transparent=yes' - }) - ).toEqual({ - action: 'allow', - overrideBrowserWindowOptions: { - alwaysOnTop: false, - closable: true, - focusable: true, - frame: true, - fullscreen: false, - kiosk: false, - modal: false, - movable: true, - opacity: 1, - show: true, - simpleFullscreen: false, - skipTaskbar: false, - titleBarStyle: 'default', - transparent: false + const response = handler({ + url: 'https://example.com/login', + features: 'alwaysOnTop=yes,frame=no,fullscreen=yes,kiosk=yes,modal=yes,transparent=yes' + }) + expect(response.action).toBe('allow') + expect(response.overrideBrowserWindowOptions).toEqual({ + alwaysOnTop: false, + closable: true, + focusable: true, + frame: true, + fullscreen: false, + kiosk: false, + modal: false, + movable: true, + opacity: 1, + show: true, + simpleFullscreen: false, + skipTaskbar: false, + titleBarStyle: 'default', + transparent: false, + webPreferences: { + allowRunningInsecureContent: false, + contextIsolation: true, + nodeIntegration: false, + nodeIntegrationInSubFrames: false, + sandbox: true, + webviewTag: false } }) + // Why: the custom createWindow is what swaps the chrome-less native child + // for Orca's origin-bar window without losing the popup contents. + expect(typeof response.createWindow).toBe('function') expect(shellOpenExternalMock).not.toHaveBeenCalled() expect(rendererSendMock).not.toHaveBeenCalled() }) + it('keeps featureless window.open popups in-app for every disposition', () => { + // Regression guard for the reverted #8332: gating the allow on + // disposition === 'new-window' silently broke featureless window.open() + // OAuth flows (disposition 'foreground-tab'), whose returned handle must + // stay live. Disposition is a UX hint, not a trust signal. + const guest = { + id: 140, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + frameName: string + features: string + disposition: string + }) => { action: 'allow' | 'deny' } + for (const disposition of ['foreground-tab', 'background-tab', 'new-window']) { + expect( + handler({ url: 'https://sso.example.com/auth', frameName: '', features: '', disposition }) + ).toMatchObject({ action: 'allow' }) + } + expect(shellOpenExternalMock).not.toHaveBeenCalled() + }) + + it('hosts allowed popups in an origin-bar window with inherited guest policies', () => { + const rendererSendMock = vi.fn() + const guestOnceMock = vi.fn() + const guest = { + id: 150, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + once: guestOnceMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const popupContents = { + id: 151, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'window'), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: vi.fn(), + on: vi.fn(), + once: vi.fn(), + off: vi.fn() + } + const popupCloseMock = vi.fn() + const popupOnClosedMock = vi.fn() + openPopupWithOriginBarMock.mockReturnValue({ + contentWebContents: popupContents, + close: popupCloseMock, + onClosed: popupOnClosedMock + }) + + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + }) => { + action: string + createWindow: (options: Record) => unknown + } + const response = handler({ url: 'https://sso.example.com/auth?code=SECRET' }) + const preCreatedContents = { id: 152 } + const options = { webContents: preCreatedContents, width: 500, height: 600 } + const returned = response.createWindow(options) + + expect(openPopupWithOriginBarMock).toHaveBeenCalledWith( + options, + 'https://sso.example.com/auth?code=SECRET' + ) + expect(returned).toBe(popupContents) + // did-create-window does not fire for createWindow-created children, so + // the popup must get guest policies (nav guards, recursive popup handling) + // attached directly here. + expect(popupContents.setWindowOpenHandler).toHaveBeenCalledTimes(1) + expect(popupContents.setBackgroundThrottling).toHaveBeenCalledWith(false) + // The renderer notice carries only the sanitized origin, never the URL. + expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { + browserPageId: 'browser-1', + origin: 'https://sso.example.com', + action: 'opened-in-orca' + }) + + // Opener-lifecycle parity: destroying the owning guest closes the popup. + const destroyedCall = guestOnceMock.mock.calls.find(([event]) => event === 'destroyed') + expect(destroyedCall).toBeDefined() + ;(destroyedCall as [string, () => void])[1]() + expect(popupCloseMock).toHaveBeenCalledTimes(1) + + // Popup windows opened by the popup itself keep the owner context, so the + // recursive handler still routes to the owning browser tab. + const popupHandler = popupContents.setWindowOpenHandler.mock.calls[0][0] as (details: { + url: string + }) => { action: string } + openPopupWithOriginBarMock.mockReturnValue({ + contentWebContents: { ...popupContents, id: 153 }, + close: vi.fn(), + onClosed: vi.fn() + }) + expect(popupHandler({ url: 'https://sso.example.com/step2' })).toMatchObject({ + action: 'allow' + }) + }) + it('blocks unsafe popup URLs for registered guests', () => { const rendererSendMock = vi.fn() const guest = { diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 18cddd8c78c..acd404f91ee 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -38,6 +38,7 @@ import { setupGuestShortcutForwarding } from './browser-guest-ui' import { ANTI_DETECTION_SCRIPT } from './anti-detection' +import { openPopupWithOriginBar, type PopupChildWindowOptions } from './popup-origin-bar-window' import { cleanElectronUserAgent } from './browser-session-ua' import type { BrowserViewportOverride } from '../../shared/types' import { @@ -160,7 +161,18 @@ const SAFE_POPUP_WINDOW_OPTIONS = { simpleFullscreen: false, skipTaskbar: false, titleBarStyle: 'default', - transparent: false + transparent: false, + // Why: applied by Electron when it creates the popup's WebContents, before + // createWindow runs. Feature strings and opener inheritance must not be able + // to relax the child's process isolation. + webPreferences: { + allowRunningInsecureContent: false, + contextIsolation: true, + nodeIntegration: false, + nodeIntegrationInSubFrames: false, + sandbox: true, + webviewTag: false + } } satisfies Electron.BrowserWindowConstructorOptions type ActiveDownload = { @@ -637,7 +649,15 @@ export class BrowserManager { if (browserTabId && canOpenAsChild) { // Why: OAuth may request ordinary size/position features, but browser // content must not create deceptive or inescapable native chrome. - return { action: 'allow', overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS } + return { + action: 'allow', + overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS, + // Why: a default child window has no address bar, so users cannot + // verify a popup's destination. Host it in an Orca window with an + // origin bar while keeping the shared session + window.opener. + createWindow: (options: PopupChildWindowOptions) => + this.createPopupChildWindowWithOriginBar(guest, url, options) + } } else if (externalUrl) { // Why: a target=_blank click on a Kagi search result page produces a // popup URL that still contains the bearer token; redact before @@ -730,6 +750,34 @@ export class BrowserManager { }) } + private createPopupChildWindowWithOriginBar( + openerGuest: Electron.WebContents, + targetUrl: string, + options: PopupChildWindowOptions + ): Electron.WebContents { + const popup = openPopupWithOriginBar(options, targetUrl) + // Why: Electron does not emit did-create-window for createWindow-created + // children, so the opener's policies and routing context attach here. + this.attachGuestPolicies( + popup.contentWebContents, + this.resolvePopupOwnerContext(openerGuest.id) + ) + this.forwardOrQueuePopupEvent(openerGuest.id, { + origin: safeOrigin(targetUrl), + action: 'opened-in-orca' + }) + // Why: parity with Electron's default child-window lifecycle — closing the + // owning browser tab must not leave orphaned session-bearing popups. + const closePopupWithOpener = (): void => popup.close() + openerGuest.once('destroyed', closePopupWithOpener) + popup.onClosed(() => { + if (!openerGuest.isDestroyed()) { + openerGuest.off('destroyed', closePopupWithOpener) + } + }) + return popup.contentWebContents + } + private retireStaleGuestWebContents(previousWebContentsId: number): void { // Why: a browser page can re-register with a new guest id after Chromium // swaps renderer processes. Late events from the dead guest must stop diff --git a/src/main/browser/popup-origin-bar-window.test.ts b/src/main/browser/popup-origin-bar-window.test.ts new file mode 100644 index 00000000000..1d8f791c7b9 --- /dev/null +++ b/src/main/browser/popup-origin-bar-window.test.ts @@ -0,0 +1,276 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type Handler = (...args: unknown[]) => void + +type FakeWebContents = { + on: ReturnType + once: ReturnType + off: ReturnType + executeJavaScript: ReturnType + loadURL: ReturnType + close: ReturnType + isDestroyed: ReturnType + emit: (event: string, ...args: unknown[]) => void +} + +const { fakeElectron } = vi.hoisted(() => { + function createFakeWebContents(): FakeWebContents { + const handlers = new Map() + const add = (event: string, handler: Handler): void => { + handlers.set(event, [...(handlers.get(event) ?? []), handler]) + } + return { + on: vi.fn(add), + once: vi.fn(add), + off: vi.fn((event: string, handler: Handler) => { + handlers.set( + event, + (handlers.get(event) ?? []).filter((h) => h !== handler) + ) + }), + executeJavaScript: vi.fn(() => Promise.resolve()), + loadURL: vi.fn(() => Promise.resolve()), + close: vi.fn(), + isDestroyed: vi.fn(() => false), + emit: (event: string, ...args: unknown[]) => { + // off() replaces the stored array, so iterating the fetched one is safe. + for (const handler of handlers.get(event) ?? []) { + handler(...args) + } + } + } + } + + class FakeWebContentsView { + static instances: FakeWebContentsView[] = [] + options: { webContents?: FakeWebContents; webPreferences?: unknown } + webContents: FakeWebContents + setBounds = vi.fn() + constructor(options: { webContents?: FakeWebContents; webPreferences?: unknown }) { + this.options = options + this.webContents = options.webContents ?? createFakeWebContents() + FakeWebContentsView.instances.push(this) + } + } + + class FakeBaseWindow { + static instances: FakeBaseWindow[] = [] + options: Record + private handlers = new Map() + contentView = { addChildView: vi.fn() } + setTitle = vi.fn() + isDestroyed = vi.fn(() => false) + close = vi.fn(() => this.emit('closed')) + constructor(options: Record) { + this.options = options + FakeBaseWindow.instances.push(this) + } + on(event: string, handler: Handler): void { + this.handlers.set(event, [...(this.handlers.get(event) ?? []), handler]) + } + once(event: string, handler: Handler): void { + this.on(event, handler) + } + getContentBounds(): { x: number; y: number; width: number; height: number } { + return { + x: 0, + y: 0, + width: this.options.width as number, + height: this.options.height as number + } + } + emit(event: string, ...args: unknown[]): void { + for (const handler of this.handlers.get(event) ?? []) { + handler(...args) + } + } + } + + return { + fakeElectron: { createFakeWebContents, FakeWebContentsView, FakeBaseWindow } + } +}) + +vi.mock('electron', () => ({ + BaseWindow: fakeElectron.FakeBaseWindow, + WebContentsView: fakeElectron.FakeWebContentsView +})) + +import { + describePopupOrigin, + openPopupWithOriginBar, + POPUP_ORIGIN_BAR_HEIGHT +} from './popup-origin-bar-window' + +const { createFakeWebContents, FakeWebContentsView, FakeBaseWindow } = fakeElectron + +function lastWindow(): InstanceType { + const instance = FakeBaseWindow.instances.at(-1) + if (!instance) { + throw new Error('no BaseWindow was constructed') + } + return instance +} + +// View construction order in openPopupWithOriginBar: origin bar first, content second. +function lastViews(): { + bar: InstanceType + content: InstanceType +} { + const bar = FakeWebContentsView.instances.at(-2) + const content = FakeWebContentsView.instances.at(-1) + if (!bar || !content) { + throw new Error('expected an origin bar view and a content view') + } + return { bar, content } +} + +beforeEach(() => { + FakeBaseWindow.instances = [] + FakeWebContentsView.instances = [] +}) + +describe('describePopupOrigin', () => { + it('reduces URLs to origin only', () => { + expect(describePopupOrigin('https://accounts.example.com/oauth?code=SECRET')).toEqual({ + label: 'https://accounts.example.com', + insecure: false + }) + }) + + it('flags plain http to remote hosts as insecure', () => { + expect(describePopupOrigin('http://phish.example.net/login')).toEqual({ + label: 'http://phish.example.net', + insecure: true + }) + }) + + it('treats loopback http as secure', () => { + expect(describePopupOrigin('http://localhost:3000/callback').insecure).toBe(false) + expect(describePopupOrigin('http://127.0.0.1:8080/').insecure).toBe(false) + expect(describePopupOrigin('http://[::1]:8080/').insecure).toBe(false) + expect(describePopupOrigin('http://app.localhost/callback').insecure).toBe(false) + }) + + it('labels about:blank popups', () => { + expect(describePopupOrigin('about:blank')).toEqual({ label: 'about:blank', insecure: false }) + }) + + it('falls back to unknown for unparseable URLs', () => { + expect(describePopupOrigin('not a url')).toEqual({ label: 'unknown', insecure: true }) + }) +}) + +describe('openPopupWithOriginBar', () => { + it('adopts the pre-created popup contents so window.opener and the session survive', () => { + const adopted = createFakeWebContents() + const webPreferences = { partition: 'persist:browser' } + const popup = openPopupWithOriginBar( + { webContents: adopted as never, webPreferences }, + 'https://example.com/login' + ) + + const { content } = lastViews() + expect(content.options.webContents).toBe(adopted) + expect(content.options.webPreferences).toBe(webPreferences) + expect(popup.contentWebContents).toBe(adopted) + // Chromium already drives the adopted contents' navigation. + expect(adopted.loadURL).not.toHaveBeenCalled() + }) + + it('loads the target itself only when no pre-created contents were provided', () => { + const popup = openPopupWithOriginBar({}, 'https://example.com/login') + expect(popup.contentWebContents.loadURL).toHaveBeenCalledWith('https://example.com/login') + }) + + it('reserves an origin-bar strip above the requested content size', () => { + openPopupWithOriginBar( + { webContents: createFakeWebContents() as never, width: 500, height: 400 }, + 'https://example.com/' + ) + + expect(lastWindow().options).toMatchObject({ + width: 500, + height: 400 + POPUP_ORIGIN_BAR_HEIGHT + }) + const { bar, content } = lastViews() + expect(bar.setBounds).toHaveBeenCalledWith({ + x: 0, + y: 0, + width: 500, + height: POPUP_ORIGIN_BAR_HEIGHT + }) + expect(content.setBounds).toHaveBeenCalledWith({ + x: 0, + y: POPUP_ORIGIN_BAR_HEIGHT, + width: 500, + height: 400 + }) + }) + + it('keeps the origin bar isolated with locked-down webPreferences', () => { + openPopupWithOriginBar( + { webContents: createFakeWebContents() as never }, + 'https://example.com/' + ) + const { bar } = lastViews() + expect(bar.options.webPreferences).toEqual({ + contextIsolation: true, + nodeIntegration: false, + sandbox: true + }) + }) + + it('renders only the origin in the bar, never path or query', () => { + openPopupWithOriginBar( + { webContents: createFakeWebContents() as never }, + 'https://accounts.example.com/oauth?code=SECRET' + ) + const { bar } = lastViews() + bar.webContents.emit('did-finish-load') + + expect(bar.webContents.executeJavaScript).toHaveBeenCalledTimes(1) + const script = bar.webContents.executeJavaScript.mock.calls[0][0] as string + expect(script).toContain('"https://accounts.example.com"') + expect(script).not.toContain('SECRET') + expect(script).not.toContain('/oauth') + expect(lastWindow().options.title).toBe('https://accounts.example.com') + }) + + it('updates the origin bar and title when the popup navigates', () => { + const adopted = createFakeWebContents() + openPopupWithOriginBar({ webContents: adopted as never }, 'about:blank') + const { bar } = lastViews() + bar.webContents.emit('did-finish-load') + bar.webContents.executeJavaScript.mockClear() + + adopted.emit('did-navigate', {}, 'http://phish.example.net/login?token=SECRET') + + const script = bar.webContents.executeJavaScript.mock.calls[0][0] as string + expect(script).toContain("classList.toggle('insecure', true)") + expect(script).toContain('"http://phish.example.net"') + expect(script).not.toContain('SECRET') + expect(lastWindow().setTitle).toHaveBeenCalledWith('http://phish.example.net') + }) + + it('closes the window when the popup content is destroyed', () => { + const adopted = createFakeWebContents() + openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/') + + adopted.emit('destroyed') + + expect(lastWindow().close).toHaveBeenCalled() + }) + + it('closes the popup content and notifies listeners when the window closes', () => { + const adopted = createFakeWebContents() + const onClosed = vi.fn() + const popup = openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/') + popup.onClosed(onClosed) + + popup.close() + + expect(adopted.close).toHaveBeenCalledTimes(1) + expect(onClosed).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/browser/popup-origin-bar-window.ts b/src/main/browser/popup-origin-bar-window.ts new file mode 100644 index 00000000000..fb5873f63e1 --- /dev/null +++ b/src/main/browser/popup-origin-bar-window.ts @@ -0,0 +1,202 @@ +import { BaseWindow, WebContentsView } from 'electron' + +// Why: Electron passes the pre-created popup WebContents through the +// createWindow options (present at runtime, absent from the published d.ts). +// Adopting it — instead of constructing fresh contents — is what preserves +// window.opener and the inherited session that OAuth/SSO popups depend on. +export type PopupChildWindowOptions = Electron.BrowserWindowConstructorOptions & { + webContents?: Electron.WebContents +} + +export type PopupOriginBarWindow = { + contentWebContents: Electron.WebContents + close: () => void + onClosed: (listener: () => void) => void +} + +export const POPUP_ORIGIN_BAR_HEIGHT = 34 + +const DEFAULT_POPUP_CONTENT_WIDTH = 800 +const DEFAULT_POPUP_CONTENT_HEIGHT = 600 +const MIN_POPUP_CONTENT_WIDTH = 360 +const MIN_POPUP_CONTENT_HEIGHT = 200 + +// Why: http on loopback is a secure context (local OAuth callback servers are +// common); only flag plain http to a real remote host. +function isLoopbackHost(hostname: string): boolean { + return ( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]' + ) +} + +// Why: the bar must show only the origin — popup URLs routinely carry OAuth +// codes and one-time tokens in path/query that must never reach UI surfaces. +export function describePopupOrigin(rawUrl: string): { label: string; insecure: boolean } { + try { + const parsed = new URL(rawUrl) + if (parsed.origin !== 'null') { + return { + label: parsed.origin, + insecure: parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname) + } + } + if (parsed.protocol === 'about:') { + return { label: 'about:blank', insecure: false } + } + return { label: parsed.protocol, insecure: false } + } catch { + return { label: 'unknown', insecure: true } + } +} + +// Colors mirror the canonical tokens in src/renderer/src/assets/main.css +// (--background/--foreground/--border/--destructive); a data: URL page cannot +// import that stylesheet, so the values are inlined per theme here. +const ORIGIN_BAR_HTML = `Not secure` + +function clampPopupContentSize(options: PopupChildWindowOptions): { + width: number + height: number +} { + return { + width: Math.max(MIN_POPUP_CONTENT_WIDTH, options.width ?? DEFAULT_POPUP_CONTENT_WIDTH), + height: Math.max(MIN_POPUP_CONTENT_HEIGHT, options.height ?? DEFAULT_POPUP_CONTENT_HEIGHT) + } +} + +/** + * Hosts a guest-opened popup inside an Orca-built window whose top strip is a + * separate, Orca-controlled WebContentsView showing the popup's current + * origin. A default Electron child window has no address bar, so arbitrary + * web content could open windows whose destination the user cannot verify. + */ +export function openPopupWithOriginBar( + options: PopupChildWindowOptions, + initialUrl: string +): PopupOriginBarWindow { + const { width, height } = clampPopupContentSize(options) + const initialOrigin = describePopupOrigin(initialUrl) + const window = new BaseWindow({ + width, + height: height + POPUP_ORIGIN_BAR_HEIGHT, + // Why: window.open features request a content size; without this the + // native frame eats into the popup's viewport. + useContentSize: true, + ...(typeof options.x === 'number' && typeof options.y === 'number' + ? { x: options.x, y: options.y } + : {}), + minWidth: MIN_POPUP_CONTENT_WIDTH, + minHeight: MIN_POPUP_CONTENT_HEIGHT + POPUP_ORIGIN_BAR_HEIGHT, + title: initialOrigin.label + }) + + // Why: the origin bar renders only Orca's own data: URL and must stay + // isolated from the (arbitrary) popup content below it. + const originBarView = new WebContentsView({ + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true } + }) + const contentView = new WebContentsView({ + webContents: options.webContents, + webPreferences: options.webPreferences + }) + window.contentView.addChildView(contentView) + window.contentView.addChildView(originBarView) + + const layoutViews = (): void => { + const bounds = window.getContentBounds() + originBarView.setBounds({ x: 0, y: 0, width: bounds.width, height: POPUP_ORIGIN_BAR_HEIGHT }) + contentView.setBounds({ + x: 0, + y: POPUP_ORIGIN_BAR_HEIGHT, + width: bounds.width, + height: Math.max(0, bounds.height - POPUP_ORIGIN_BAR_HEIGHT) + }) + } + window.on('resize', layoutViews) + layoutViews() + + const contentWebContents = contentView.webContents + let currentUrl = initialUrl + const renderOrigin = (): void => { + const { label, insecure } = describePopupOrigin(currentUrl) + if (!window.isDestroyed()) { + window.setTitle(label) + } + // Why: textContent + JSON encoding — the URL is attacker-controlled and + // must never be interpolated into the bar's markup. + void originBarView.webContents + .executeJavaScript( + `document.body.classList.toggle('insecure', ${insecure ? 'true' : 'false'});` + + `document.getElementById('origin').textContent = ${JSON.stringify(label)};` + ) + .catch(() => {}) + } + originBarView.webContents.once('did-finish-load', renderOrigin) + void originBarView.webContents.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(ORIGIN_BAR_HTML)}` + ) + + const handleDidNavigate = (_event: Electron.Event, url: string): void => { + currentUrl = url + renderOrigin() + } + contentWebContents.on('did-navigate', handleDidNavigate) + + // Why: with no adopted contents there is no Chromium-driven navigation for + // this popup, so load the target ourselves (opener handle is already gone). + if (!options.webContents) { + void contentWebContents.loadURL(initialUrl).catch(() => {}) + } + + const closedListeners: (() => void)[] = [] + const handleContentDestroyed = (): void => { + if (!window.isDestroyed()) { + window.close() + } + } + contentWebContents.once('destroyed', handleContentDestroyed) + window.once('closed', () => { + if (!contentWebContents.isDestroyed()) { + contentWebContents.off('destroyed', handleContentDestroyed) + contentWebContents.off('did-navigate', handleDidNavigate) + // Why: close() (not destroy) so the page's unload handlers run — OAuth + // pages often notify the opener from unload. + contentWebContents.close() + } + for (const listener of closedListeners) { + listener() + } + }) + + return { + contentWebContents, + close: (): void => { + if (!window.isDestroyed()) { + window.close() + } + }, + onClosed: (listener: () => void): void => { + closedListeners.push(listener) + } + } +}