Add sanitized provider and account runtime diagnostics, complete the Multi-Debrid-Downloader product rename, preserve existing application data during migration, update public release verification, and publish the accompanying English documentation and regression coverage.
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const electron = vi.hoisted(() => ({
|
|
openExternal: vi.fn(async () => undefined)
|
|
}));
|
|
|
|
vi.mock("electron", () => ({
|
|
shell: {
|
|
openExternal: electron.openExternal
|
|
}
|
|
}));
|
|
|
|
import {
|
|
applyMainWindowSecurity,
|
|
applyRemoteLoginSecurity,
|
|
createMainWindowWebPreferences,
|
|
createRemoteLoginWebPreferences,
|
|
isAllowedHttpsUrl,
|
|
openAllowedExternalUrl,
|
|
type HttpsHostRule
|
|
} from "../src/main/browser-security";
|
|
|
|
type NavigationHandler = (event: { preventDefault: () => void }, url: string) => void;
|
|
type WindowOpenHandler = (details: { url: string }) => { action: "allow" | "deny" };
|
|
type PermissionHandler = (webContents: unknown, permission: string, callback: (allowed: boolean) => void) => void;
|
|
|
|
function createWindow() {
|
|
const webContentsHandlers = new Map<string, NavigationHandler>();
|
|
let windowOpenHandler: WindowOpenHandler | null = null;
|
|
let permissionHandler: PermissionHandler | null = null;
|
|
const window = {
|
|
webContents: {
|
|
on: vi.fn((event: string, handler: NavigationHandler) => {
|
|
webContentsHandlers.set(event, handler);
|
|
}),
|
|
setWindowOpenHandler: vi.fn((handler: WindowOpenHandler) => {
|
|
windowOpenHandler = handler;
|
|
}),
|
|
session: {
|
|
setPermissionRequestHandler: vi.fn((handler: PermissionHandler) => {
|
|
permissionHandler = handler;
|
|
})
|
|
}
|
|
}
|
|
};
|
|
return {
|
|
window,
|
|
navigate: (url: string) => {
|
|
const event = { preventDefault: vi.fn() };
|
|
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();
|
|
permissionHandler?.(window.webContents, permission, callback);
|
|
return callback;
|
|
}
|
|
};
|
|
}
|
|
|
|
describe("browser-security", () => {
|
|
const githubOnly: HttpsHostRule[] = [{ hostname: "github.com" }];
|
|
const realDebridProvider: HttpsHostRule[] = [{ hostname: "real-debrid.com", includeSubdomains: true }];
|
|
|
|
beforeEach(() => {
|
|
electron.openExternal.mockClear();
|
|
});
|
|
|
|
it("creates a restrictive main-window webPreferences profile with the existing preload", () => {
|
|
expect(createMainWindowWebPreferences("C:\\MDD\\preload.js")).toEqual({
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: true,
|
|
webSecurity: true,
|
|
allowRunningInsecureContent: false,
|
|
preload: "C:\\MDD\\preload.js"
|
|
});
|
|
});
|
|
|
|
it("creates a restrictive remote-login webPreferences profile for the requested partition", () => {
|
|
expect(createRemoteLoginWebPreferences("persist:realdebrid-web")).toEqual({
|
|
partition: "persist:realdebrid-web",
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: true,
|
|
webSecurity: true,
|
|
allowRunningInsecureContent: false
|
|
});
|
|
});
|
|
|
|
it("keeps native WebContents receivers when registering main-window security", () => {
|
|
const listeners = new Map<string, NavigationHandler>();
|
|
const webContents = {
|
|
on(this: unknown, event: string, listener: NavigationHandler) {
|
|
if (this !== webContents) {
|
|
throw new TypeError("WebContents receiver missing");
|
|
}
|
|
listeners.set(event, listener);
|
|
},
|
|
setWindowOpenHandler(this: unknown, _handler: WindowOpenHandler) {
|
|
if (this !== webContents) {
|
|
throw new TypeError("WebContents receiver missing");
|
|
}
|
|
},
|
|
session: {
|
|
setPermissionRequestHandler(this: unknown, _handler: PermissionHandler) {
|
|
if (this !== webContents.session) {
|
|
throw new TypeError("Session receiver missing");
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
expect(() => applyMainWindowSecurity({ webContents }, {
|
|
rendererUrl: "http://localhost:5180",
|
|
externalHosts: githubOnly
|
|
})).not.toThrow();
|
|
expect(listeners.has("will-navigate")).toBe(true);
|
|
expect(listeners.has("will-redirect")).toBe(true);
|
|
});
|
|
|
|
it("denies main-window navigation to an untrusted origin", () => {
|
|
const harness = createWindow();
|
|
applyMainWindowSecurity(harness.window, {
|
|
rendererUrl: "http://localhost:5180",
|
|
externalHosts: githubOnly
|
|
});
|
|
|
|
const event = harness.navigate("https://evil.example/app");
|
|
|
|
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
|
expect(electron.openExternal).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("opens only exact allowlisted HTTPS external URLs from denied main-window navigation", () => {
|
|
const harness = createWindow();
|
|
applyMainWindowSecurity(harness.window, {
|
|
rendererUrl: "http://localhost:5180",
|
|
externalHosts: githubOnly
|
|
});
|
|
|
|
const allowed = harness.navigate("https://github.com/Sucukdeluxe/Multi-Debrid-Downloader");
|
|
const lookalike = harness.navigate("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("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();
|
|
const attackerUrl = pathToFileURL(path.join("C:", "Users", "Public", "attacker.html")).toString();
|
|
applyMainWindowSecurity(harness.window, {
|
|
rendererUrl,
|
|
externalHosts: githubOnly
|
|
});
|
|
|
|
const renderer = harness.navigate(rendererUrl);
|
|
const attacker = harness.navigate(attackerUrl);
|
|
|
|
expect(renderer.preventDefault).not.toHaveBeenCalled();
|
|
expect(attacker.preventDefault).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("allows the packaged renderer file despite Windows path casing differences", () => {
|
|
const harness = createWindow();
|
|
const rendererUrl = "file:///C:/Program%20Files/MDD/resources/app.asar/build/renderer/index.html";
|
|
applyMainWindowSecurity(harness.window, {
|
|
rendererUrl,
|
|
externalHosts: githubOnly
|
|
});
|
|
|
|
const renderer = harness.navigate("file:///c:/program%20files/mdd/resources/app.asar/build/renderer/index.html");
|
|
|
|
expect(renderer.preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("denies popups while allowing exact allowlisted HTTPS popup URLs through the shell", () => {
|
|
const harness = createWindow();
|
|
applyMainWindowSecurity(harness.window, {
|
|
rendererUrl: "http://localhost:5180",
|
|
externalHosts: githubOnly
|
|
});
|
|
|
|
expect(harness.openWindow("https://github.com/Sucukdeluxe")).toEqual({ action: "deny" });
|
|
expect(harness.openWindow("https://github.com.evil.example/Sucukdeluxe")).toEqual({ action: "deny" });
|
|
expect(electron.openExternal).toHaveBeenCalledTimes(1);
|
|
expect(electron.openExternal).toHaveBeenCalledWith("https://github.com/Sucukdeluxe");
|
|
});
|
|
|
|
it("denies permission requests centrally", () => {
|
|
const harness = createWindow();
|
|
applyMainWindowSecurity(harness.window, {
|
|
rendererUrl: "http://localhost:5180",
|
|
externalHosts: githubOnly
|
|
});
|
|
|
|
const callback = harness.requestPermission("media");
|
|
|
|
expect(callback).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("allows remote-login navigation only to exact provider hostnames or their subdomains", () => {
|
|
const harness = createWindow();
|
|
applyRemoteLoginSecurity(harness.window, {
|
|
providerHosts: realDebridProvider,
|
|
externalHosts: realDebridProvider
|
|
});
|
|
|
|
const provider = harness.navigate("https://real-debrid.com/apitoken");
|
|
const subdomain = harness.navigate("https://api.real-debrid.com/oauth");
|
|
const lookalike = harness.navigate("https://real-debrid.com.evil.example/login");
|
|
|
|
expect(provider.preventDefault).not.toHaveBeenCalled();
|
|
expect(subdomain.preventDefault).not.toHaveBeenCalled();
|
|
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);
|
|
expect(isAllowedHttpsUrl("http://real-debrid.com/apitoken", realDebridProvider)).toBe(false);
|
|
expect(isAllowedHttpsUrl("https://real-debrid.com.evil.example/apitoken", realDebridProvider)).toBe(false);
|
|
expect(isAllowedHttpsUrl("https://evil-real-debrid.com/apitoken", realDebridProvider)).toBe(false);
|
|
});
|
|
});
|