From eab81522e0fcea570fc9af3e9e1ef53ead00b70c Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Fri, 10 Jul 2026 05:46:22 +0700 Subject: [PATCH 1/2] fix(electron): constrain renderer navigation --- electron/main.ts | 13 ++ electron/navigationPolicy.test.ts | 211 ++++++++++++++++++++++++++++++ electron/navigationPolicy.ts | 145 ++++++++++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 electron/navigationPolicy.test.ts create mode 100644 electron/navigationPolicy.ts diff --git a/electron/main.ts b/electron/main.ts index ed05ffeb6..2966cbd1c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -11,6 +11,7 @@ import { Notification, nativeImage, session, + shell, systemPreferences, Tray, } from "electron"; @@ -26,6 +27,10 @@ import { registerIpcHandlers, } from "./ipc/handlers"; import { ensureMediaServer } from "./mediaServer"; +import { + hardenWebContentsNavigation, + shouldHardenWebContentsType, +} from "./navigationPolicy"; import { ensurePackagedRendererServer } from "./rendererServer"; import type { UpdateToastPayload } from "./updater"; import { @@ -73,6 +78,14 @@ app.commandLine.appendSwitch("ignore-gpu-blocklist"); app.commandLine.appendSwitch("enable-unsafe-webgpu"); app.commandLine.appendSwitch("enable-gpu-rasterization"); +app.on("web-contents-created", (_event, contents) => { + if (!shouldHardenWebContentsType(contents.getType())) { + return; + } + + hardenWebContentsNavigation(contents, (url) => shell.openExternal(url)); +}); + function configureGpuAccelerationSwitches() { const { useAngle, useGl, disableFeatures } = getGpuSwitches(process.platform, process.env); if (useAngle) { diff --git a/electron/navigationPolicy.test.ts b/electron/navigationPolicy.test.ts new file mode 100644 index 000000000..f661315dd --- /dev/null +++ b/electron/navigationPolicy.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createWillNavigateHandler, + createWillRedirectHandler, + createWindowOpenHandler, + hardenWebContentsNavigation, + isInternalRendererTarget, + normalizeExternalHttpUrl, + shouldHardenWebContentsType, +} from "./navigationPolicy"; + +describe("normalizeExternalHttpUrl", () => { + it.each([ + ["https://example.com/docs", "https://example.com/docs"], + ["http://127.0.0.1:3000/path?q=1", "http://127.0.0.1:3000/path?q=1"], + ["HTTPS://Example.COM:443/docs", "https://example.com/docs"], + ])("normalizes an external HTTP(S) URL: %s", (value, expected) => { + expect(normalizeExternalHttpUrl(value)).toBe(expected); + }); + + it.each([ + "", + "not a URL", + "file:///tmp/recordly.html", + "data:text/html,hello", + "javascript:alert(1)", + "mailto:security@example.com", + "https://user:password@example.com/", + ])("rejects an unsafe external URL: %s", (value) => { + expect(normalizeExternalHttpUrl(value)).toBeNull(); + }); +}); + +describe("shouldHardenWebContentsType", () => { + it("selects BrowserWindow contents only", () => { + expect(shouldHardenWebContentsType("window")).toBe(true); + expect(shouldHardenWebContentsType("webview")).toBe(false); + expect(shouldHardenWebContentsType("offscreen")).toBe(false); + }); +}); + +describe("isInternalRendererTarget", () => { + it.each([ + ["http://localhost:5173/?windowType=editor", "http://localhost:5173/editor?reload=1"], + ["http://127.0.0.1:43123/?windowType=editor", "http://127.0.0.1:43123/assets/index.js"], + [ + "file:///opt/Recordly/dist/index.html?windowType=editor", + "file:///opt/Recordly/dist/index.html?windowType=hud-overlay#status", + ], + ])("identifies the current renderer origin/file", (currentUrl, targetUrl) => { + expect(isInternalRendererTarget(currentUrl, targetUrl)).toBe(true); + }); + + it.each([ + ["http://localhost:5173/?windowType=editor", "http://localhost.example.com:5173/"], + ["http://localhost:5173/", "http://localhost:5174/"], + ["https://recordly.example/", "http://recordly.example/"], + ["https://recordly.example/", "https://user:pass@recordly.example/"], + ["file:///opt/Recordly/dist/index.html", "file:///etc/passwd"], + ["file:///opt/Recordly/dist/index.html", "data:text/html,hello"], + ["not a URL", "https://example.com/"], + ])("distinguishes a target outside the current renderer", (currentUrl, targetUrl) => { + expect(isInternalRendererTarget(currentUrl, targetUrl)).toBe(false); + }); +}); + +describe("navigation event handlers", () => { + it("preserves an exact renderer reload", () => { + const preventDefault = vi.fn(); + const openExternal = vi.fn(async () => undefined); + const handler = createWillNavigateHandler( + () => "http://localhost:5173/editor?windowType=editor", + openExternal, + ); + + handler({ + url: "http://localhost:5173/editor?windowType=editor", + preventDefault, + }); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it("stops same-origin renderer navigation without externalizing it", () => { + const preventDefault = vi.fn(); + const openExternal = vi.fn(async () => undefined); + const handler = createWillNavigateHandler( + () => "http://localhost:5173/editor", + openExternal, + ); + + handler({ url: "http://localhost:5173/settings", preventDefault }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it("stops same-file query mutation without externalizing it", () => { + const preventDefault = vi.fn(); + const openExternal = vi.fn(async () => undefined); + const handler = createWillNavigateHandler( + () => "file:///opt/Recordly/dist/index.html?windowType=editor", + openExternal, + ); + + handler({ + url: "file:///opt/Recordly/dist/index.html?smokeExport=1", + preventDefault, + }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it("stops cross-origin navigation and opens HTTP(S) in the system browser", () => { + const preventDefault = vi.fn(); + const openExternal = vi.fn(async () => undefined); + const handler = createWillNavigateHandler( + () => "http://localhost:5173/editor", + openExternal, + ); + + handler({ url: "https://example.com/docs", preventDefault }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(openExternal).toHaveBeenCalledWith("https://example.com/docs"); + }); + + it("stops unsafe schemes without opening them externally", () => { + const preventDefault = vi.fn(); + const openExternal = vi.fn(async () => undefined); + const handler = createWillNavigateHandler( + () => "file:///opt/Recordly/dist/index.html", + openExternal, + ); + + handler({ url: "file:///etc/passwd", preventDefault }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it("stops server redirects without externalizing the target", () => { + const preventDefault = vi.fn(); + createWillRedirectHandler()({ url: "https://example.com/redirect", preventDefault }); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it("always denies Electron child windows while preserving safe external links", () => { + const openExternal = vi.fn(async () => undefined); + const handler = createWindowOpenHandler(() => "http://localhost:5173/editor", openExternal); + + expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" }); + expect(handler({ url: "http://localhost:5173/settings" })).toEqual({ action: "deny" }); + expect(handler({ url: "javascript:alert(1)" })).toEqual({ action: "deny" }); + expect(openExternal).toHaveBeenCalledOnce(); + expect(openExternal).toHaveBeenCalledWith("https://example.com/docs"); + }); + + it("reports a system-browser failure without allowing the child window", async () => { + const error = new Error("browser unavailable"); + const reportOpenError = vi.fn(); + const handler = createWindowOpenHandler( + () => "http://localhost:5173/editor", + vi.fn(async () => { + throw error; + }), + reportOpenError, + ); + + expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" }); + await vi.waitFor(() => { + expect(reportOpenError).toHaveBeenCalledWith("https://example.com/docs", error); + }); + }); + + it("reports a synchronous browser-launch failure while still denying the child", () => { + const error = new Error("browser launch threw"); + const reportOpenError = vi.fn(); + const handler = createWindowOpenHandler( + () => "http://localhost:5173/editor", + vi.fn(() => { + throw error; + }), + reportOpenError, + ); + + expect(handler({ url: "https://example.com/docs" })).toEqual({ action: "deny" }); + expect(reportOpenError).toHaveBeenCalledWith("https://example.com/docs", error); + }); + + it("attaches navigation, redirect, and window-open policies", () => { + const on = vi.fn(); + const setWindowOpenHandler = vi.fn(); + const webContents = { + getURL: () => "http://localhost:5173/", + on, + setWindowOpenHandler, + }; + + hardenWebContentsNavigation( + webContents, + vi.fn(async () => undefined), + ); + + expect(on).toHaveBeenCalledWith("will-navigate", expect.any(Function)); + expect(on).toHaveBeenCalledWith("will-redirect", expect.any(Function)); + expect(setWindowOpenHandler).toHaveBeenCalledWith(expect.any(Function)); + }); +}); diff --git a/electron/navigationPolicy.ts b/electron/navigationPolicy.ts new file mode 100644 index 000000000..82d9f3526 --- /dev/null +++ b/electron/navigationPolicy.ts @@ -0,0 +1,145 @@ +import type { WebContents } from "electron"; + +type OpenExternal = (url: string) => Promise; +type ReportOpenError = (url: string, error: unknown) => void; + +export type NavigationEvent = { + url: string; + preventDefault: () => void; +}; + +export function shouldHardenWebContentsType(type: ReturnType): boolean { + return type === "window"; +} + +export function normalizeExternalHttpUrl(value: string): string | null { + try { + const url = new URL(value); + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + !url.hostname || + url.username || + url.password + ) { + return null; + } + + return url.href; + } catch { + return null; + } +} + +export function isInternalRendererTarget(currentValue: string, targetValue: string): boolean { + try { + const currentUrl = new URL(currentValue); + const targetUrl = new URL(targetValue); + + if (targetUrl.username || targetUrl.password) { + return false; + } + + if ( + (currentUrl.protocol === "http:" || currentUrl.protocol === "https:") && + (targetUrl.protocol === "http:" || targetUrl.protocol === "https:") + ) { + return currentUrl.origin === targetUrl.origin; + } + + if (currentUrl.protocol === "file:" && targetUrl.protocol === "file:") { + return currentUrl.host === targetUrl.host && currentUrl.pathname === targetUrl.pathname; + } + + return currentUrl.href === targetUrl.href; + } catch { + return false; + } +} + +function isExactRendererLocation(currentValue: string, targetValue: string): boolean { + try { + const currentUrl = new URL(currentValue); + const targetUrl = new URL(targetValue); + return !targetUrl.username && !targetUrl.password && currentUrl.href === targetUrl.href; + } catch { + return false; + } +} + +function openExternalIfSafe( + value: string, + openExternal: OpenExternal, + reportOpenError: ReportOpenError, +): void { + const safeUrl = normalizeExternalHttpUrl(value); + if (!safeUrl) { + return; + } + + try { + void openExternal(safeUrl).catch((error) => reportOpenError(safeUrl, error)); + } catch (error) { + reportOpenError(safeUrl, error); + } +} + +const defaultReportOpenError: ReportOpenError = (url, error) => { + console.error("[navigation-policy] Failed to open external URL", { url, error }); +}; + +export function createWillNavigateHandler( + getCurrentUrl: () => string, + openExternal: OpenExternal, + reportOpenError: ReportOpenError = defaultReportOpenError, +) { + return (event: NavigationEvent): void => { + const currentUrl = getCurrentUrl(); + // Preserve an exact reload, but freeze all renderer-selected destination changes, + // including same-origin query mutations that can carry privileged local paths. + if (isExactRendererLocation(currentUrl, event.url)) { + return; + } + + // The internal-target check only prevents app URLs from leaking into the system browser. + event.preventDefault(); + if (isInternalRendererTarget(currentUrl, event.url)) { + return; + } + + openExternalIfSafe(event.url, openExternal, reportOpenError); + }; +} + +export function createWillRedirectHandler() { + return (event: NavigationEvent): void => { + event.preventDefault(); + }; +} + +export function createWindowOpenHandler( + getCurrentUrl: () => string, + openExternal: OpenExternal, + reportOpenError: ReportOpenError = defaultReportOpenError, +) { + return (details: { url: string }) => { + if (!isInternalRendererTarget(getCurrentUrl(), details.url)) { + openExternalIfSafe(details.url, openExternal, reportOpenError); + } + return { action: "deny" as const }; + }; +} + +export function hardenWebContentsNavigation( + webContents: Pick, + openExternal: OpenExternal, + reportOpenError: ReportOpenError = defaultReportOpenError, +): void { + webContents.on( + "will-navigate", + createWillNavigateHandler(() => webContents.getURL(), openExternal, reportOpenError), + ); + webContents.on("will-redirect", createWillRedirectHandler()); + webContents.setWindowOpenHandler( + createWindowOpenHandler(() => webContents.getURL(), openExternal, reportOpenError), + ); +} From eb6a7faeb4a63298c3e62a94889b34f0959270d8 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 11 Jul 2026 10:25:38 +0700 Subject: [PATCH 2/2] fix(electron): preserve trusted navigation location --- electron/navigationPolicy.test.ts | 54 +++++++++++++++++++++++++++++++ electron/navigationPolicy.ts | 16 ++++++--- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/electron/navigationPolicy.test.ts b/electron/navigationPolicy.test.ts index f661315dd..e021c96ab 100644 --- a/electron/navigationPolicy.test.ts +++ b/electron/navigationPolicy.test.ts @@ -206,6 +206,60 @@ describe("navigation event handlers", () => { expect(on).toHaveBeenCalledWith("will-navigate", expect.any(Function)); expect(on).toHaveBeenCalledWith("will-redirect", expect.any(Function)); + expect(on).toHaveBeenCalledWith("did-navigate", expect.any(Function)); expect(setWindowOpenHandler).toHaveBeenCalledWith(expect.any(Function)); }); + + it("does not trust a renderer-mutated URL as an exact reload", () => { + let currentUrl = "file:///opt/Recordly/dist/index.html?windowType=editor"; + const on = vi.fn(); + const webContents = { + getURL: () => currentUrl, + on, + setWindowOpenHandler: vi.fn(), + }; + const openExternal = vi.fn(async () => undefined); + + hardenWebContentsNavigation(webContents, openExternal); + + // history.replaceState() changes getURL() without crossing a document-navigation boundary. + currentUrl = "file:///opt/Recordly/dist/index.html?windowType=source-selector"; + const willNavigate = on.mock.calls.find(([eventName]) => eventName === "will-navigate")?.[1]; + if (typeof willNavigate !== "function") { + throw new Error("will-navigate handler was not registered"); + } + + const preventDefault = vi.fn(); + willNavigate({ url: currentUrl, preventDefault }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it("trusts an exact reload after a completed document navigation", () => { + const on = vi.fn(); + const webContents = { + getURL: () => "", + on, + setWindowOpenHandler: vi.fn(), + }; + + hardenWebContentsNavigation( + webContents, + vi.fn(async () => undefined), + ); + + const didNavigate = on.mock.calls.find(([eventName]) => eventName === "did-navigate")?.[1]; + const willNavigate = on.mock.calls.find(([eventName]) => eventName === "will-navigate")?.[1]; + if (typeof didNavigate !== "function" || typeof willNavigate !== "function") { + throw new Error("navigation handlers were not registered"); + } + + const loadedUrl = "file:///opt/Recordly/dist/index.html?windowType=editor"; + didNavigate({}, loadedUrl); + const preventDefault = vi.fn(); + willNavigate({ url: loadedUrl, preventDefault }); + + expect(preventDefault).not.toHaveBeenCalled(); + }); }); diff --git a/electron/navigationPolicy.ts b/electron/navigationPolicy.ts index 82d9f3526..1bc9df4ee 100644 --- a/electron/navigationPolicy.ts +++ b/electron/navigationPolicy.ts @@ -88,21 +88,21 @@ const defaultReportOpenError: ReportOpenError = (url, error) => { }; export function createWillNavigateHandler( - getCurrentUrl: () => string, + getTrustedRendererUrl: () => string, openExternal: OpenExternal, reportOpenError: ReportOpenError = defaultReportOpenError, ) { return (event: NavigationEvent): void => { - const currentUrl = getCurrentUrl(); + const trustedRendererUrl = getTrustedRendererUrl(); // Preserve an exact reload, but freeze all renderer-selected destination changes, // including same-origin query mutations that can carry privileged local paths. - if (isExactRendererLocation(currentUrl, event.url)) { + if (isExactRendererLocation(trustedRendererUrl, event.url)) { return; } // The internal-target check only prevents app URLs from leaking into the system browser. event.preventDefault(); - if (isInternalRendererTarget(currentUrl, event.url)) { + if (isInternalRendererTarget(trustedRendererUrl, event.url)) { return; } @@ -134,9 +134,15 @@ export function hardenWebContentsNavigation( openExternal: OpenExternal, reportOpenError: ReportOpenError = defaultReportOpenError, ): void { + // Renderer history APIs mutate getURL() without a document navigation. Keep the last + // main-frame document URL as the reload trust boundary instead of trusting that live value. + let trustedRendererUrl = webContents.getURL(); + webContents.on("did-navigate", (_event, url) => { + trustedRendererUrl = url; + }); webContents.on( "will-navigate", - createWillNavigateHandler(() => webContents.getURL(), openExternal, reportOpenError), + createWillNavigateHandler(() => trustedRendererUrl, openExternal, reportOpenError), ); webContents.on("will-redirect", createWillRedirectHandler()); webContents.setWindowOpenHandler(