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);