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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
34
apps/desktop/src/lib/preview-external.test.ts
Normal file
34
apps/desktop/src/lib/preview-external.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
26
apps/desktop/src/lib/preview-external.ts
Normal file
26
apps/desktop/src/lib/preview-external.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user