diff --git a/apps/code/src/main/trpc/routers/os.ts b/apps/code/src/main/trpc/routers/os.ts index df50b82d0e..51285b907b 100644 --- a/apps/code/src/main/trpc/routers/os.ts +++ b/apps/code/src/main/trpc/routers/os.ts @@ -9,6 +9,7 @@ import { ALLOWED_IMAGE_MIME_TYPES, IMAGE_MIME_TYPES, isRasterImageFile, + isSafeExternalUrl, } from "@posthog/shared"; import { z } from "zod"; import { container } from "../../di/container"; @@ -223,7 +224,16 @@ export const osRouter = router({ * Open URL in external browser */ openExternal: publicProcedure - .input(z.object({ url: z.string() })) + .input( + z.object({ + url: z + .string() + .refine( + isSafeExternalUrl, + "Only http(s) and mailto URLs may be opened externally", + ), + }), + ) .mutation(async ({ input }) => { await getUrlLauncher().launch(input.url); }), diff --git a/apps/code/src/renderer/utils/browser.ts b/apps/code/src/renderer/utils/browser.ts index 157a84f928..4c3a72066f 100644 --- a/apps/code/src/renderer/utils/browser.ts +++ b/apps/code/src/renderer/utils/browser.ts @@ -1,6 +1,8 @@ +import { isSafeExternalUrl } from "@posthog/shared"; import { trpcClient } from "@renderer/trpc/client"; export async function openUrlInBrowser(url: string): Promise { + if (!isSafeExternalUrl(url)) return; try { await trpcClient.os.openExternal.mutate({ url }); } catch { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 752019b25c..18974bda31 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -37,3 +37,4 @@ export { type SagaResult, type SagaStep, } from "./saga"; +export { isSafeExternalUrl } from "./url"; diff --git a/packages/shared/src/url.test.ts b/packages/shared/src/url.test.ts new file mode 100644 index 0000000000..5fb495d609 --- /dev/null +++ b/packages/shared/src/url.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { isSafeExternalUrl } from "./url"; + +describe("isSafeExternalUrl", () => { + it.each([ + "https://github.com/PostHog/code/pull/42", + "http://example.com", + "https://example.com/path?q=1#frag", + "HTTPS://EXAMPLE.COM", + "mailto:hi@posthog.com", + ])("allows %s", (url) => { + expect(isSafeExternalUrl(url)).toBe(true); + }); + + it.each([ + "javascript:alert(1)", + "file:///etc/passwd", + "data:text/html,", + "smb://server/share", + "ms-msdt:/id", + "vscode://extension", + "//evil.com", + "/relative/path", + "not a url", + "", + " ", + ])("blocks %s", (url) => { + expect(isSafeExternalUrl(url)).toBe(false); + }); +}); diff --git a/packages/shared/src/url.ts b/packages/shared/src/url.ts new file mode 100644 index 0000000000..ac7d8ddb57 --- /dev/null +++ b/packages/shared/src/url.ts @@ -0,0 +1,22 @@ +const SAFE_EXTERNAL_URL_SCHEMES: ReadonlySet = new Set([ + "http:", + "https:", + "mailto:", +]); + +/** + * Whether a URL is safe to hand to the host's "open externally" capability, + * which ultimately reaches `shell.openExternal` and dispatches to whatever app + * the OS has registered for the scheme. Restricting to web and mail schemes + * stops a tampered or attacker-supplied value from triggering `file:`, `smb:`, + * `data:`, `javascript:`, `ms-msdt:`, or custom app deep-link schemes. + */ +export function isSafeExternalUrl(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + return SAFE_EXTERNAL_URL_SCHEMES.has(parsed.protocol); +}