fix(desktop): hand preview guest links to the audited opener via a guest preload

Preview-pane guest pages (Streamlit's traceback "Ask Google" / "Ask …"
buttons, plain `<a target="_blank">` anchors) could not open anything: the
`<webview>` has no `allowpopups`, so Chromium drops the popup before any
handler runs. Opening from `setWindowOpenHandler` is banned
(GHSA-9f4c-93c8-jc8g, window-open-policy.ts), so this adds an explicit
click bridge instead:

- main.ts installs a guest preload through `will-attach-webview`, keyed on
  the `persist:hermes-preview` partition only.
- The preload forwards a clicked `_blank` anchor's resolved href to the
  host renderer via `ipcRenderer.sendToHost`; it opens nothing itself.
- PreviewPane admits the URL and routes it through the existing audited
  `hermes:openExternal` IPC.

Salvaged from PR #112959 (squash of its two commits).

Fixes #112941
This commit is contained in:
liuhao1024
2026-09-16 21:48:29 -07:00
committed by Teknium
parent 091b8c4f53
commit b08ec00a70
10 changed files with 380 additions and 2 deletions

View File

@@ -671,3 +671,79 @@ describe('PreviewPane console state', () => {
})
})
})
describe('PreviewPane guest external handoff', () => {
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
const initialHermesDesktop = desktopWindow.hermesDesktop
afterEach(() => {
if (initialHermesDesktop) {
desktopWindow.hermesDesktop = initialHermesDesktop
} else {
delete desktopWindow.hermesDesktop
}
})
async function renderWebview() {
let rendered!: ReturnType<typeof render>
await act(async () => {
rendered = render(
<PreviewPane
target={{
kind: 'url',
label: 'Preview',
source: 'http://localhost:8501',
url: 'http://localhost:8501'
}}
/>
)
})
return rendered.container.querySelector('webview') as HTMLElement
}
function guestHandoff(webview: HTMLElement, url: string) {
act(() => {
webview.dispatchEvent(Object.assign(new Event('ipc-message'), { args: [url], channel: 'preview-open-external' }))
})
}
it('opens an admitted guest anchor URL through the audited OS-browser channel', async () => {
const openExternal = vi.fn(async () => undefined)
desktopWindow.hermesDesktop = { openExternal } as unknown as Window['hermesDesktop']
const webview = await renderWebview()
guestHandoff(webview, 'https://www.google.com/search?q=traceback')
expect(openExternal).toHaveBeenCalledExactlyOnceWith('https://www.google.com/search?q=traceback')
})
it('rejects file: and javascript: guest URLs without any OS open', async () => {
const openExternal = vi.fn(async () => undefined)
desktopWindow.hermesDesktop = { openExternal } as unknown as Window['hermesDesktop']
const webview = await renderWebview()
guestHandoff(webview, 'file:///etc/passwd')
guestHandoff(webview, 'javascript:alert(1)')
expect(openExternal).not.toHaveBeenCalled()
})
it('ignores channels the guest preload does not own', async () => {
const openExternal = vi.fn(async () => undefined)
desktopWindow.hermesDesktop = { openExternal } as unknown as Window['hermesDesktop']
const webview = await renderWebview()
act(() => {
webview.dispatchEvent(
Object.assign(new Event('ipc-message'), { args: ['https://example.com'], channel: 'something-else' })
)
})
expect(openExternal).not.toHaveBeenCalled()
})
})

View File

