fix: host browser popups in an Orca origin-bar window instead of chrome-less child windows (#8343)

* fix: host browser popups in an Orca origin-bar window instead of chrome-less child windows

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 <help@stably.ai>

* fix: show page title in popup title bar instead of doubling the origin

The native title bar and the origin bar both showed the origin, reading
as a doubled header. Match Chrome popup behavior: title bar shows the
page title (reset to origin on navigation so a stale title cannot
outlive its origin); the origin bar below remains the unspoofable trust
surface.

Co-authored-by: Orca <help@stably.ai>

* fix: elide the start of long popup origins so the registrable domain stays visible

Adversarial review findings on the origin bar:

- The bar right-ellipsized long hostnames, hiding exactly the part that
  matters: window.open('https://accounts.google.com.<...>.evil.com',
  '', 'width=360') rendered as 'https://accounts.google.com.signin.s…'.
  Clip from the left instead (rtl container + isolated ltr bdi so host
  and port keep normal character order), matching how Chrome elides.
- Re-assert the origin on the popup's did-finish-load so a transiently
  dropped executeJavaScript write cannot leave a stale origin up.
- Re-pin view layout on enter/leave-full-screen: verified at runtime
  that HTML5 fullscreen keeps the bar visible on macOS (resize fires);
  the explicit events make that hold on every platform.
- Test hardening: fake WebContents now flips isDestroyed after
  'destroyed' so the double-close guard is actually exercised.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing 2026-07-11 21:38:59 -07:00 committed by GitHub
parent 1eace14c57
commit 01dcbcb007
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 786 additions and 26 deletions

View File

@ -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<string, unknown>) => 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 = {

View File

@ -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

View File

@ -0,0 +1,330 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
type Handler = (...args: unknown[]) => void
type FakeWebContents = {
on: ReturnType<typeof vi.fn>
once: ReturnType<typeof vi.fn>
off: ReturnType<typeof vi.fn>
executeJavaScript: ReturnType<typeof vi.fn>
loadURL: ReturnType<typeof vi.fn>
close: ReturnType<typeof vi.fn>
isDestroyed: ReturnType<typeof vi.fn>
emit: (event: string, ...args: unknown[]) => void
}
const { fakeElectron } = vi.hoisted(() => {
function createFakeWebContents(): FakeWebContents {
const handlers = new Map<string, Handler[]>()
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<string, unknown>
private handlers = new Map<string, Handler[]>()
contentView = { addChildView: vi.fn() }
setTitle = vi.fn()
isDestroyed = vi.fn(() => false)
close = vi.fn(() => this.emit('closed'))
constructor(options: Record<string, unknown>) {
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<typeof FakeBaseWindow> {
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<typeof FakeWebContentsView>
content: InstanceType<typeof FakeWebContentsView>
} {
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('<bdi id="origin">')
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)
})
})

View File

@ -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 = `<!doctype html><html><head><meta charset="utf-8"><style>
:root { color-scheme: light dark; }
body {
margin: 0; height: 100vh; box-sizing: border-box; padding: 0 10px;
display: flex; align-items: center; gap: 8px;
overflow: hidden; white-space: nowrap;
-webkit-user-select: none; user-select: none; cursor: default;
font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
background: #fff; color: #0a0a0a; border-bottom: 1px solid #e5e5e5;
}
#insecure { display: none; color: #e40014; font-weight: 600; }
body.insecure #insecure { display: inline; }
/* Elide the START of long origins so the registrable domain stays visible
(Chrome elides the same side): rtl container puts clip+ellipsis on the
left; the bdi keeps the ASCII origin itself rendering LTR. */
#origin-clip { overflow: hidden; text-overflow: ellipsis; direction: rtl; }
#origin { unicode-bidi: isolate; direction: ltr; }
@media (prefers-color-scheme: dark) {
body { background: #0a0a0a; color: #fafafa; border-bottom-color: rgb(255 255 255 / 0.07); }
#insecure { color: #ff6568; }
}
</style></head><body><span id="insecure">Not secure</span><span id="origin-clip"><bdi id="origin"></bdi></span></body></html>`
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)
}
}
}