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.
This commit is contained in:
Sucukdeluxe
2026-08-12 00:43:02 +02:00
parent 265498d629
commit ba413010c8
3 changed files with 137 additions and 15 deletions
+32 -15
View File
@@ -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<boolean> {
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 || ""));
+58
View File
@@ -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);
});
});
+47
View File
@@ -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);