diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index c854d1f0a..29d2c8f8e 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 18cddd8c7..acd404f91 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 000000000..c0161dd94 --- /dev/null +++ b/src/main/browser/popup-origin-bar-window.test.ts @@ -0,0 +1,330 @@ +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() + let destroyed = false + 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(), + // Mirrors real Electron: isDestroyed() is already true inside a + // 'destroyed' handler, which is what the double-close guard relies on. + isDestroyed: vi.fn(() => destroyed), + emit: (event: string, ...args: unknown[]) => { + if (event === 'destroyed') { + destroyed = true + } + // 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('shows the page title in the native title bar but resets to origin on navigation', () => { + const adopted = createFakeWebContents() + openPopupWithOriginBar( + { webContents: adopted as never }, + 'https://accounts.example.com/oauth?code=SECRET' + ) + // Until the page supplies a title, the window title is the origin. + expect(lastWindow().options.title).toBe('https://accounts.example.com') + + adopted.emit('page-title-updated', {}, 'Sign in to Example') + expect(lastWindow().setTitle).toHaveBeenLastCalledWith('Sign in to Example') + + // A stale title must not survive a cross-origin navigation. + adopted.emit('did-navigate', {}, 'https://evil.example.net/') + expect(lastWindow().setTitle).toHaveBeenLastCalledWith('https://evil.example.net') + }) + + it('closes the window when the popup content is destroyed, without re-closing the contents', () => { + const adopted = createFakeWebContents() + openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/') + + adopted.emit('destroyed') + + expect(lastWindow().close).toHaveBeenCalled() + // The window's closed handler must not call close() on already-destroyed + // contents — that throws in real Electron. + expect(adopted.close).not.toHaveBeenCalled() + }) + + it('re-asserts the origin when the popup finishes loading', () => { + const adopted = createFakeWebContents() + openPopupWithOriginBar({ webContents: adopted as never }, 'https://example.com/login') + const { bar } = lastViews() + bar.webContents.emit('did-finish-load') + bar.webContents.executeJavaScript.mockClear() + + adopted.emit('did-finish-load') + + expect(bar.webContents.executeJavaScript).toHaveBeenCalledTimes(1) + expect(bar.webContents.executeJavaScript.mock.calls[0][0]).toContain('"https://example.com"') + }) + + it('elides the start of long origins so the registrable domain stays visible', () => { + openPopupWithOriginBar( + { webContents: createFakeWebContents() as never }, + 'https://example.com/' + ) + const { bar } = lastViews() + const dataUrl = bar.webContents.loadURL.mock.calls[0][0] as string + const html = decodeURIComponent(dataUrl.replace('data:text/html;charset=utf-8,', '')) + // rtl clip container ellipsizes the left; the isolated ltr bdi keeps the + // origin's own characters (host, port) in normal order. + expect(html).toContain('') + expect(html).toMatch(/#origin-clip\s*{[^}]*direction:\s*rtl/) + expect(html).toMatch(/#origin\s*{[^}]*direction:\s*ltr/) + }) + + 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 000000000..504c862a5 --- /dev/null +++ b/src/main/browser/popup-origin-bar-window.ts @@ -0,0 +1,227 @@ +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) + // Why: HTML5 fullscreen makes the whole window fullscreen. resize covers + // this on macOS, but re-pin on the explicit events too so the origin bar + // provably stays above fullscreen content on every platform. + window.on('enter-full-screen', layoutViews) + window.on('leave-full-screen', layoutViews) + layoutViews() + + const contentWebContents = contentView.webContents + let currentUrl = initialUrl + const renderOrigin = (): void => { + const { label, insecure } = describePopupOrigin(currentUrl) + // Why: origin is the title only until the page supplies one — the bar + // below stays the trust surface, so the native title bar can show the + // page title (Chrome popup behavior) instead of doubling the origin. + // Re-asserting on navigation stops a stale title outliving its origin. + 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: origin writes fail silently if the bar is mid-load; re-asserting at + // load completion means a dropped write can never leave a stale origin up + // for the lifetime of the page. + contentWebContents.on('did-finish-load', renderOrigin) + const handlePageTitleUpdated = (_event: Electron.Event, title: string): void => { + if (!window.isDestroyed() && title) { + window.setTitle(title) + } + } + contentWebContents.on('page-title-updated', handlePageTitleUpdated) + + // 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) + contentWebContents.off('did-finish-load', renderOrigin) + contentWebContents.off('page-title-updated', handlePageTitleUpdated) + // 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) + } + } +}