@@ -25,6 +25,7 @@ import {
endAnnotateMode,
flushAnnotateStack
} from '@/lib/preview-annotate'
import { admitPreviewExternalUrl, PREVIEW_EXTERNAL_CHANNEL } from '@/lib/preview-external'
import { reachablePreviewUrl } from '@/lib/preview-reach'
import { rafCoalesce } from '@/lib/raf-coalesce'
import { cn } from '@/lib/utils'
@@ -1026,6 +1027,25 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest =
webview.setAttribute('src', target.url)
webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes')
// The guest preload (main.ts installs it on this partition) forwards a
// clicked `_blank` anchor here. Admission is our side of the contract —
// web/mail schemes only, so a guest page can never reach the local-file
// opener — and the open itself goes through the audited
// `hermes:openExternal` channel, never a popup side effect.
const onGuestExternal = (event: Event) => {
const detail = event as Event & { args?: unknown[]; channel?: string }
if (detail.channel !== PREVIEW_EXTERNAL_CHANNEL) {
return
}
const url = String(detail.args?.[0] ?? '')
if (admitPreviewExternalUrl(url)) {
void window.hermesDesktop?.openExternal?.(url)
}
}
const onConsole = (event: Event) => {
const detail = event as Event & {
level?: number
@@ -1206,6 +1226,7 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest =
}
webview.addEventListener('console-message', onConsole)
webview.addEventListener('ipc-message', onGuestExternal)
webview.addEventListener('context-menu', onGuestContextMenu)
webview.addEventListener('devtools-closed', onDevToolsClosed)
webview.addEventListener('devtools-opened', onDevToolsOpened)
@@ -1223,6 +1244,7 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest =
return () => {
annotateLoopRef.current += 1
webview.removeEventListener('console-message', onConsole)
webview.removeEventListener('ipc-message', onGuestExternal)
webview.removeEventListener('context-menu', onGuestContextMenu)
webview.removeEventListener('devtools-closed', onDevToolsClosed)
webview.removeEventListener('devtools-opened', onDevToolsOpened)

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { admitPreviewExternalUrl, PREVIEW_EXTERNAL_CHANNEL } from './preview-external'
describe('admitPreviewExternalUrl', () => {
it('admits the web and mail schemes the guest handoff exists for', () => {
expect(admitPreviewExternalUrl('https://www.google.com/search?q=traceback')).toBe(true)
expect(admitPreviewExternalUrl('http://localhost:8501/')).toBe(true)
expect(admitPreviewExternalUrl('mailto:support@example.com')).toBe(true)
})
it('rejects file: — a guest page must not reach the local-file opener', () => {
expect(admitPreviewExternalUrl('file:///etc/passwd')).toBe(false)
expect(admitPreviewExternalUrl('FILE:///Users/me/secret.txt')).toBe(false)
})
it('rejects script-capable and opaque schemes', () => {
expect(admitPreviewExternalUrl('javascript:alert(1)')).toBe(false)
expect(admitPreviewExternalUrl('data:text/html,hi')).toBe(false)
expect(admitPreviewExternalUrl('blob:https://example.com/uuid')).toBe(false)
expect(admitPreviewExternalUrl('chrome://settings')).toBe(false)
})
it('rejects unparseable and empty input', () => {
expect(admitPreviewExternalUrl('')).toBe(false)
expect(admitPreviewExternalUrl(' ')).toBe(false)
expect(admitPreviewExternalUrl('not a url')).toBe(false)
expect(admitPreviewExternalUrl('localhost:8501')).toBe(false)
})
it('uses the channel the guest preload sends on', () => {
expect(PREVIEW_EXTERNAL_CHANNEL).toBe('preview-open-external')
})
})

View File

@@ -0,0 +1,26 @@
// Admission for URLs handed up by the preview guest preload
// (electron/preview-guest-preload.ts, `preview-open-external` channel).
//
// A guest page chose this URL, not the user typing it, so the scheme set is
// strictly the web/mail subset of what main's `openExternalUrl` accepts.
// `file:` stays out on purpose: main's opener does open local files, but that
// capability belongs to host-driven surfaces (the artifacts panel), and an
// untrusted preview page must never reach `shell.openPath` through us.
export const PREVIEW_EXTERNAL_CHANNEL = 'preview-open-external'
export function admitPreviewExternalUrl(rawUrl: string): boolean {
const raw = String(rawUrl ?? '').trim()
if (!raw) {
return false
}
try {
const { protocol } = new URL(raw)
return protocol === 'https:' || protocol === 'http:' || protocol === 'mailto:'
} catch {
return false
}
}