Files
orca/docs/reference/plugin-isolated-frames.md
T

9.9 KiB

Isolated plugin frames

Design contract for running plugin UI in isolated guests.

Why

Panel and custom-editor frames are srcdoc iframes sharing the main window's session. CSP is the only network barrier, so DNS prefetch and WebRTC (via a nested frame's fresh RTCPeerConnection) still reach the network, and the opaque origin forces authors to inline every asset and shim workers, WASM, storage and blob:.

Isolated guests (desktop)

Every plugin frame on desktop is an Electron <webview>:

  • Partition: persist:orca-plugin-<key>, <key> = first 16 hex chars of sha256(publisher.id). One session, one renderer process and one storage area per plugin.
  • Origin: orca-plugin-frame://<key>/<path>, a privileged scheme (standard, secure, supportFetchAPI, stream) handled on that partition's session. Paths resolve inside the plugin root with the same containment and content-integrity checks as readPanelEntry; anything else is 404. orca-plugin-frame://<key>/__orca/* is reserved for files Orca ships.
  • CSP (response header on every HTML document): default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self' data: blob:; worker-src 'self' blob:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'.
  • Network is denied at the session, not by CSP. "No network" means the frame itself makes no connections, requests or DNS lookups. The one way data leaves is a URL the person agrees to open: orca.shell.openExternal needs a click or key press, and Orca asks before opening a host this plugin was not allowed before (see Host APIs below).
    • webRequest.onBeforeRequest cancels every URL that is not orca-plugin-frame://<key>/, data:, blob: or devtools:.
    • setProxy pins a loopback proxy that drops every connection, with <-loopback> so loopback targets are proxied too, so traffic webRequest never sees (preconnect, WebRTC TURN-over-TCP) has nowhere to go; preconnect hints go through it and are never resolved.
    • <link rel="dns-prefetch"> resolves on the system resolver, outside webRequest and the proxy, so will-attach-webview pins the guest renderer's --blink-settings=dnsPrefetchingEnabled=false (PLUGIN_FRAME_GUEST_RENDERER_ARGUMENTS), covering static, script-inserted and child-frame hints (plugin-frame-guest-egress.electron.test.ts). X-DNS-Prefetch-Control: off, the matching <meta> and the app-wide dns-prefetch-disable switch were measured and do not stop it.
  • Storage: uninstalling a plugin clears its partition (clearStorageData and clearCache).
    • Guest setWebRTCIPHandlingPolicy('disable_non_proxied_udp').
    • Permission request/check handlers deny everything, so navigator.clipboard reads and writes are refused; document.execCommand('copy'|'cut') works only inside a user gesture, as on any web page (pinned by plugin-frame-guest-clipboard.electron.test.ts); HID, serial, USB and Bluetooth choosers are cancelled.
    • will-download is cancelled; setWindowOpenHandler denies; navigation away from the plugin origin is cancelled.
  • Admission: will-attach-webview only admits an orca-plugin- partition whose src origin matches its key, whose plugin is installed, enabled and consented, and whose session main has already configured (main configures it, proxy included, before readPanelEntry returns the guest URL). It force-sets the guest preload, sandbox, contextIsolation, nodeIntegration: false, webSecurity, no popups, and disableDialogs (alert/confirm/prompt do nothing in a plugin frame).
  • Bridge: every HTML document the protocol serves starts with the same frame-API script as the iframe shell (api, host.call/has, on, shell, files, document.get/onDidChange/applyEdit), so the two cannot diverge. The guest preload only hands that script a one-shot transport (deleted before plugin code runs; offered only to text/html top documents, so an SVG or XML page Orca does not wrap never gets one) that caps bytes and rate in the isolated world and relays the {orca:1, kind, …} envelopes with ipcRenderer.sendToHost('orca-plugin-frame', msg, userActivation) / ipcRenderer.on('orca-plugin-frame'); userActivation is read in the isolated world, so page script cannot forge it. Why not contextBridge.exposeInMainWorld('orca', …): it drops error.code/retryable on rejected calls and freezes getters such as orca.api, and a sandboxed preload cannot share code chunks with main. The host renderer uses webview.send and the ipc-message event. Envelope validation, budgets, sessions and capability gating are unchanged. A custom editor's declared entry gets the editor API; every other document gets the panel API.
  • Theme: main bakes the host's current --orca-* tokens and light/dark class into every document it serves (the renderer sends its theme with each entry read), so the first paint and boot-time token reads match; hello and the theme event keep it current without a reload.
  • Reloads: the guest URL carries rev=: the installed content hash, or for a dev folder a per-run counter the dev watcher bumps on each change it reports under that folder (plugin-dev-revision.ts; the tree is never hashed for this). An update or an asset-only dev edit remounts the guest.
  • Input: Orca's window shortcuts are forwarded from a focused guest through the browser tab path, minus browser-page chords (address bar, find, reload, history, page zoom); a custom editor's save chord still rides editor.keydown, so it fires once. Editable fields get Orca's Cut/Copy/Paste context menu.
  • Switch (default off): isolated guests ship off, so merging changes nothing; a separate one-line PR flips the constant PLUGIN_ISOLATED_FRAMES_DEFAULT in src/main/plugins/panels/plugin-frame-isolation.ts. ORCA_PLUGIN_ISOLATED_FRAMES=1 (dev, e2e) turns guests on and =0 off, overriding the experimentalPluginIsolatedFrames setting; all are main-process only. Off, frames use the srcdoc iframe (never offered orca.shell/orca.files).
  • Compatibility: existing single-file plugins load unchanged. Browser clients (serve/web) have no <webview> and keep the srcdoc iframe; multi-file assets need the desktop app. With isolation on, a desktop guest that cannot be set up fails closed (the panel shows its load error and its session is revoked); the iframe is never a fallback for a setup failure. On the guest path no srcdoc HTML is read, hashed or sent over IPC; the guest's own navigation is the one integrity check per open.
  • Serving: images, fonts and media stream from disk (one Range: bytes= answered with 206, HEAD from stat), up to 32 MiB a file; a session holds at most 32 requests at once (503 past that). Documents, scripts, styles, JSON, SVG and WASM are read whole (16 MiB cap). A declared HTML entry is a document whatever its extension. Path segments ending in a dot or space are refused.
  • Integrity: a navigation re-hashes the plugin tree, at most every 2 s per tree; any other request (including fetch of an .html file) reuses a check from the last 10 s. The hash records each file's digest, and documents, scripts, styles, JSON, SVG and WASM are served only if their bytes still match it, so a file swapped after the check is refused.

