diff --git a/packages/desktop/src/main/ipc-origin.ts b/packages/desktop/src/main/ipc-origin.ts new file mode 100644 index 0000000000..a5073f8f77 --- /dev/null +++ b/packages/desktop/src/main/ipc-origin.ts @@ -0,0 +1,7 @@ +export function isTrustedIpcUrl(value: string, devUrl = process.env.ELECTRON_RENDERER_URL) { + if (!URL.canParse(value)) return false + const url = new URL(value) + if (url.protocol === "oc:" && url.host === "renderer") return true + if (!devUrl || !URL.canParse(devUrl)) return false + return url.origin === new URL(devUrl).origin +} diff --git a/packages/desktop/src/main/ipc-policy.test.ts b/packages/desktop/src/main/ipc-policy.test.ts new file mode 100644 index 0000000000..666ad47928 --- /dev/null +++ b/packages/desktop/src/main/ipc-policy.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { isTrustedIpcUrl } from "./ipc-origin" + +describe("isTrustedIpcUrl", () => { + test("allows packaged renderer pages", () => { + expect(isTrustedIpcUrl("oc://renderer/index.html", undefined)).toBe(true) + }) + + test("rejects other packaged and remote origins", () => { + expect(isTrustedIpcUrl("oc://attacker/index.html", undefined)).toBe(false) + expect(isTrustedIpcUrl("https://example.com", undefined)).toBe(false) + expect(isTrustedIpcUrl("not a url", undefined)).toBe(false) + }) + + test("allows only the configured development origin", () => { + const devUrl = "http://localhost:5173" + expect(isTrustedIpcUrl("http://localhost:5173/index.html", devUrl)).toBe(true) + expect(isTrustedIpcUrl("http://localhost:5174/index.html", devUrl)).toBe(false) + }) +}) diff --git a/packages/desktop/src/main/ipc-policy.ts b/packages/desktop/src/main/ipc-policy.ts new file mode 100644 index 0000000000..4d090571e0 --- /dev/null +++ b/packages/desktop/src/main/ipc-policy.ts @@ -0,0 +1,31 @@ +import { ipcMain } from "electron" +import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" +import { isTrustedIpcUrl } from "./ipc-origin" + +type IpcEvent = IpcMainEvent | IpcMainInvokeEvent + +export function handle( + channel: string, + listener: (event: IpcMainInvokeEvent, ...args: Args) => Result, +) { + ipcMain.handle(channel, (event, ...args) => { + requireTrustedIpcSender(event) + return listener(event, ...(args as Args)) + }) +} + +export function on( + channel: string, + listener: (event: IpcMainEvent, ...args: Args) => void, +) { + ipcMain.on(channel, (event, ...args) => { + requireTrustedIpcSender(event) + listener(event, ...(args as Args)) + }) +} + +function requireTrustedIpcSender(event: IpcEvent) { + const frame = event.senderFrame + if (frame === event.sender.mainFrame && isTrustedIpcUrl(frame.url)) return + throw new Error("Rejected IPC from untrusted renderer") +} diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 4563b688ac..925452e293 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -1,13 +1,14 @@ import { execFile } from "node:child_process" import { stat } from "node:fs/promises" import { basename } from "node:path" -import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron" +import { app, BrowserWindow, Notification, clipboard, dialog, shell } from "electron" import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types" import { runDesktopMenuAction } from "./desktop-menu-actions" import { setForceFocus } from "./debug" +import { handle, on } from "./ipc-policy" import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker" import { getStore, removeStoreFileIfEmpty } from "./store" import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows" @@ -47,26 +48,26 @@ export function registerIpcHandlers(deps: Deps) { const updaterSubscriptions = createUpdaterSubscriptions() app.once("will-quit", updaterSubscriptions.clear) - ipcMain.handle("kill-sidecar", () => deps.killSidecar()) - ipcMain.handle("await-initialization", () => deps.awaitInitialization()) - ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) - ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) - ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => + handle("kill-sidecar", () => deps.killSidecar()) + handle("await-initialization", () => deps.awaitInitialization()) + handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) + handle("get-default-server-url", () => deps.getDefaultServerUrl()) + handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => deps.setDefaultServerUrl(url), ) - ipcMain.handle("is-first-launch-onboarding-pending", () => deps.isFirstLaunchOnboardingPending()) - ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) => + handle("is-first-launch-onboarding-pending", () => deps.isFirstLaunchOnboardingPending()) + handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) => deps.finishFirstLaunchOnboarding(createDefaultProject), ) - ipcMain.handle("is-old-layout-eligible", () => deps.isOldLayoutEligible()) - ipcMain.handle("get-display-backend", () => deps.getDisplayBackend()) - ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => + handle("is-old-layout-eligible", () => deps.isOldLayoutEligible()) + handle("get-display-backend", () => deps.getDisplayBackend()) + handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) => deps.setDisplayBackend(backend), ) - ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown)) - ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName)) - ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) - ipcMain.handle("updater-subscribe", (event) => { + handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown)) + handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName)) + handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName)) + handle("updater-subscribe", (event) => { const id = event.sender.id updaterSubscriptions.set( id, @@ -77,18 +78,18 @@ export function registerIpcHandlers(deps: Deps) { ) event.sender.once("destroyed", () => updaterSubscriptions.delete(id)) }) - ipcMain.handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id)) - ipcMain.handle("updater-check", () => deps.updater.check()) - ipcMain.handle("updater-install", () => deps.updater.install()) - ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) - ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs()) - ipcMain.handle("set-force-focus", (event: IpcMainInvokeEvent, enabled: boolean) => + handle("updater-unsubscribe", (event) => updaterSubscriptions.delete(event.sender.id)) + handle("updater-check", () => deps.updater.check()) + handle("updater-install", () => deps.updater.install()) + handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color)) + handle("export-debug-logs", () => deps.exportDebugLogs()) + handle("set-force-focus", (event: IpcMainInvokeEvent, enabled: boolean) => setForceFocus(event.sender, enabled), ) - ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) => + handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) => deps.recordFatalRendererError(error), ) - ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { + handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { try { const store = getStore(name) const value = store.get(key) @@ -98,27 +99,27 @@ export function registerIpcHandlers(deps: Deps) { return null } }) - ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => { + handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => { getStore(name).set(key, value) }) - ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => { + handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => { getStore(name).delete(key) void removeStoreFileIfEmpty(name) }) - ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => { + handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => { getStore(name).clear() void removeStoreFileIfEmpty(name) }) - ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => { + handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => { const store = getStore(name) return Object.keys(store.store) }) - ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => { + handle("store-length", (_event: IpcMainInvokeEvent, name: string) => { const store = getStore(name) return Object.keys(store.store).length }) - ipcMain.handle( + handle( "open-directory-picker", async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => { const result = await dialog.showOpenDialog({ @@ -131,7 +132,7 @@ export function registerIpcHandlers(deps: Deps) { }, ) - ipcMain.handle( + handle( "open-file-picker", async ( event: IpcMainInvokeEvent, @@ -157,15 +158,15 @@ export function registerIpcHandlers(deps: Deps) { }, ) - ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => { + handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => { return pickedFiles.read(event.sender.id, token, filePath) }) - ipcMain.handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => { + handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => { pickedFiles.release(event.sender.id, token) }) - ipcMain.handle( + handle( "save-file-picker", async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => { const result = await dialog.showSaveDialog({ @@ -177,11 +178,11 @@ export function registerIpcHandlers(deps: Deps) { }, ) - ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => { + on("open-link", (_event: IpcMainEvent, url: string) => { void shell.openExternal(url) }) - ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => { + handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => { if (!app) return shell.openPath(path) await new Promise((resolve, reject) => { const [cmd, args] = @@ -190,7 +191,7 @@ export function registerIpcHandlers(deps: Deps) { }) }) - ipcMain.handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => { + handle("reveal-path", async (_event: IpcMainInvokeEvent, path: string) => { const exists = await stat(path).then( () => true, () => false, @@ -200,7 +201,7 @@ export function registerIpcHandlers(deps: Deps) { return true }) - ipcMain.handle("read-clipboard-image", () => { + handle("read-clipboard-image", () => { const image = clipboard.readImage() if (image.isEmpty()) return null const buffer = image.toPNG().buffer @@ -208,13 +209,13 @@ export function registerIpcHandlers(deps: Deps) { return { buffer, width: size.width, height: size.height } }) - ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => { + on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => { new Notification({ title, body }).show() }) - ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length) + handle("get-window-count", () => BrowserWindow.getAllWindows().length) - ipcMain.handle("get-window-id", (event: IpcMainInvokeEvent) => { + handle("get-window-id", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) throw new Error("Window not found") const id = getWindowID(win) @@ -222,42 +223,42 @@ export function registerIpcHandlers(deps: Deps) { return id }) - ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => { + handle("get-window-focused", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) return win?.isFocused() ?? false }) - ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => { + handle("set-window-focus", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) win?.focus() }) - ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => { + handle("show-window", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) win?.show() }) - ipcMain.on("relaunch", () => { + on("relaunch", () => { deps.relaunch() }) - ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) - ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => { + handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) + handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => { event.sender.setZoomFactor(factor) const win = BrowserWindow.fromWebContents(event.sender) if (!win) return updateTitlebar(win) }) - ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled()) - ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => { + handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled()) + handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => { setPinchZoomEnabled(enabled) }) - ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { + handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return setTitlebar(win, theme) }) - ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => { + handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => { runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, { checkForUpdates: () => void deps.showUpdater(), relaunch: deps.relaunch, diff --git a/packages/desktop/src/main/wsl/ipc.ts b/packages/desktop/src/main/wsl/ipc.ts index c3a2271bf6..ed2872f5bf 100644 --- a/packages/desktop/src/main/wsl/ipc.ts +++ b/packages/desktop/src/main/wsl/ipc.ts @@ -1,8 +1,9 @@ -import { app, ipcMain } from "electron" +import { app } from "electron" import type { IpcMainInvokeEvent } from "electron" import type { WslServersController } from "./servers" import { requireWslIpcString, requireWslIpcStrings } from "./policy" import type { WslServersState } from "../../preload/types" +import { handle } from "../ipc-policy" export function registerWslIpcHandlers(controller: WslServersController) { if (process.platform !== "win32") { @@ -23,7 +24,7 @@ export function registerWslIpcHandlers(controller: WslServersController) { subscriptions.clear() }) - ipcMain.handle("wsl-servers-subscribe", (event) => { + handle("wsl-servers-subscribe", (event) => { const id = event.sender.id if (subscriptions.has(id)) return subscriptions.set( @@ -38,30 +39,30 @@ export function registerWslIpcHandlers(controller: WslServersController) { ) event.sender.once("destroyed", () => unsubscribe(id)) }) - ipcMain.handle("wsl-servers-unsubscribe", (event) => unsubscribe(event.sender.id)) - ipcMain.handle("wsl-servers-get-state", () => controller.getState()) - ipcMain.handle("wsl-servers-probe-runtime", () => controller.probeRuntime()) - ipcMain.handle("wsl-servers-refresh-distros", () => controller.refreshDistros()) - ipcMain.handle("wsl-servers-install-wsl", () => controller.installWsl()) - ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) => + handle("wsl-servers-unsubscribe", (event) => unsubscribe(event.sender.id)) + handle("wsl-servers-get-state", () => controller.getState()) + handle("wsl-servers-probe-runtime", () => controller.probeRuntime()) + handle("wsl-servers-refresh-distros", () => controller.refreshDistros()) + handle("wsl-servers-install-wsl", () => controller.installWsl()) + handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) => controller.installDistro(requireWslIpcString("distro", name)), ) - ipcMain.handle("wsl-servers-probe-addable", (_event: IpcMainInvokeEvent, distros: string[]) => + handle("wsl-servers-probe-addable", (_event: IpcMainInvokeEvent, distros: string[]) => controller.probeAddable(requireWslIpcStrings("distro", distros)), ) - ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => + handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) => controller.installOpencode(requireWslIpcString("distro", name)), ) - ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => + handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) => controller.openTerminal(requireWslIpcString("distro", name)), ) - ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => + handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => controller.addServer(requireWslIpcString("distro", distro)), ) - ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => + handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => controller.removeServer(requireWslIpcString("server id", id)), ) - ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => + handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => controller.startServer(requireWslIpcString("server id", id)), ) } @@ -85,19 +86,19 @@ function registerUnavailableWslIpcHandlers() { job: null, }) - ipcMain.handle("wsl-servers-subscribe", (event) => { + handle("wsl-servers-subscribe", (event) => { event.sender.send("wsl-servers-event", { type: "state", state: state() }) }) - ipcMain.handle("wsl-servers-unsubscribe", () => undefined) - ipcMain.handle("wsl-servers-get-state", () => state()) - ipcMain.handle("wsl-servers-probe-runtime", unavailable) - ipcMain.handle("wsl-servers-refresh-distros", unavailable) - ipcMain.handle("wsl-servers-install-wsl", unavailable) - ipcMain.handle("wsl-servers-install-distro", unavailable) - ipcMain.handle("wsl-servers-probe-addable", unavailable) - ipcMain.handle("wsl-servers-install-opencode", unavailable) - ipcMain.handle("wsl-servers-open-terminal", unavailable) - ipcMain.handle("wsl-servers-add", unavailable) - ipcMain.handle("wsl-servers-remove", unavailable) - ipcMain.handle("wsl-servers-start", unavailable) + handle("wsl-servers-unsubscribe", () => undefined) + handle("wsl-servers-get-state", () => state()) + handle("wsl-servers-probe-runtime", unavailable) + handle("wsl-servers-refresh-distros", unavailable) + handle("wsl-servers-install-wsl", unavailable) + handle("wsl-servers-install-distro", unavailable) + handle("wsl-servers-probe-addable", unavailable) + handle("wsl-servers-install-opencode", unavailable) + handle("wsl-servers-open-terminal", unavailable) + handle("wsl-servers-add", unavailable) + handle("wsl-servers-remove", unavailable) + handle("wsl-servers-start", unavailable) }