Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 1a54fb0

Browse files
committed
zach/chore: review feedback
1 parent f258e2e commit 1a54fb0

3 files changed

Lines changed: 143 additions & 45 deletions

File tree

‎apps/code/src/main/external-links.test.ts‎

Lines changed: 88 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

3-
const mockOpenExternal = vi.hoisted(() => vi.fn());
3+
const mockOpenExternal = vi.hoisted(() => vi.fn(() => Promise.resolve()));
44
const mockWarn = vi.hoisted(() => vi.fn());
55

66
vi.mock("electron", () => ({
@@ -26,7 +26,13 @@ type WillNavigateHandler = (
2626
url: string,
2727
) => void;
2828

29-
function setup() {
29+
// Packaged renderer served from a file: URL, and dev renderer from the Vite origin.
30+
const PROD_HOME = new URL(
31+
"file:///Applications/PostHog.app/resources/renderer/main_window/index.html",
32+
);
33+
const DEV_HOME = new URL("http://localhost:5173");
34+
35+
function setup(appHome: URL) {
3036
let windowOpenHandler: WindowOpenHandler | undefined;
3137
let willNavigateHandler: WillNavigateHandler | undefined;
3238
const window = {
@@ -41,6 +47,7 @@ function setup() {
4147
};
4248
setupExternalLinkHandlers(
4349
window as unknown as Parameters<typeof setupExternalLinkHandlers>[0],
50+
appHome,
4451
);
4552
if (!windowOpenHandler || !willNavigateHandler) {
4653
throw new Error("Handlers were not registered");
@@ -66,19 +73,14 @@ const UNSAFE_URLS = [
6673
"not a url",
6774
];
6875

69-
// In production the renderer is served from file://, so the will-navigate
70-
// handler treats file: URLs as in-app navigation rather than external links.
71-
const NON_FILE_UNSAFE_URLS = UNSAFE_URLS.filter(
72-
(url) => !url.startsWith("file:"),
73-
);
74-
7576
beforeEach(() => {
7677
vi.clearAllMocks();
78+
mockOpenExternal.mockImplementation(() => Promise.resolve());
7779
});
7880

7981
describe("window open handler", () => {
8082
it.each(SAFE_URLS)("opens %s externally and denies the window", (url) => {
81-
const { windowOpenHandler } = setup();
83+
const { windowOpenHandler } = setup(PROD_HOME);
8284

8385
const result = windowOpenHandler({ url });
8486

@@ -87,54 +89,109 @@ describe("window open handler", () => {
8789
});
8890

8991
it.each(UNSAFE_URLS)("blocks %s without opening it", (url) => {
90-
const { windowOpenHandler } = setup();
92+
const { windowOpenHandler } = setup(PROD_HOME);
9193

9294
const result = windowOpenHandler({ url });
9395

9496
expect(result).toEqual({ action: "deny" });
9597
expect(mockOpenExternal).not.toHaveBeenCalled();
9698
expect(mockWarn).toHaveBeenCalledOnce();
9799
});
100+
101+
it("swallows an openExternal rejection instead of leaving it unhandled", async () => {
102+
mockOpenExternal.mockImplementationOnce(() =>
103+
Promise.reject(new Error("no handler")),
104+
);
105+
const { windowOpenHandler } = setup(PROD_HOME);
106+
107+
windowOpenHandler({ url: "https://posthog.com" });
108+
await new Promise((resolve) => setTimeout(resolve, 0));
109+
110+
expect(mockWarn).toHaveBeenCalledOnce();
111+
});
98112
});
99113

100-
describe("will-navigate handler", () => {
101-
it.each(SAFE_URLS)(
102-
"prevents navigation to %s and opens it externally",
103-
(url) => {
104-
const { willNavigateHandler } = setup();
105-
const preventDefault = vi.fn();
114+
describe("will-navigate (packaged, file: home)", () => {
115+
it.each([
116+
"file:///Applications/PostHog.app/resources/renderer/main_window/index.html",
117+
"file:///Applications/PostHog.app/resources/renderer/main_window/index.html#/tasks/1",
118+
"file:///Applications/PostHog.app/resources/renderer/main_window/assets/app.js",
119+
])("treats in-app file %s as internal navigation", (url) => {
120+
const { willNavigateHandler } = setup(PROD_HOME);
121+
const preventDefault = vi.fn();
106122

107-
willNavigateHandler({ preventDefault }, url);
123+
willNavigateHandler({ preventDefault }, url);
108124

109-
expect(preventDefault).toHaveBeenCalledOnce();
110-
expect(mockOpenExternal).toHaveBeenCalledExactlyOnceWith(url);
111-
},
112-
);
125+
expect(preventDefault).not.toHaveBeenCalled();
126+
expect(mockOpenExternal).not.toHaveBeenCalled();
127+
});
113128

114-
it.each(NON_FILE_UNSAFE_URLS)(
115-
"prevents navigation to %s without opening it",
129+
it.each([
130+
"file:///etc/passwd",
131+
"file:///Applications/PostHog.app/resources/renderer/other/index.html",
132+
])("blocks out-of-app file %s (not opened externally either)", (url) => {
133+
const { willNavigateHandler } = setup(PROD_HOME);
134+
const preventDefault = vi.fn();
135+
136+
willNavigateHandler({ preventDefault }, url);
137+
138+
expect(preventDefault).toHaveBeenCalledOnce();
139+
expect(mockOpenExternal).not.toHaveBeenCalled();
140+
expect(mockWarn).toHaveBeenCalledOnce();
141+
});
142+
143+
it("routes an external https link to the browser", () => {
144+
const { willNavigateHandler } = setup(PROD_HOME);
145+
const preventDefault = vi.fn();
146+
147+
willNavigateHandler({ preventDefault }, "https://posthog.com");
148+
149+
expect(preventDefault).toHaveBeenCalledOnce();
150+
expect(mockOpenExternal).toHaveBeenCalledExactlyOnceWith(
151+
"https://posthog.com",
152+
);
153+
});
154+
});
155+
156+
describe("will-navigate (dev server, http: home)", () => {
157+
it.each(["http://localhost:5173/", "http://localhost:5173/sessions/42"])(
158+
"treats same-origin dev URL %s as internal navigation",
116159
(url) => {
117-
const { willNavigateHandler } = setup();
160+
const { willNavigateHandler } = setup(DEV_HOME);
118161
const preventDefault = vi.fn();
119162

120163
willNavigateHandler({ preventDefault }, url);
121164

122-
expect(preventDefault).toHaveBeenCalledOnce();
165+
expect(preventDefault).not.toHaveBeenCalled();
123166
expect(mockOpenExternal).not.toHaveBeenCalled();
124-
expect(mockWarn).toHaveBeenCalledOnce();
125167
},
126168
);
127169

170+
// The old startsWith check treated these as in-app, so an attacker origin
171+
// could load inside the app window. They must now be punted to the browser:
172+
// userinfo that resolves to another host, a longer port, and a scheme swap.
128173
it.each([
129-
"file:///app/.vite/renderer/main_window/index.html",
130-
"file:///etc/passwd",
131-
])("treats %s as in-app navigation and never opens it externally", (url) => {
132-
const { willNavigateHandler } = setup();
174+
"http://localhost:5173@evil.example/",
175+
"http://localhost:51730/",
176+
"https://localhost:5173/",
177+
])("does not treat lookalike origin %s as internal", (url) => {
178+
const { willNavigateHandler } = setup(DEV_HOME);
133179
const preventDefault = vi.fn();
134180

135181
willNavigateHandler({ preventDefault }, url);
136182

137-
expect(preventDefault).not.toHaveBeenCalled();
183+
expect(preventDefault).toHaveBeenCalledOnce();
184+
expect(mockOpenExternal).toHaveBeenCalledExactlyOnceWith(url);
185+
});
186+
187+
it("blocks an unsafe scheme in dev too", () => {
188+
const { willNavigateHandler } = setup(DEV_HOME);
189+
const preventDefault = vi.fn();
190+
191+
willNavigateHandler({ preventDefault }, "file:///etc/passwd");
192+
193+
expect(preventDefault).toHaveBeenCalledOnce();
138194
expect(mockOpenExternal).not.toHaveBeenCalled();
195+
expect(mockWarn).toHaveBeenCalledOnce();
139196
});
140197
});

‎apps/code/src/main/external-links.ts‎

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@ import { logger } from "./utils/logger";
44

55
const log = logger.scope("external-links");
66

7-
const MAIN_WINDOW_VITE_DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL;
8-
97
function urlScheme(url: string): string {
108
try {
119
return new URL(url).protocol;
@@ -25,20 +23,54 @@ function openExternalIfSafe(url: string): void {
2523
});
2624
return;
2725
}
28-
void shell.openExternal(url);
26+
// openExternal rejects when the OS has no handler for the scheme (or the user
27+
// dismisses the confirmation prompt on some platforms). Swallow it so a failed
28+
// open never surfaces as an unhandled rejection in the main process.
29+
shell.openExternal(url).catch((error) => {
30+
log.warn("shell.openExternal rejected", { scheme: urlScheme(url), error });
31+
});
2932
}
3033

31-
export function setupExternalLinkHandlers(window: BrowserWindow): void {
34+
// A navigation is "in-app" only when it targets the exact renderer origin (dev
35+
// server) or a file under the packaged renderer directory. Comparing parsed
36+
// URLs — rather than a startsWith prefix — stops lookalikes like
37+
// http://localhost:5173.evil.example or file:///etc/passwd from being treated
38+
// as internal and skipping the external-link scheme check below.
39+
function isInAppNavigation(target: string, appHome: URL): boolean {
40+
let parsed: URL;
41+
try {
42+
parsed = new URL(target);
43+
} catch {
44+
return false;
45+
}
46+
47+
if (appHome.protocol === "file:") {
48+
// file: origins are all opaque ("null"), so pin to the directory that holds
49+
// index.html instead of comparing origins.
50+
if (parsed.protocol !== "file:") return false;
51+
const appDir = appHome.pathname.slice(
52+
0,
53+
appHome.pathname.lastIndexOf("/") + 1,
54+
);
55+
return parsed.pathname.startsWith(appDir);
56+
}
57+
58+
// Dev server (http/https): pin scheme + host + port exactly.
59+
return parsed.origin === appHome.origin;
60+
}
61+
62+
export function setupExternalLinkHandlers(
63+
window: BrowserWindow,
64+
appHome: URL,
65+
): void {
3266
window.webContents.setWindowOpenHandler(({ url }) => {
3367
openExternalIfSafe(url);
3468
return { action: "deny" };
3569
});
3670

3771
window.webContents.on("will-navigate", (event, url) => {
38-
const appUrl = MAIN_WINDOW_VITE_DEV_SERVER_URL || "file://";
39-
if (!url.startsWith(appUrl)) {
40-
event.preventDefault();
41-
openExternalIfSafe(url);
42-
}
72+
if (isInAppNavigation(url, appHome)) return;
73+
event.preventDefault();
74+
openExternalIfSafe(url);
4375
});
4476
}

‎apps/code/src/main/window.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import path from "node:path";
2-
import { fileURLToPath } from "node:url";
2+
import { fileURLToPath, pathToFileURL } from "node:url";
33
import { createIPCHandler } from "@posthog/electron-trpc/main";
44
import { MAIN_WINDOW_SERVICE } from "@posthog/platform/main-window";
55
import { DARK_APP_BACKGROUND_COLOR } from "@posthog/shared/constants";
@@ -305,17 +305,26 @@ export function createWindow(): void {
305305
},
306306
});
307307

308-
setupExternalLinkHandlers(mainWindow);
308+
const rendererFilePath = path.join(
309+
__dirname,
310+
`../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`,
311+
);
312+
// The URL the renderer is served from, used to tell in-app navigations from
313+
// external links. In dev it's the Vite server origin; in prod it's the
314+
// packaged index.html file URL.
315+
const appHome = MAIN_WINDOW_VITE_DEV_SERVER_URL
316+
? new URL(MAIN_WINDOW_VITE_DEV_SERVER_URL)
317+
: pathToFileURL(rendererFilePath);
318+
319+
setupExternalLinkHandlers(mainWindow, appHome);
309320
setupEditableContextMenu(mainWindow);
310321
setupCrashLogging(mainWindow);
311322
buildApplicationMenu();
312323

313324
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
314325
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
315326
} else {
316-
mainWindow.loadFile(
317-
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
318-
);
327+
mainWindow.loadFile(rendererFilePath);
319328
}
320329

321330
mainWindow.on("closed", () => {

0 commit comments

Comments
 (0)