From 265498d62974a1bc871309f907de4cfd79f9bedc Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Wed, 12 Aug 2026 00:30:49 +0200 Subject: [PATCH] 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. --- src/main/all-debrid-web.ts | 19 +-- src/main/browser-security.ts | 136 +++++++++++++++++++ src/main/ipc-security.ts | 47 +++++++ src/main/main.ts | 233 ++++++++++++++++++--------------- src/main/realdebrid-web.ts | 19 +-- tests/alldebrid-web.test.ts | 115 ++++++++++++++++ tests/browser-security.test.ts | 184 ++++++++++++++++++++++++++ tests/ipc-security.test.ts | 66 ++++++++++ tests/realdebrid-web.test.ts | 111 ++++++++++------ 9 files changed, 765 insertions(+), 165 deletions(-) create mode 100644 src/main/browser-security.ts create mode 100644 src/main/ipc-security.ts create mode 100644 tests/alldebrid-web.test.ts create mode 100644 tests/browser-security.test.ts create mode 100644 tests/ipc-security.test.ts diff --git a/src/main/all-debrid-web.ts b/src/main/all-debrid-web.ts index 7517666..2e41bc6 100644 --- a/src/main/all-debrid-web.ts +++ b/src/main/all-debrid-web.ts @@ -1,7 +1,8 @@ import { BrowserWindow, session } from "electron"; -import { AllDebridHostInfo } from "../shared/types"; -import { UnrestrictedLink } from "./realdebrid"; -import { filenameFromUrl, sleep } from "./utils"; +import { AllDebridHostInfo } from "../shared/types"; +import { UnrestrictedLink } from "./realdebrid"; +import { filenameFromUrl, sleep } from "./utils"; +import { ALLDEBRID_LOGIN_HOSTS, applyRemoteLoginSecurity, createRemoteLoginWebPreferences } from "./browser-security"; const ALLDEBRID_BASE_URL = "https://alldebrid.com"; const ALLDEBRID_LOGIN_URL = `${ALLDEBRID_BASE_URL}/register/?from=de`; @@ -302,12 +303,12 @@ export class AllDebridWebFallback { minHeight: 760, autoHideMenuBar: true, title: "AllDebrid Web-Login", - webPreferences: { - partition, - contextIsolation: true, - nodeIntegration: false - } - }); + webPreferences: createRemoteLoginWebPreferences(partition) + }); + applyRemoteLoginSecurity(window, { + providerHosts: ALLDEBRID_LOGIN_HOSTS, + externalHosts: ALLDEBRID_LOGIN_HOSTS + }); window.setMenuBarVisibility(false); window.on("closed", () => { if (this.loginWindow === window) { diff --git a/src/main/browser-security.ts b/src/main/browser-security.ts new file mode 100644 index 0000000..015f093 --- /dev/null +++ b/src/main/browser-security.ts @@ -0,0 +1,136 @@ +import { shell, type WebPreferences } from "electron"; + +export type HttpsHostRule = { + hostname: string; + includeSubdomains?: boolean; +}; + +type NavigationEvent = { + preventDefault: () => void; +}; + +type SecurityWindow = { + webContents: { + on: (event: "will-navigate", listener: (event: NavigationEvent, url: string) => void) => unknown; + setWindowOpenHandler: (handler: (details: { url: string }) => { action: "deny" }) => unknown; + session: { + setPermissionRequestHandler: (handler: (webContents: unknown, permission: string, callback: (allowed: boolean) => void) => void) => unknown; + }; + }; +}; + +export type MainWindowSecurityOptions = { + rendererUrl: string; + externalHosts: readonly HttpsHostRule[]; +}; + +export type RemoteLoginSecurityOptions = { + providerHosts: readonly HttpsHostRule[]; + externalHosts: readonly HttpsHostRule[]; +}; + +export const MAIN_WINDOW_EXTERNAL_HOSTS: readonly HttpsHostRule[] = [ + { hostname: "github.com" }, + { hostname: "codeberg.org" }, + { hostname: "real-debrid.com", includeSubdomains: true }, + { hostname: "alldebrid.com", includeSubdomains: true }, + { hostname: "bestdebrid.com", includeSubdomains: true } +]; + +export const REALDEBRID_LOGIN_HOSTS: readonly HttpsHostRule[] = [ + { hostname: "real-debrid.com", includeSubdomains: true } +]; + +export const ALLDEBRID_LOGIN_HOSTS: readonly HttpsHostRule[] = [ + { hostname: "alldebrid.com", includeSubdomains: true } +]; + +export function createMainWindowWebPreferences(preload: string): WebPreferences { + return { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + preload + }; +} + +export function createRemoteLoginWebPreferences(partition: string): WebPreferences { + return { + partition, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + allowRunningInsecureContent: false + }; +} + +export function isAllowedHttpsUrl(rawUrl: string, hosts: readonly HttpsHostRule[]): boolean { + let parsed: URL; + try { + parsed = new URL(String(rawUrl || "")); + } catch { + return false; + } + if (parsed.protocol !== "https:") { + return false; + } + const hostname = parsed.hostname.toLowerCase(); + return hosts.some((host) => { + const expected = host.hostname.toLowerCase(); + return hostname === expected || (host.includeSubdomains === true && hostname.endsWith(`.${expected}`)); + }); +} + +export function openAllowedExternalUrl(rawUrl: string, hosts: readonly HttpsHostRule[]): boolean { + if (!isAllowedHttpsUrl(rawUrl, hosts)) { + return false; + } + void shell.openExternal(new URL(rawUrl).toString()); + return true; +} + +export function applyMainWindowSecurity(window: SecurityWindow, options: MainWindowSecurityOptions): void { + applyCommonSecurity(window, options.externalHosts); + window.webContents.on("will-navigate", (event, url) => { + if (isExpectedRendererUrl(url, options.rendererUrl)) { + return; + } + event.preventDefault(); + openAllowedExternalUrl(url, options.externalHosts); + }); +} + +export function applyRemoteLoginSecurity(window: SecurityWindow, options: RemoteLoginSecurityOptions): void { + applyCommonSecurity(window, options.externalHosts); + window.webContents.on("will-navigate", (event, url) => { + if (isAllowedHttpsUrl(url, options.providerHosts)) { + return; + } + event.preventDefault(); + openAllowedExternalUrl(url, options.externalHosts); + }); +} + +function applyCommonSecurity(window: SecurityWindow, externalHosts: readonly HttpsHostRule[]): void { + window.webContents.setWindowOpenHandler((details) => { + openAllowedExternalUrl(details.url, externalHosts); + return { action: "deny" }; + }); + window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => { + callback(false); + }); +} + +function isExpectedRendererUrl(rawUrl: string, expectedUrl: string): boolean { + try { + const parsed = new URL(String(rawUrl || "")); + const expected = new URL(String(expectedUrl || "")); + if (expected.protocol === "file:") { + return parsed.protocol === "file:" && parsed.host === expected.host && parsed.pathname === expected.pathname; + } + return parsed.origin === expected.origin; + } catch { + return false; + } +} diff --git a/src/main/ipc-security.ts b/src/main/ipc-security.ts new file mode 100644 index 0000000..998ca1c --- /dev/null +++ b/src/main/ipc-security.ts @@ -0,0 +1,47 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export type TrustedIpcOptions = { + isPackaged: boolean; + devServerUrl: string; + appPath: string; +}; + +type IpcSenderEvent = { + senderFrame?: { url: string } | null; + sender?: { getURL?: () => string }; +}; + +export function assertTrustedIpcSender(event: IpcSenderEvent, options: TrustedIpcOptions): void { + const senderUrl = String(event.senderFrame?.url || event.sender?.getURL?.() || ""); + if (!senderUrl || !isTrustedSenderUrl(senderUrl, options)) { + throw new Error("IPC-Absender ist nicht vertrauenswürdig"); + } +} + +function isTrustedSenderUrl(senderUrl: string, options: TrustedIpcOptions): boolean { + if (options.isPackaged) { + return isPackagedRendererUrl(senderUrl, options.appPath); + } + try { + const sender = new URL(senderUrl); + const expected = new URL(options.devServerUrl); + return sender.origin === expected.origin; + } catch { + return false; + } +} + +function isPackagedRendererUrl(senderUrl: string, appPath: string): boolean { + try { + const parsed = new URL(senderUrl); + if (parsed.protocol !== "file:") { + return false; + } + const senderPath = path.resolve(fileURLToPath(parsed)); + const rendererPath = path.resolve(appPath, "build", "renderer"); + return senderPath === rendererPath || senderPath.startsWith(rendererPath + path.sep); + } catch { + return false; + } +} diff --git a/src/main/main.ts b/src/main/main.ts index 2654223..79f044f 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,6 +1,7 @@ -import fs from "node:fs"; -import path from "node:path"; -import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, safeStorage, shell, Tray } from "electron"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, safeStorage, shell, Tray, type IpcMainEvent, type IpcMainInvokeEvent } from "electron"; import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, RendererSettingsUpdate, UpdateInstallProgress } from "../shared/types"; import { AppController } from "./app-controller"; import { IPC_CHANNELS } from "../shared/ipc"; @@ -18,6 +19,8 @@ import { isMdd2Backup } from "./backup-crypto"; import { validateAccountCommand, validateAccountCredentialCheckInput } from "./account-commands"; import { createRendererSettings } from "./renderer-state"; import { validateRendererSettingsUpdate } from "./renderer-settings"; +import { applyMainWindowSecurity, createMainWindowWebPreferences, MAIN_WINDOW_EXTERNAL_HOSTS, openAllowedExternalUrl } from "./browser-security"; +import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security"; function validateString(value: unknown, name: string): string { if (typeof value !== "string") { @@ -82,9 +85,39 @@ let controller: AppController; let pendingBackupImport: Buffer | null = null; const CLIPBOARD_MAX_TEXT_CHARS = 50_000; -function isDevMode(): boolean { - return process.env.NODE_ENV === "development"; -} +function isDevMode(): boolean { + return process.env.NODE_ENV === "development"; +} + +function getRendererFileUrl(): string { + return pathToFileURL(path.join(app.getAppPath(), "build", "renderer", "index.html")).toString(); +} + +function getTrustedRendererUrl(): string { + return isDevMode() ? DEV_SERVER_URL : getRendererFileUrl(); +} + +function getTrustedIpcOptions(): TrustedIpcOptions { + return { + isPackaged: !isDevMode(), + devServerUrl: DEV_SERVER_URL, + appPath: app.getAppPath() + }; +} + +function handleTrusted(channel: string, listener: (event: IpcMainInvokeEvent, ...args: TArgs) => unknown): void { + ipcMain.handle(channel, (event, ...args) => { + assertTrustedIpcSender(event, getTrustedIpcOptions()); + return listener(event, ...(args as TArgs)); + }); +} + +function onTrusted(channel: string, listener: (event: IpcMainEvent, ...args: TArgs) => void): void { + ipcMain.on(channel, (event, ...args) => { + assertTrustedIpcSender(event, getTrustedIpcOptions()); + listener(event, ...(args as TArgs)); + }); +} // Single owner of the scheduled-start timer. startOnPast: a past time entered // interactively starts right away; at boot a stale past time is cleared instead @@ -124,12 +157,13 @@ function createWindow(): BrowserWindow { backgroundColor: "#070b14", title: `${APP_NAME} - v${controller.getVersion()}`, icon: resolveAppIconPath(app.isPackaged, app.getAppPath(), process.resourcesPath), - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - preload: path.join(__dirname, "../preload/preload.js") - } - }); + webPreferences: createMainWindowWebPreferences(path.join(__dirname, "../preload/preload.js")) + }); + + applyMainWindowSecurity(window, { + rendererUrl: getTrustedRendererUrl(), + externalHosts: MAIN_WINDOW_EXTERNAL_HOSTS + }); if (!isDevMode()) { window.webContents.session.webRequest.onHeadersReceived((details, callback) => { @@ -332,10 +366,10 @@ function updateTray(): void { } function registerIpcHandlers(): void { - ipcMain.handle(IPC_CHANNELS.GET_SNAPSHOT, () => controller.getSnapshot()); - ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion()); - ipcMain.handle(IPC_CHANNELS.CHECK_UPDATES, async () => controller.checkUpdates()); - ipcMain.handle(IPC_CHANNELS.INSTALL_UPDATE, async () => { + handleTrusted(IPC_CHANNELS.GET_SNAPSHOT, () => controller.getSnapshot()); + handleTrusted(IPC_CHANNELS.GET_VERSION, () => controller.getVersion()); + handleTrusted(IPC_CHANNELS.CHECK_UPDATES, async () => controller.checkUpdates()); + handleTrusted(IPC_CHANNELS.INSTALL_UPDATE, async () => { const result = await controller.installUpdate((progress: UpdateInstallProgress) => { if (!mainWindow || mainWindow.isDestroyed()) { return; @@ -349,19 +383,10 @@ function registerIpcHandlers(): void { } return result; }); - ipcMain.handle(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => { - try { - const parsed = new URL(String(rawUrl || "").trim()); - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { - return false; - } - await shell.openExternal(parsed.toString()); - return true; - } catch { - return false; - } - }); - ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => { + handleTrusted(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => { + return openAllowedExternalUrl(String(rawUrl || "").trim(), MAIN_WINDOW_EXTERNAL_HOSTS); + }); + handleTrusted(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => { const validated = validateRendererSettingsUpdate(partial ?? {}, controller.getSettings()); const result = controller.updateSettings(validated as Partial); updateClipboardWatcher(); @@ -369,14 +394,14 @@ function registerIpcHandlers(): void { armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true }); return createRendererSettings(result); }); - ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => { + handleTrusted(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => { const validatedProvider = validateString(provider, "provider") as DebridProvider; if (!RESETTABLE_PROVIDER_KEYS.has(validatedProvider)) { throw new Error("provider ist ungültig"); } return createRendererSettings(controller.resetProviderDailyUsage(validatedProvider)); }); - ipcMain.handle(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, (_event: IpcMainInvokeEvent, keyId: string) => { + handleTrusted(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, (_event: IpcMainInvokeEvent, keyId: string) => { const validatedKeyId = validateString(keyId, "keyId").trim(); if (!validatedKeyId) { throw new Error("keyId ist ungültig"); @@ -384,30 +409,30 @@ function registerIpcHandlers(): void { return createRendererSettings(controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId)); }); - ipcMain.handle(IPC_CHANNELS.CREATE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { + handleTrusted(IPC_CHANNELS.CREATE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { const command = validateAccountCommand(rawCommand); if (command.action !== "create") throw new Error("Account-Payload ist ungültig"); return controller.executeAccountCommand(command); }); - ipcMain.handle(IPC_CHANNELS.REPLACE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { + handleTrusted(IPC_CHANNELS.REPLACE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { const command = validateAccountCommand(rawCommand); if (command.action !== "replace") throw new Error("Account-Payload ist ungültig"); return controller.executeAccountCommand(command); }); - ipcMain.handle(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { + handleTrusted(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { const command = validateAccountCommand(rawCommand); if (command.action !== "update-secret") throw new Error("Account-Payload ist ungültig"); return controller.executeAccountCommand(command); }); - ipcMain.handle(IPC_CHANNELS.DELETE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { + handleTrusted(IPC_CHANNELS.DELETE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => { const command = validateAccountCommand(rawCommand); if (command.action !== "delete") throw new Error("Account-Payload ist ungültig"); return controller.executeAccountCommand(command); }); - ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => { + handleTrusted(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => { validatePlainObject(payload ?? {}, "payload"); validateString(payload?.rawText, "rawText"); if (payload.packageName !== undefined) { @@ -418,13 +443,13 @@ function registerIpcHandlers(): void { } return controller.addLinks(payload); }); - ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => { + handleTrusted(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => { const validPaths = validateStringArray(filePaths ?? [], "filePaths"); const safePaths = validPaths.filter((p) => path.isAbsolute(p)); return controller.addContainers(safePaths); }); - ipcMain.handle(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts()); - ipcMain.handle(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => { + handleTrusted(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts()); + handleTrusted(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => { validateString(packageId, "packageId"); validateString(policy, "policy"); if (policy !== "keep" && policy !== "skip" && policy !== "overwrite") { @@ -432,8 +457,8 @@ function registerIpcHandlers(): void { } return controller.resolveStartConflict(packageId, policy); }); - ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll()); - ipcMain.handle(IPC_CHANNELS.START, () => { + handleTrusted(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll()); + handleTrusted(IPC_CHANNELS.START, () => { if (scheduledStartTimer !== null) { clearTimeout(scheduledStartTimer); scheduledStartTimer = null; @@ -441,21 +466,21 @@ function registerIpcHandlers(): void { } return controller.start(); }); - ipcMain.handle(IPC_CHANNELS.START_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => { + handleTrusted(IPC_CHANNELS.START_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => { validateStringArray(packageIds ?? [], "packageIds"); return controller.startPackages(packageIds ?? []); }); - ipcMain.handle(IPC_CHANNELS.START_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => { + handleTrusted(IPC_CHANNELS.START_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => { validateStringArray(itemIds ?? [], "itemIds"); return controller.startItems(itemIds ?? []); }); - ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop()); - ipcMain.handle(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause()); - ipcMain.handle(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { + handleTrusted(IPC_CHANNELS.STOP, () => controller.stop()); + handleTrusted(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause()); + handleTrusted(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.cancelPackage(packageId); }); - ipcMain.handle(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => { + handleTrusted(IPC_CHANNELS.RENAME_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string, newName: string) => { validateString(packageId, "packageId"); validateString(newName, "newName"); if (newName.length > RENAME_PACKAGE_MAX_CHARS) { @@ -463,19 +488,19 @@ function registerIpcHandlers(): void { } return controller.renamePackage(packageId, newName); }); - ipcMain.handle(IPC_CHANNELS.REORDER_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => { + handleTrusted(IPC_CHANNELS.REORDER_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => { validateStringArray(packageIds, "packageIds"); return controller.reorderPackages(packageIds); }); - ipcMain.handle(IPC_CHANNELS.REMOVE_ITEM, (_event: IpcMainInvokeEvent, itemId: string) => { + handleTrusted(IPC_CHANNELS.REMOVE_ITEM, (_event: IpcMainInvokeEvent, itemId: string) => { validateString(itemId, "itemId"); return controller.removeItem(itemId); }); - ipcMain.handle(IPC_CHANNELS.TOGGLE_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { + handleTrusted(IPC_CHANNELS.TOGGLE_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.togglePackage(packageId); }); - ipcMain.handle(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, async (_event: IpcMainInvokeEvent, packageIds: string[]) => { + handleTrusted(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, async (_event: IpcMainInvokeEvent, packageIds: string[]) => { const validPackageIds = validateStringArray(packageIds ?? [], "packageIds"); const exported = controller.exportPackageSelection(validPackageIds); if (exported.packageCount === 0 || exported.linkCount === 0) { @@ -492,7 +517,7 @@ function registerIpcHandlers(): void { await fs.promises.writeFile(result.filePath, exported.text, "utf8"); return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath }; }); - ipcMain.handle(IPC_CHANNELS.EXPORT_ITEM_SELECTION, async (_event: IpcMainInvokeEvent, itemIds: string[]) => { + handleTrusted(IPC_CHANNELS.EXPORT_ITEM_SELECTION, async (_event: IpcMainInvokeEvent, itemIds: string[]) => { const validItemIds = validateStringArray(itemIds ?? [], "itemIds"); const exported = controller.exportItemSelection(validItemIds); if (exported.packageCount === 0 || exported.linkCount === 0) { @@ -509,19 +534,19 @@ function registerIpcHandlers(): void { await fs.promises.writeFile(result.filePath, exported.text, "utf8"); return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath }; }); - ipcMain.handle(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => { + handleTrusted(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.retryExtraction(packageId); }); - ipcMain.handle(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, packageId: string) => { + handleTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.extractNow(packageId); }); - ipcMain.handle(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { + handleTrusted(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); return controller.resetPackage(packageId); }); - ipcMain.handle(IPC_CHANNELS.SET_PACKAGE_PRIORITY, (_event: IpcMainInvokeEvent, packageId: string, priority: string) => { + handleTrusted(IPC_CHANNELS.SET_PACKAGE_PRIORITY, (_event: IpcMainInvokeEvent, packageId: string, priority: string) => { validateString(packageId, "packageId"); validateString(priority, "priority"); if (priority !== "high" && priority !== "normal" && priority !== "low") { @@ -529,28 +554,28 @@ function registerIpcHandlers(): void { } return controller.setPackagePriority(packageId, priority); }); - ipcMain.handle(IPC_CHANNELS.SKIP_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => { + handleTrusted(IPC_CHANNELS.SKIP_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => { validateStringArray(itemIds ?? [], "itemIds"); return controller.skipItems(itemIds ?? []); }); - ipcMain.handle(IPC_CHANNELS.RESET_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => { + handleTrusted(IPC_CHANNELS.RESET_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => { validateStringArray(itemIds ?? [], "itemIds"); return controller.resetItems(itemIds ?? []); }); - ipcMain.handle(IPC_CHANNELS.GET_HISTORY, () => controller.getHistory()); - ipcMain.handle(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory()); - ipcMain.handle(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => { + handleTrusted(IPC_CHANNELS.GET_HISTORY, () => controller.getHistory()); + handleTrusted(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory()); + handleTrusted(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => { validateString(entryId, "entryId"); return controller.removeHistoryEntry(entryId); }); - ipcMain.handle(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: unknown) => { + handleTrusted(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: unknown) => { return revealHistoryEntry({ entryId }, { loadHistory: () => controller.getHistory(), stat: (directory) => fs.promises.stat(directory), openPath: (directory) => shell.openPath(directory) }); }); - ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, async () => { + handleTrusted(IPC_CHANNELS.EXPORT_QUEUE, async () => { const options = { defaultPath: `rd-queue-export.json`, filters: [{ name: "Queue Export", extensions: ["json"] }] @@ -563,7 +588,7 @@ function registerIpcHandlers(): void { await fs.promises.writeFile(result.filePath, json, "utf8"); return { saved: true }; }); - ipcMain.handle(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => { + handleTrusted(IPC_CHANNELS.IMPORT_QUEUE, (_event: IpcMainInvokeEvent, json: string) => { validateString(json, "json"); const bytes = Buffer.byteLength(json, "utf8"); if (bytes > IMPORT_QUEUE_MAX_BYTES) { @@ -571,21 +596,21 @@ function registerIpcHandlers(): void { } return controller.importQueue(json); }); - ipcMain.handle(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => { + handleTrusted(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => { const settings = controller.getSettings(); const next = !settings.clipboardWatch; controller.updateSettings({ clipboardWatch: next }); updateClipboardWatcher(); return next; }); - ipcMain.handle(IPC_CHANNELS.PICK_FOLDER, async () => { + handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => { const options = { properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory"> }; const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); return result.canceled ? null : result.filePaths[0] || null; }); - ipcMain.handle(IPC_CHANNELS.PICK_CONTAINERS, async () => { + handleTrusted(IPC_CHANNELS.PICK_CONTAINERS, async () => { const options = { properties: ["openFile", "multiSelections"] as Array<"openFile" | "multiSelections">, filters: [ @@ -596,20 +621,20 @@ function registerIpcHandlers(): void { const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options); return result.canceled ? [] : result.filePaths; }); - ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats()); - ipcMain.handle(IPC_CHANNELS.RESET_SESSION_STATS, () => controller.resetSessionStats()); - ipcMain.handle(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats()); + handleTrusted(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats()); + handleTrusted(IPC_CHANNELS.RESET_SESSION_STATS, () => controller.resetSessionStats()); + handleTrusted(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats()); - ipcMain.handle(IPC_CHANNELS.RESTART, () => { + handleTrusted(IPC_CHANNELS.RESTART, () => { app.relaunch(); app.quit(); }); - ipcMain.handle(IPC_CHANNELS.QUIT, () => { + handleTrusted(IPC_CHANNELS.QUIT, () => { app.quit(); }); - ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async (_event: IpcMainInvokeEvent, rawPassphrase: unknown) => { + handleTrusted(IPC_CHANNELS.EXPORT_BACKUP, async (_event: IpcMainInvokeEvent, rawPassphrase: unknown) => { const passphrase = validateString(rawPassphrase, "passphrase"); const options = { defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`, @@ -624,9 +649,9 @@ function registerIpcHandlers(): void { return { saved: true }; }); - ipcMain.handle(IPC_CHANNELS.EXPORT_ONLINE_BACKUP, async () => controller.exportOnlineBackup()); + handleTrusted(IPC_CHANNELS.EXPORT_ONLINE_BACKUP, async () => controller.exportOnlineBackup()); - ipcMain.handle(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, async (_event: IpcMainInvokeEvent, rawKey: unknown) => { + handleTrusted(IPC_CHANNELS.IMPORT_ONLINE_BACKUP, async (_event: IpcMainInvokeEvent, rawKey: unknown) => { const key = validateString(rawKey, "key").trim(); if (key.length > 128) { throw new Error("Online-Sicherungsschlüssel ist ungültig"); @@ -634,7 +659,7 @@ function registerIpcHandlers(): void { return controller.importOnlineBackup(key); }); - ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { + handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => { const options = { defaultPath: controller.getSupportBundleDefaultFileName(), filters: [{ name: "Support Bundle", extensions: ["zip"] }] @@ -648,40 +673,40 @@ function registerIpcHandlers(): void { return { saved: true, filePath: result.filePath }; }); - ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => { + handleTrusted(IPC_CHANNELS.OPEN_LOG, async () => { const logPath = getLogFilePath(); await shell.openPath(logPath); }); - ipcMain.handle(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => { + handleTrusted(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => { const logPath = controller.getAuditLogPath(); if (logPath) { await shell.openPath(logPath); } }); - ipcMain.handle(IPC_CHANNELS.OPEN_RENAME_LOG, async () => { + handleTrusted(IPC_CHANNELS.OPEN_RENAME_LOG, async () => { const logPath = controller.getRenameLogPath(); if (logPath) { await shell.openPath(logPath); } }); - ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => { + handleTrusted(IPC_CHANNELS.OPEN_SESSION_LOG, async () => { const logPath = controller.getSessionLogPath(); if (logPath) { await shell.openPath(logPath); } }); - ipcMain.handle(IPC_CHANNELS.OPEN_TRACE_LOG, async () => { + handleTrusted(IPC_CHANNELS.OPEN_TRACE_LOG, async () => { const logPath = controller.getTraceLogPath(); if (logPath) { await shell.openPath(logPath); } }); - ipcMain.handle(IPC_CHANNELS.OPEN_PACKAGE_LOG, async (_event: IpcMainInvokeEvent, packageId: string) => { + handleTrusted(IPC_CHANNELS.OPEN_PACKAGE_LOG, async (_event: IpcMainInvokeEvent, packageId: string) => { validateString(packageId, "packageId"); const logPath = controller.getPackageLogPath(packageId); if (logPath) { @@ -689,11 +714,11 @@ function registerIpcHandlers(): void { } }); - ipcMain.handle(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK, async () => controller.getDebugSetupCheck()); + handleTrusted(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK, async () => controller.getDebugSetupCheck()); - ipcMain.handle(IPC_CHANNELS.GET_RECENT_ERRORS, async () => getRecentErrors()); + handleTrusted(IPC_CHANNELS.GET_RECENT_ERRORS, async () => getRecentErrors()); - ipcMain.handle(IPC_CHANNELS.TEST_NOTIFY, async (_event: IpcMainInvokeEvent, url: string, mention: string) => { + handleTrusted(IPC_CHANNELS.TEST_NOTIFY, async (_event: IpcMainInvokeEvent, url: string, mention: string) => { validateString(url, "url"); return sendNotification(url, { title: "🔔 Test-Benachrichtigung", @@ -702,9 +727,9 @@ function registerIpcHandlers(): void { }); }); - ipcMain.handle(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig()); + handleTrusted(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig()); - ipcMain.handle(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string, durationMinutes?: number) => { + handleTrusted(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string, durationMinutes?: number) => { if (typeof enabled !== "boolean") { throw new Error("enabled muss ein Boolean sein"); } @@ -717,16 +742,16 @@ function registerIpcHandlers(): void { return controller.setTraceEnabled(enabled, note, durationMinutes ? durationMinutes * 60 * 1000 : undefined); }); - ipcMain.handle(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => { + handleTrusted(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => { const rotated = controller.rotateDebugToken(); return { path: rotated.path }; }); - ipcMain.handle(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => { + handleTrusted(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => { return controller.getRemoteDiagnostics(); }); - ipcMain.handle(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, async (_event: IpcMainInvokeEvent, input: EnableRemoteDiagnosticsInput) => { + handleTrusted(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, async (_event: IpcMainInvokeEvent, input: EnableRemoteDiagnosticsInput) => { if (!input || (input.hostMode !== "local" && input.hostMode !== "network")) { throw new Error("hostMode muss 'local' oder 'network' sein"); } @@ -741,15 +766,15 @@ function registerIpcHandlers(): void { }); }); - ipcMain.handle(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS, async () => { + handleTrusted(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS, async () => { return controller.disableRemoteDiagnostics(); }); - ipcMain.handle(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => { + handleTrusted(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => { return controller.rotateRemoteDiagnosticsToken(); }); - ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => { + handleTrusted(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => { validateString(itemId, "itemId"); const logPath = controller.getItemLogPath(itemId); if (logPath) { @@ -757,15 +782,15 @@ function registerIpcHandlers(): void { } }); - ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => { + handleTrusted(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => { await controller.openRealDebridLoginWindow(); }); - ipcMain.handle(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => { + handleTrusted(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => { await controller.openAllDebridLoginWindow(); }); - ipcMain.handle(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES, async () => { + handleTrusted(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES, async () => { const options = { properties: ["openFile"] as Array<"openFile">, filters: [ @@ -780,23 +805,23 @@ function registerIpcHandlers(): void { return controller.importBestDebridCookies(result.filePaths[0]); }); - ipcMain.handle(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO, async () => { + handleTrusted(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO, async () => { return controller.getAllDebridHostInfo(); }); - ipcMain.handle(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS, async () => { + handleTrusted(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS, async () => { return controller.getDebridLinkHostLimits(); }); - ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => { + handleTrusted(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => { return controller.checkDebridAccounts(); }); - ipcMain.handle(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, async (_event, rawInput: unknown) => { + handleTrusted(IPC_CHANNELS.CHECK_ACCOUNT_CREDENTIALS, async (_event, rawInput: unknown) => { return controller.checkAccountCredentials(validateAccountCredentialCheckInput(rawInput)); }); - ipcMain.handle(IPC_CHANNELS.SELECT_BACKUP_IMPORT, async () => { + handleTrusted(IPC_CHANNELS.SELECT_BACKUP_IMPORT, async () => { pendingBackupImport = null; const options = { properties: ["openFile"] as Array<"openFile">, @@ -821,11 +846,11 @@ function registerIpcHandlers(): void { return { selected: true, requiresPassphrase: isMdd2Backup(data) }; }); - ipcMain.handle(IPC_CHANNELS.CANCEL_BACKUP_IMPORT, () => { + handleTrusted(IPC_CHANNELS.CANCEL_BACKUP_IMPORT, () => { pendingBackupImport = null; }); - ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async (_event: IpcMainInvokeEvent, rawPassphrase?: unknown) => { + handleTrusted(IPC_CHANNELS.IMPORT_BACKUP, async (_event: IpcMainInvokeEvent, rawPassphrase?: unknown) => { const data = pendingBackupImport; pendingBackupImport = null; if (!data) { @@ -845,7 +870,7 @@ function registerIpcHandlers(): void { return importResult; }); - ipcMain.on(IPC_CHANNELS.LOG_RENDERER_ERROR, (_event, rawReport: unknown) => { + onTrusted(IPC_CHANNELS.LOG_RENDERER_ERROR, (_event, rawReport: unknown) => { try { logger.error(formatRendererErrorReport(rawReport)); } catch (error) { diff --git a/src/main/realdebrid-web.ts b/src/main/realdebrid-web.ts index 3e3ca3d..7732246 100644 --- a/src/main/realdebrid-web.ts +++ b/src/main/realdebrid-web.ts @@ -1,7 +1,8 @@ import { BrowserWindow, session } from "electron"; -import { UnrestrictedLink } from "./realdebrid"; -import { filenameFromUrl, sleep } from "./utils"; -import { API_BASE_URL, REQUEST_RETRIES } from "./constants"; +import { UnrestrictedLink } from "./realdebrid"; +import { filenameFromUrl, sleep } from "./utils"; +import { API_BASE_URL, REQUEST_RETRIES } from "./constants"; +import { applyRemoteLoginSecurity, createRemoteLoginWebPreferences, REALDEBRID_LOGIN_HOSTS } from "./browser-security"; const RD_BASE_URL = "https://real-debrid.com"; const RD_LOGIN_URL = RD_BASE_URL; @@ -217,12 +218,12 @@ export class RealDebridWebFallback { minHeight: 760, autoHideMenuBar: true, title: "Real-Debrid Web-Login", - webPreferences: { - partition, - contextIsolation: true, - nodeIntegration: false - } - }); + webPreferences: createRemoteLoginWebPreferences(partition) + }); + applyRemoteLoginSecurity(window, { + providerHosts: REALDEBRID_LOGIN_HOSTS, + externalHosts: REALDEBRID_LOGIN_HOSTS + }); window.setMenuBarVisibility(false); window.webContents.setUserAgent(RD_USER_AGENT); const primeFromWindow = (): void => { diff --git a/tests/alldebrid-web.test.ts b/tests/alldebrid-web.test.ts new file mode 100644 index 0000000..d38242f --- /dev/null +++ b/tests/alldebrid-web.test.ts @@ -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 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(); + }); +}); diff --git a/tests/browser-security.test.ts b/tests/browser-security.test.ts new file mode 100644 index 0000000..a04b47b --- /dev/null +++ b/tests/browser-security.test.ts @@ -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(); + 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); + }); +}); diff --git a/tests/ipc-security.test.ts b/tests/ipc-security.test.ts new file mode 100644 index 0000000..d47e800 --- /dev/null +++ b/tests/ipc-security.test.ts @@ -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"); + }); +}); diff --git a/tests/realdebrid-web.test.ts b/tests/realdebrid-web.test.ts index a3632e2..46d2b46 100644 --- a/tests/realdebrid-web.test.ts +++ b/tests/realdebrid-web.test.ts @@ -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 void> = {}; - const windowEvents: Record 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 void> = {}; + const windowEvents: Record 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();