Host APIs added for frames

No new capability; both require transient user activation in the frame and are rate-limited. The shell checks navigator.userActivation first; the frame host re-checks the activation that arrived with the message: only a guest's preload stamp counts. A srcdoc iframe shares the host window, whose activation any keystroke in Orca sets, so iframes (browser clients) are never offered these methods and get not_permitted. Main runs them only for calls from its own window over IPC, so a dialog or browser tab never opens on another machine.

  • orca.shell.openExternal(url): http:/https:/mailto: only, opened in the OS browser. Anything in the URL leaves the machine, so for an http:/https: host this plugin has not been allowed, Orca first shows " wants to open " with the URL (truncated to 300 characters), Open / Cancel, and "Always allow for this plugin". The choice is kept per plugin and host (with port) in main's settings (pluginExternalHostGrants, which the renderer cannot write) and cleared on uninstall. mailto: opens without asking: it fills a draft the person still has to send. Resolves { opened: false } when the person cancels.
  • orca.files.saveAs({ suggestedName, mimeType, data }): Orca shows its save dialog; the plugin never sees the chosen path. data is a string or ArrayBuffer, capped at 64 MiB. Resolves with { status: 'saved' }, or { status: 'cancelled' } when the person cancels. Any mimeType is accepted; the dialog filter comes from the suggested name's extension. Executable, script, installer, shortcut and disk-image types are refused, whether suggested or typed (plugin-save-as-extensions.ts, Chromium's dangerous download types plus .iso .img .vhd .vhdx, which shed the download mark when mounted). The written file is marked as downloaded: a Zone.Identifier stream with ZoneId=3 on Windows, com.apple.quarantine on macOS; Linux has no such mark.
  • Revocation reaches the side effect: each call re-checks that its plugin is still installed, enabled, consented and not kill-listed right before it writes, opens a tab or sends terminal input, so a save dialog left open while the plugin is disabled or uninstalled writes nothing. Dialogs and rate budgets bind to the window whose frame made the call (its WebContents travels with the IPC call).
  • orca.locale: Orca's UI locale from hello (guests' navigator.language follows the OS).