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