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..010b0c1ab9bd --- /dev/null +++ b/packages/desktop/src/main/external-link.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { isSafeExternalLink } from "./external-link" + +describe("external link", () => { + test("allows http and https links", () => { + expect(isSafeExternalLink("https://opencode.ai")).toBe(true) + expect(isSafeExternalLink("http://localhost:3000")).toBe(true) + }) + + test("blocks non-http protocols", () => { + expect(isSafeExternalLink("file:///tmp/demo.txt")).toBe(false) + expect(isSafeExternalLink("javascript:alert(1)")).toBe(false) + expect(isSafeExternalLink("smb://attacker/share")).toBe(false) + expect(isSafeExternalLink("ms-msdt:/id PCWDiagnostic")).toBe(false) + }) + + test("blocks malformed links", () => { + expect(isSafeExternalLink("not a url")).toBe(false) + expect(isSafeExternalLink("/relative/path")).toBe(false) + expect(isSafeExternalLink("")).toBe(false) + }) +}) diff --git a/packages/desktop/src/main/external-link.ts b/packages/desktop/src/main/external-link.ts new file mode 100644 index 000000000000..923c3d89757c --- /dev/null +++ b/packages/desktop/src/main/external-link.ts @@ -0,0 +1,10 @@ +const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]) + +export function isSafeExternalLink(url: string) { + try { + const parsed = new URL(url) + return ALLOWED_PROTOCOLS.has(parsed.protocol) + } catch { + return false + } +} diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index d6bc5bb3a83a..1a4aa3ef5fba 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 { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker" +import { isSafeExternalLink } from "./external-link" import { getStore, removeStoreFileIfEmpty } from "./store" import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows" import type { UpdaterController } from "./updater-controller" @@ -172,6 +173,7 @@ export function registerIpcHandlers(deps: Deps) { ) ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => { + if (!isSafeExternalLink(url)) return void shell.openExternal(url) })