Harden Electron trust boundaries
Add centralized browser security helpers for restrictive BrowserWindow profiles, navigation denial, popup denial, permission denial, and exact HTTPS host allowlists. Apply those policies to the main renderer window and to the Real-Debrid and AllDebrid browser-login windows without weakening the credential renderer boundary. Add shared IPC sender validation for renderer handlers so development accepts only the local Vite origin and non-development accepts only the built file renderer tree. Route the registered renderer IPC handlers through the shared guard and restrict app:open-external to the same exact HTTPS allowlist. Cover the hardening with focused red-green tests for untrusted navigation, popup handling, permission requests, exact host/subdomain matching, packaged file renderer boundaries, untrusted IPC senders, and both browser-login window profiles.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockFromPartition,
|
||||
mockSession,
|
||||
mockBrowserWindowCtor,
|
||||
mockLoadURL,
|
||||
mockShow,
|
||||
mockFocus,
|
||||
mockSetWindowOpenHandler,
|
||||
mockSetPermissionRequestHandler
|
||||
} = vi.hoisted(() => {
|
||||
const fetch = vi.fn();
|
||||
const clearStorageData = vi.fn();
|
||||
const clearCache = vi.fn();
|
||||
const fromPartition = vi.fn();
|
||||
const loadURL = vi.fn(async () => {});
|
||||
const show = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const setWindowOpenHandler = vi.fn();
|
||||
const setPermissionRequestHandler = vi.fn();
|
||||
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||
let destroyed = false;
|
||||
|
||||
const browserWindow = {
|
||||
isDestroyed: vi.fn(() => destroyed),
|
||||
isMinimized: vi.fn(() => false),
|
||||
restore: vi.fn(),
|
||||
show,
|
||||
focus,
|
||||
close: vi.fn(() => {
|
||||
destroyed = true;
|
||||
windowEvents.closed?.();
|
||||
}),
|
||||
setMenuBarVisibility: vi.fn(),
|
||||
loadURL,
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
windowEvents[event] = handler;
|
||||
return browserWindow;
|
||||
}),
|
||||
webContents: {
|
||||
setWindowOpenHandler,
|
||||
on: vi.fn(),
|
||||
session: {
|
||||
setPermissionRequestHandler
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const BrowserWindowCtor = vi.fn((_options: unknown) => {
|
||||
destroyed = false;
|
||||
return browserWindow;
|
||||
});
|
||||
|
||||
return {
|
||||
mockFromPartition: fromPartition,
|
||||
mockSession: {
|
||||
fetch,
|
||||
clearStorageData,
|
||||
clearCache
|
||||
},
|
||||
mockBrowserWindowCtor: BrowserWindowCtor,
|
||||
mockLoadURL: loadURL,
|
||||
mockShow: show,
|
||||
mockFocus: focus,
|
||||
mockSetWindowOpenHandler: setWindowOpenHandler,
|
||||
mockSetPermissionRequestHandler: setPermissionRequestHandler
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
session: {
|
||||
fromPartition: mockFromPartition
|
||||
},
|
||||
BrowserWindow: mockBrowserWindowCtor,
|
||||
shell: {
|
||||
openExternal: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import { AllDebridWebFallback } from "../src/main/all-debrid-web";
|
||||
|
||||
describe("alldebrid-web", () => {
|
||||
beforeEach(() => {
|
||||
mockFromPartition.mockReturnValue(mockSession);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFromPartition.mockReturnValue(mockSession);
|
||||
});
|
||||
|
||||
it("opens the AllDebrid login window with the shared restrictive browser boundary", async () => {
|
||||
const fallback = new AllDebridWebFallback(() => true);
|
||||
|
||||
await fallback.openLoginWindow();
|
||||
|
||||
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
|
||||
expect(mockBrowserWindowCtor.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
|
||||
webPreferences: {
|
||||
partition: "persist:alldebrid-web",
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false
|
||||
}
|
||||
}));
|
||||
expect(mockSetWindowOpenHandler).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetPermissionRequestHandler).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoadURL).toHaveBeenCalledWith("https://alldebrid.com/register/?from=de");
|
||||
expect(mockShow).toHaveBeenCalled();
|
||||
expect(mockFocus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
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,
|
||||
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;
|
||||
},
|
||||
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,
|
||||
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("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("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("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("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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertTrustedIpcSender } from "../src/main/ipc-security";
|
||||
|
||||
function eventFor(url: string) {
|
||||
return {
|
||||
senderFrame: { url },
|
||||
sender: {
|
||||
getURL: () => url
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("ipc-security", () => {
|
||||
it("accepts IPC from the configured Vite development renderer origin", () => {
|
||||
expect(() => assertTrustedIpcSender(eventFor("http://localhost:5180/settings"), {
|
||||
isPackaged: false,
|
||||
devServerUrl: "http://localhost:5180",
|
||||
appPath: "C:\\Program Files\\MDD"
|
||||
})).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects IPC from development-origin lookalikes", () => {
|
||||
expect(() => assertTrustedIpcSender(eventFor("http://localhost.evil.example:5180/settings"), {
|
||||
isPackaged: false,
|
||||
devServerUrl: "http://localhost:5180",
|
||||
appPath: "C:\\Program Files\\MDD"
|
||||
})).toThrow("IPC-Absender ist nicht vertrauenswürdig");
|
||||
});
|
||||
|
||||
it("accepts IPC from the packaged renderer file tree", () => {
|
||||
const appPath = path.join("C:", "Program Files", "MDD", "resources", "app.asar");
|
||||
const rendererUrl = pathToFileURL(path.join(appPath, "build", "renderer", "index.html")).toString();
|
||||
|
||||
expect(() => assertTrustedIpcSender(eventFor(rendererUrl), {
|
||||
isPackaged: true,
|
||||
devServerUrl: "http://localhost:5180",
|
||||
appPath
|
||||
})).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects IPC from local files outside the packaged renderer tree", () => {
|
||||
const appPath = path.join("C:", "Program Files", "MDD", "resources", "app.asar");
|
||||
const attackerUrl = pathToFileURL(path.join("C:", "Users", "Public", "attacker.html")).toString();
|
||||
|
||||
expect(() => assertTrustedIpcSender(eventFor(attackerUrl), {
|
||||
isPackaged: true,
|
||||
devServerUrl: "http://localhost:5180",
|
||||
appPath
|
||||
})).toThrow("IPC-Absender ist nicht vertrauenswürdig");
|
||||
});
|
||||
|
||||
it("rejects IPC when Electron provides no sender URL", () => {
|
||||
expect(() => assertTrustedIpcSender({
|
||||
senderFrame: null,
|
||||
sender: {
|
||||
getURL: () => ""
|
||||
}
|
||||
}, {
|
||||
isPackaged: false,
|
||||
devServerUrl: "http://localhost:5180",
|
||||
appPath: "C:\\Program Files\\MDD"
|
||||
})).toThrow("IPC-Absender ist nicht vertrauenswürdig");
|
||||
});
|
||||
});
|
||||
@@ -6,23 +6,27 @@ const {
|
||||
mockClearCache,
|
||||
mockFromPartition,
|
||||
mockBrowserWindow,
|
||||
mockBrowserWindowCtor,
|
||||
mockExecuteJavaScript,
|
||||
mockLoadURL,
|
||||
mockShow,
|
||||
mockFocus
|
||||
} = vi.hoisted(() => {
|
||||
const sessionFetch = vi.fn();
|
||||
const clearStorageData = vi.fn();
|
||||
const clearCache = vi.fn();
|
||||
const fromPartition = vi.fn();
|
||||
const executeJavaScript = vi.fn();
|
||||
const loadURL = vi.fn(async () => {});
|
||||
const show = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const webContentsEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||
let destroyed = false;
|
||||
mockBrowserWindowCtor,
|
||||
mockExecuteJavaScript,
|
||||
mockLoadURL,
|
||||
mockShow,
|
||||
mockFocus,
|
||||
mockSetWindowOpenHandler,
|
||||
mockSetPermissionRequestHandler
|
||||
} = vi.hoisted(() => {
|
||||
const sessionFetch = vi.fn();
|
||||
const clearStorageData = vi.fn();
|
||||
const clearCache = vi.fn();
|
||||
const fromPartition = vi.fn();
|
||||
const executeJavaScript = vi.fn();
|
||||
const loadURL = vi.fn(async () => {});
|
||||
const show = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const setWindowOpenHandler = vi.fn();
|
||||
const setPermissionRequestHandler = vi.fn();
|
||||
const webContentsEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||
let destroyed = false;
|
||||
|
||||
const browserWindow = {
|
||||
isDestroyed: vi.fn(() => destroyed),
|
||||
@@ -41,18 +45,22 @@ const {
|
||||
return browserWindow;
|
||||
}),
|
||||
webContents: {
|
||||
setUserAgent: vi.fn(),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
webContentsEvents[event] = handler;
|
||||
}),
|
||||
executeJavaScript
|
||||
}
|
||||
};
|
||||
setUserAgent: vi.fn(),
|
||||
setWindowOpenHandler,
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
webContentsEvents[event] = handler;
|
||||
}),
|
||||
executeJavaScript,
|
||||
session: {
|
||||
setPermissionRequestHandler
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const BrowserWindowCtor = vi.fn(() => {
|
||||
destroyed = false;
|
||||
return browserWindow;
|
||||
});
|
||||
const BrowserWindowCtor = vi.fn((_options: unknown) => {
|
||||
destroyed = false;
|
||||
return browserWindow;
|
||||
});
|
||||
|
||||
return {
|
||||
mockSessionFetch: sessionFetch,
|
||||
@@ -61,19 +69,24 @@ const {
|
||||
mockFromPartition: fromPartition,
|
||||
mockBrowserWindow: browserWindow,
|
||||
mockBrowserWindowCtor: BrowserWindowCtor,
|
||||
mockExecuteJavaScript: executeJavaScript,
|
||||
mockLoadURL: loadURL,
|
||||
mockShow: show,
|
||||
mockFocus: focus
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
session: {
|
||||
fromPartition: mockFromPartition
|
||||
},
|
||||
BrowserWindow: mockBrowserWindowCtor
|
||||
}));
|
||||
mockExecuteJavaScript: executeJavaScript,
|
||||
mockLoadURL: loadURL,
|
||||
mockShow: show,
|
||||
mockFocus: focus,
|
||||
mockSetWindowOpenHandler: setWindowOpenHandler,
|
||||
mockSetPermissionRequestHandler: setPermissionRequestHandler
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
session: {
|
||||
fromPartition: mockFromPartition
|
||||
},
|
||||
BrowserWindow: mockBrowserWindowCtor,
|
||||
shell: {
|
||||
openExternal: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web";
|
||||
|
||||
@@ -128,8 +141,20 @@ describe("realdebrid-web", () => {
|
||||
fileSize: 12345,
|
||||
retriesUsed: 0
|
||||
});
|
||||
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com");
|
||||
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
|
||||
expect(mockBrowserWindowCtor.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
|
||||
webPreferences: {
|
||||
partition: "persist:realdebrid-web",
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false
|
||||
}
|
||||
}));
|
||||
expect(mockSetWindowOpenHandler).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetPermissionRequestHandler).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com");
|
||||
expect(mockShow).toHaveBeenCalled();
|
||||
expect(mockFocus).toHaveBeenCalled();
|
||||
expect(mockSessionFetch).not.toHaveBeenCalled();
|
||||
|
||||
Reference in New Issue
Block a user