diff --git a/packages/desktop/src/main/external-link.test.ts b/packages/desktop/src/main/external-link.test.ts new file mode 100644 index 000000000000..22417da80469 --- /dev/null +++ b/packages/desktop/src/main/external-link.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { safeExternalUrl } from "./external-link" + +describe("safeExternalUrl", () => { + test("allows web links", () => { + expect(safeExternalUrl("https://opencode.ai/docs")).toBe("https://opencode.ai/docs") + expect(safeExternalUrl("http://localhost:3000/callback")).toBe("http://localhost:3000/callback") + }) + + test("rejects executable and application protocols", () => { + expect(safeExternalUrl("file:///tmp/script.sh")).toBeUndefined() + expect(safeExternalUrl("ssh://attacker.example")).toBeUndefined() + expect(safeExternalUrl("custom-handler:payload")).toBeUndefined() + }) + + test("rejects malformed URLs", () => { + expect(safeExternalUrl("not a url")).toBeUndefined() + }) +}) diff --git a/packages/desktop/src/main/external-link.ts b/packages/desktop/src/main/external-link.ts new file mode 100644 index 000000000000..374a885e4067 --- /dev/null +++ b/packages/desktop/src/main/external-link.ts @@ -0,0 +1,8 @@ +const allowedProtocols = new Set(["https:", "http:"]) + +export function safeExternalUrl(value: string) { + if (!URL.canParse(value)) return + const url = new URL(value) + if (!allowedProtocols.has(url.protocol)) return + return url.toString() +} diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 4563b688acea..651b5f6f5890 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -8,6 +8,7 @@ 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 { safeExternalUrl } from "./external-link" import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker" import { getStore, removeStoreFileIfEmpty } from "./store" import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows" @@ -178,7 +179,9 @@ export function registerIpcHandlers(deps: Deps) { ) ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => { - void shell.openExternal(url) + const externalUrl = safeExternalUrl(url) + if (!externalUrl) return + void shell.openExternal(externalUrl) }) ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {