From eb6a7faeb4a63298c3e62a94889b34f0959270d8 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Sat, 11 Jul 2026 10:25:38 +0700 Subject: [PATCH] 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 f661315d..e021c96a 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 82d9f352..1bc9df4e 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(