From 3f5acfff31018ea83ee9d728624f57be2fdbf1e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Sousa=20Andrade?= <659718+joaosa@users.noreply.github.com> Date: Sun, 10 May 2026 14:02:47 +0100 Subject: [PATCH] Restrict desktop external URL schemes (#845) --- packages/desktop/src/features/opener.test.ts | 53 ++++++++++++++++++++ packages/desktop/src/features/opener.ts | 20 +++++++- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 packages/desktop/src/features/opener.test.ts diff --git a/packages/desktop/src/features/opener.test.ts b/packages/desktop/src/features/opener.test.ts new file mode 100644 index 000000000..b6a3ac1e7 --- /dev/null +++ b/packages/desktop/src/features/opener.test.ts @@ -0,0 +1,53 @@ +import { ipcMain, shell } from "electron"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { isAllowedExternalUrl, registerOpenerHandlers } from "./opener"; + +vi.mock("electron", () => ({ + ipcMain: { handle: vi.fn() }, + shell: { openExternal: vi.fn() }, +})); + +function getRegisteredOpenUrlHandler(): (_event: unknown, url: unknown) => Promise { + registerOpenerHandlers(); + const handler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => { + return channel === "paseo:opener:openUrl"; + })?.[1]; + if (typeof handler !== "function") { + throw new Error("open URL handler was not registered"); + } + return handler as (_event: unknown, url: unknown) => Promise; +} + +describe("desktop opener", () => { + beforeEach(() => { + vi.mocked(ipcMain.handle).mockReset(); + vi.mocked(shell.openExternal).mockReset(); + }); + + it("allows only http and https external URLs", () => { + expect(isAllowedExternalUrl("https://example.com/path")).toBe(true); + expect(isAllowedExternalUrl("http://localhost:8081")).toBe(true); + expect(isAllowedExternalUrl("file:///etc/passwd")).toBe(false); + expect(isAllowedExternalUrl("javascript:alert(1)")).toBe(false); + expect(isAllowedExternalUrl("paseo://settings")).toBe(false); + expect(isAllowedExternalUrl("/relative/path")).toBe(false); + expect(isAllowedExternalUrl(null)).toBe(false); + }); + + it("opens allowed URLs through Electron shell", async () => { + const handler = getRegisteredOpenUrlHandler(); + + await handler({}, "https://example.com"); + + expect(shell.openExternal).toHaveBeenCalledWith("https://example.com"); + }); + + it("rejects blocked URLs before invoking Electron shell", async () => { + const handler = getRegisteredOpenUrlHandler(); + + await expect(handler({}, "file:///etc/passwd")).rejects.toThrow("Unsupported external URL"); + + expect(shell.openExternal).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/desktop/src/features/opener.ts b/packages/desktop/src/features/opener.ts index d39ac9e37..7085df5ba 100644 --- a/packages/desktop/src/features/opener.ts +++ b/packages/desktop/src/features/opener.ts @@ -1,7 +1,25 @@ import { shell, ipcMain } from "electron"; +const ALLOWED_EXTERNAL_URL_PROTOCOLS = new Set(["http:", "https:"]); + +export function isAllowedExternalUrl(value: unknown): value is string { + if (typeof value !== "string") { + return false; + } + + try { + const url = new URL(value); + return ALLOWED_EXTERNAL_URL_PROTOCOLS.has(url.protocol); + } catch { + return false; + } +} + export function registerOpenerHandlers(): void { - ipcMain.handle("paseo:opener:openUrl", async (_event, url: string) => { + ipcMain.handle("paseo:opener:openUrl", async (_event, url: unknown) => { + if (!isAllowedExternalUrl(url)) { + throw new Error("Unsupported external URL"); + } await shell.openExternal(url); }); }