From ba413010c80ec6788e7bf922e316ca9d42214e3b Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 12 Aug 2026 00:43:02 +0200 Subject: [PATCH] Fix Electron redirect and login boundary hardening Apply the central Electron navigation policy to will-redirect in addition to will-navigate so main and remote-login windows block hostile redirect targets with the same exact host rules. Make main-window webPreferences explicitly keep webSecurity enabled and insecure content disabled, and make allowlisted external URL opening await shell.openExternal so IPC returns false on denied or failed opens without unhandled rejections. Expand focused coverage with hostile redirect cases, controlled shell failure handling, and AllDebrid Web behavior tests for existing-session generation plus login-required browser-window retry flow. --- src/main/browser-security.ts | 47 ++++++++++++++++++--------- tests/alldebrid-web.test.ts | 58 ++++++++++++++++++++++++++++++++++ tests/browser-security.test.ts | 47 +++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 15 deletions(-) diff --git a/src/main/browser-security.ts b/src/main/browser-security.ts index 015f093..c3241e7 100644 --- a/src/main/browser-security.ts +++ b/src/main/browser-security.ts @@ -11,10 +11,10 @@ type NavigationEvent = { type SecurityWindow = { webContents: { - on: (event: "will-navigate", listener: (event: NavigationEvent, url: string) => void) => unknown; - setWindowOpenHandler: (handler: (details: { url: string }) => { action: "deny" }) => unknown; + on: unknown; + setWindowOpenHandler: unknown; session: { - setPermissionRequestHandler: (handler: (webContents: unknown, permission: string, callback: (allowed: boolean) => void) => void) => unknown; + setPermissionRequestHandler: unknown; }; }; }; @@ -50,6 +50,8 @@ export function createMainWindowWebPreferences(preload: string): WebPreferences contextIsolation: true, nodeIntegration: false, sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, preload }; } @@ -82,46 +84,61 @@ export function isAllowedHttpsUrl(rawUrl: string, hosts: readonly HttpsHostRule[ }); } -export function openAllowedExternalUrl(rawUrl: string, hosts: readonly HttpsHostRule[]): boolean { +export async function openAllowedExternalUrl(rawUrl: string, hosts: readonly HttpsHostRule[]): Promise { if (!isAllowedHttpsUrl(rawUrl, hosts)) { return false; } - void shell.openExternal(new URL(rawUrl).toString()); - return true; + try { + await shell.openExternal(new URL(rawUrl).toString()); + return true; + } catch { + return false; + } } export function applyMainWindowSecurity(window: SecurityWindow, options: MainWindowSecurityOptions): void { applyCommonSecurity(window, options.externalHosts); - window.webContents.on("will-navigate", (event, url) => { + const handleNavigation = (event: NavigationEvent, url: string): void => { if (isExpectedRendererUrl(url, options.rendererUrl)) { return; } event.preventDefault(); - openAllowedExternalUrl(url, options.externalHosts); - }); + void openAllowedExternalUrl(url, options.externalHosts); + }; + onNavigation(window, "will-navigate", handleNavigation); + onNavigation(window, "will-redirect", handleNavigation); } export function applyRemoteLoginSecurity(window: SecurityWindow, options: RemoteLoginSecurityOptions): void { applyCommonSecurity(window, options.externalHosts); - window.webContents.on("will-navigate", (event, url) => { + const handleNavigation = (event: NavigationEvent, url: string): void => { if (isAllowedHttpsUrl(url, options.providerHosts)) { return; } event.preventDefault(); - openAllowedExternalUrl(url, options.externalHosts); - }); + void openAllowedExternalUrl(url, options.externalHosts); + }; + onNavigation(window, "will-navigate", handleNavigation); + onNavigation(window, "will-redirect", handleNavigation); } function applyCommonSecurity(window: SecurityWindow, externalHosts: readonly HttpsHostRule[]): void { - window.webContents.setWindowOpenHandler((details) => { - openAllowedExternalUrl(details.url, externalHosts); + const setWindowOpenHandler = window.webContents.setWindowOpenHandler as (handler: (details: { url: string }) => { action: "deny" }) => unknown; + setWindowOpenHandler((details) => { + void openAllowedExternalUrl(details.url, externalHosts); return { action: "deny" }; }); - window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => { + const setPermissionRequestHandler = window.webContents.session.setPermissionRequestHandler as (handler: (webContents: unknown, permission: string, callback: (allowed: boolean) => void) => void) => unknown; + setPermissionRequestHandler((_webContents, _permission, callback) => { callback(false); }); } +function onNavigation(window: SecurityWindow, event: "will-navigate" | "will-redirect", listener: (event: NavigationEvent, url: string) => void): void { + const on = window.webContents.on as (event: "will-navigate" | "will-redirect", listener: (event: NavigationEvent, url: string) => void) => unknown; + on(event, listener); +} + function isExpectedRendererUrl(rawUrl: string, expectedUrl: string): boolean { try { const parsed = new URL(String(rawUrl || "")); diff --git a/tests/alldebrid-web.test.ts b/tests/alldebrid-web.test.ts index d38242f..c743e1a 100644 --- a/tests/alldebrid-web.test.ts +++ b/tests/alldebrid-web.test.ts @@ -3,10 +3,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { mockFromPartition, mockSession, + mockFetch, mockBrowserWindowCtor, mockLoadURL, mockShow, mockFocus, + mockClose, mockSetWindowOpenHandler, mockSetPermissionRequestHandler } = vi.hoisted(() => { @@ -59,10 +61,12 @@ const { clearStorageData, clearCache }, + mockFetch: fetch, mockBrowserWindowCtor: BrowserWindowCtor, mockLoadURL: loadURL, mockShow: show, mockFocus: focus, + mockClose: browserWindow.close, mockSetWindowOpenHandler: setWindowOpenHandler, mockSetPermissionRequestHandler: setPermissionRequestHandler }; @@ -112,4 +116,58 @@ describe("alldebrid-web", () => { expect(mockShow).toHaveBeenCalled(); expect(mockFocus).toHaveBeenCalled(); }); + + it("uses an existing AllDebrid Web session to unrestrict without opening a login window", async () => { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ + link: "https://alldebrid.direct/session-file.bin", + filename: "session-file.bin", + filesize: 9876 + }), { status: 200 })); + const fallback = new AllDebridWebFallback(() => true); + + const result = await fallback.unrestrict("https://rapidgator.net/file/session"); + + expect(result).toEqual({ + directUrl: "https://alldebrid.direct/session-file.bin", + fileName: "session-file.bin", + fileSize: 9876, + retriesUsed: 0 + }); + expect(mockBrowserWindowCtor).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[0]).toBe("https://alldebrid.com/service.php"); + expect(mockFetch.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ + method: "POST", + body: "link=https%3A%2F%2Frapidgator.net%2Ffile%2Fsession&nb=0&json=true&pw=" + })); + }); + + it("opens the login window after login_required and retries generation with the same session partition", async () => { + mockFetch + .mockResolvedValueOnce(new Response("login", { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + link: "https://alldebrid.direct/retry-file.bin", + filename: "retry-file.bin", + filesize: 12345 + }), { status: 200 })); + const fallback = new AllDebridWebFallback(() => true); + + const result = await fallback.unrestrict("https://rapidgator.net/file/retry"); + + expect(result).toEqual({ + directUrl: "https://alldebrid.direct/retry-file.bin", + fileName: "retry-file.bin", + fileSize: 12345, + retriesUsed: 0 + }); + expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1); + expect(mockLoadURL).toHaveBeenCalledWith("https://alldebrid.com/register/?from=de"); + expect(mockShow).toHaveBeenCalled(); + expect(mockFocus).toHaveBeenCalled(); + expect(mockSetWindowOpenHandler).toHaveBeenCalledTimes(1); + expect(mockSetPermissionRequestHandler).toHaveBeenCalledTimes(1); + expect(mockClose).toHaveBeenCalledTimes(1); + expect(mockFromPartition).toHaveBeenCalledWith("persist:alldebrid-web"); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); }); diff --git a/tests/browser-security.test.ts b/tests/browser-security.test.ts index a04b47b..ad8f8c3 100644 --- a/tests/browser-security.test.ts +++ b/tests/browser-security.test.ts @@ -18,6 +18,7 @@ import { createMainWindowWebPreferences, createRemoteLoginWebPreferences, isAllowedHttpsUrl, + openAllowedExternalUrl, type HttpsHostRule } from "../src/main/browser-security"; @@ -51,6 +52,11 @@ function createWindow() { webContentsHandlers.get("will-navigate")?.(event, url); return event; }, + redirect: (url: string) => { + const event = { preventDefault: vi.fn() }; + webContentsHandlers.get("will-redirect")?.(event, url); + return event; + }, openWindow: (url: string) => windowOpenHandler?.({ url }), requestPermission: (permission: string) => { const callback = vi.fn(); @@ -73,6 +79,8 @@ describe("browser-security", () => { contextIsolation: true, nodeIntegration: false, sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false, preload: "C:\\MDD\\preload.js" }); }); @@ -117,6 +125,22 @@ describe("browser-security", () => { expect(electron.openExternal).toHaveBeenCalledWith("https://github.com/Sucukdeluxe/multi-debrid-downloader"); }); + it("applies the main-window navigation policy to redirects", () => { + const harness = createWindow(); + applyMainWindowSecurity(harness.window, { + rendererUrl: "http://localhost:5180", + externalHosts: githubOnly + }); + + const allowed = harness.redirect("https://github.com/Sucukdeluxe/multi-debrid-downloader"); + const lookalike = harness.redirect("https://github.com.evil.example/Sucukdeluxe"); + + expect(allowed.preventDefault).toHaveBeenCalledTimes(1); + expect(lookalike.preventDefault).toHaveBeenCalledTimes(1); + expect(electron.openExternal).toHaveBeenCalledTimes(1); + expect(electron.openExternal).toHaveBeenCalledWith("https://github.com/Sucukdeluxe/multi-debrid-downloader"); + }); + it("denies local-file navigation outside the packaged main renderer file", () => { const harness = createWindow(); const rendererUrl = pathToFileURL(path.join("C:", "Program Files", "MDD", "resources", "app.asar", "build", "renderer", "index.html")).toString(); @@ -174,6 +198,29 @@ describe("browser-security", () => { expect(lookalike.preventDefault).toHaveBeenCalledTimes(1); }); + it("applies the remote-login navigation policy to redirects", () => { + const harness = createWindow(); + applyRemoteLoginSecurity(harness.window, { + providerHosts: realDebridProvider, + externalHosts: realDebridProvider + }); + + const provider = harness.redirect("https://real-debrid.com/apitoken"); + const subdomain = harness.redirect("https://api.real-debrid.com/oauth"); + const lookalike = harness.redirect("https://real-debrid.com.evil.example/login"); + + expect(provider.preventDefault).not.toHaveBeenCalled(); + expect(subdomain.preventDefault).not.toHaveBeenCalled(); + expect(lookalike.preventDefault).toHaveBeenCalledTimes(1); + expect(electron.openExternal).not.toHaveBeenCalled(); + }); + + it("returns false when allowed external URLs cannot be opened", async () => { + electron.openExternal.mockRejectedValueOnce(new Error("shell rejected")); + + await expect(openAllowedExternalUrl("https://github.com/Sucukdeluxe", githubOnly)).resolves.toBe(false); + }); + it("matches HTTPS allowlists without includes-based hostname shortcuts", () => { expect(isAllowedHttpsUrl("https://real-debrid.com/apitoken", realDebridProvider)).toBe(true); expect(isAllowedHttpsUrl("https://api.real-debrid.com/oauth", realDebridProvider)).toBe(true);