Harden Electron trust boundaries
Add centralized browser security helpers for restrictive BrowserWindow profiles, navigation denial, popup denial, permission denial, and exact HTTPS host allowlists. Apply those policies to the main renderer window and to the Real-Debrid and AllDebrid browser-login windows without weakening the credential renderer boundary. Add shared IPC sender validation for renderer handlers so development accepts only the local Vite origin and non-development accepts only the built file renderer tree. Route the registered renderer IPC handlers through the shared guard and restrict app:open-external to the same exact HTTPS allowlist. Cover the hardening with focused red-green tests for untrusted navigation, popup handling, permission requests, exact host/subdomain matching, packaged file renderer boundaries, untrusted IPC senders, and both browser-login window profiles.
This commit is contained in:
@@ -2,6 +2,7 @@ import { BrowserWindow, session } from "electron";
|
||||
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,11 +303,11 @@ 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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+122
-97
@@ -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 { 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") {
|
||||
@@ -86,6 +89,36 @@ 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<TArgs extends unknown[]>(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<TArgs extends unknown[]>(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
|
||||
// (an unattended auto-start at boot would race autoResumeOnStart's conflict gate).
|
||||
@@ -124,11 +157,12 @@ 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()) {
|
||||
@@ -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;
|
||||
}
|
||||
handleTrusted(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => {
|
||||
return openAllowedExternalUrl(String(rawUrl || "").trim(), MAIN_WINDOW_EXTERNAL_HOSTS);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => {
|
||||
handleTrusted(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => {
|
||||
const validated = validateRendererSettingsUpdate(partial ?? {}, controller.getSettings());
|
||||
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
||||
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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BrowserWindow, session } from "electron";
|
||||
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,11 +218,11 @@ 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);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockFromPartition,
|
||||
mockSession,
|
||||
mockBrowserWindowCtor,
|
||||
mockLoadURL,
|
||||
mockShow,
|
||||
mockFocus,
|
||||
mockSetWindowOpenHandler,
|
||||
mockSetPermissionRequestHandler
|
||||
} = vi.hoisted(() => {
|
||||
const fetch = vi.fn();
|
||||
const clearStorageData = vi.fn();
|
||||
const clearCache = vi.fn();
|
||||
const fromPartition = vi.fn();
|
||||
const loadURL = vi.fn(async () => {});
|
||||
const show = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const setWindowOpenHandler = vi.fn();
|
||||
const setPermissionRequestHandler = vi.fn();
|
||||
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||
let destroyed = false;
|
||||
|
||||
const browserWindow = {
|
||||
isDestroyed: vi.fn(() => destroyed),
|
||||
isMinimized: vi.fn(() => false),
|
||||
restore: vi.fn(),
|
||||
show,
|
||||
focus,
|
||||
close: vi.fn(() => {
|
||||
destroyed = true;
|
||||
windowEvents.closed?.();
|
||||
}),
|
||||
setMenuBarVisibility: vi.fn(),
|
||||
loadURL,
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
windowEvents[event] = handler;
|
||||
return browserWindow;
|
||||
}),
|
||||
webContents: {
|
||||
setWindowOpenHandler,
|
||||
on: vi.fn(),
|
||||
session: {
|
||||
setPermissionRequestHandler
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const BrowserWindowCtor = vi.fn((_options: unknown) => {
|
||||
destroyed = false;
|
||||
return browserWindow;
|
||||
});
|
||||
|
||||
return {
|
||||
mockFromPartition: fromPartition,
|
||||
mockSession: {
|
||||
fetch,
|
||||
clearStorageData,
|
||||
clearCache
|
||||
},
|
||||
mockBrowserWindowCtor: BrowserWindowCtor,
|
||||
mockLoadURL: loadURL,
|
||||
mockShow: show,
|
||||
mockFocus: focus,
|
||||
mockSetWindowOpenHandler: setWindowOpenHandler,
|
||||
mockSetPermissionRequestHandler: setPermissionRequestHandler
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
session: {
|
||||
fromPartition: mockFromPartition
|
||||
},
|
||||
BrowserWindow: mockBrowserWindowCtor,
|
||||
shell: {
|
||||
openExternal: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import { AllDebridWebFallback } from "../src/main/all-debrid-web";
|
||||
|
||||
describe("alldebrid-web", () => {
|
||||
beforeEach(() => {
|
||||
mockFromPartition.mockReturnValue(mockSession);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFromPartition.mockReturnValue(mockSession);
|
||||
});
|
||||
|
||||
it("opens the AllDebrid login window with the shared restrictive browser boundary", async () => {
|
||||
const fallback = new AllDebridWebFallback(() => true);
|
||||
|
||||
await fallback.openLoginWindow();
|
||||
|
||||
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
|
||||
expect(mockBrowserWindowCtor.mock.calls[0]?.[0]).toEqual(expect.objectContaining({
|
||||
webPreferences: {
|
||||
partition: "persist:alldebrid-web",
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false
|
||||
}
|
||||
}));
|
||||
expect(mockSetWindowOpenHandler).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetPermissionRequestHandler).toHaveBeenCalledTimes(1);
|
||||
expect(mockLoadURL).toHaveBeenCalledWith("https://alldebrid.com/register/?from=de");
|
||||
expect(mockShow).toHaveBeenCalled();
|
||||
expect(mockFocus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const electron = vi.hoisted(() => ({
|
||||
openExternal: vi.fn(async () => undefined)
|
||||
}));
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
shell: {
|
||||
openExternal: electron.openExternal
|
||||
}
|
||||
}));
|
||||
|
||||
import {
|
||||
applyMainWindowSecurity,
|
||||
applyRemoteLoginSecurity,
|
||||
createMainWindowWebPreferences,
|
||||
createRemoteLoginWebPreferences,
|
||||
isAllowedHttpsUrl,
|
||||
type HttpsHostRule
|
||||
} from "../src/main/browser-security";
|
||||
|
||||
type NavigationHandler = (event: { preventDefault: () => void }, url: string) => void;
|
||||
type WindowOpenHandler = (details: { url: string }) => { action: "allow" | "deny" };
|
||||
type PermissionHandler = (webContents: unknown, permission: string, callback: (allowed: boolean) => void) => void;
|
||||
|
||||
function createWindow() {
|
||||
const webContentsHandlers = new Map<string, NavigationHandler>();
|
||||
let windowOpenHandler: WindowOpenHandler | null = null;
|
||||
let permissionHandler: PermissionHandler | null = null;
|
||||
const window = {
|
||||
webContents: {
|
||||
on: vi.fn((event: string, handler: NavigationHandler) => {
|
||||
webContentsHandlers.set(event, handler);
|
||||
}),
|
||||
setWindowOpenHandler: vi.fn((handler: WindowOpenHandler) => {
|
||||
windowOpenHandler = handler;
|
||||
}),
|
||||
session: {
|
||||
setPermissionRequestHandler: vi.fn((handler: PermissionHandler) => {
|
||||
permissionHandler = handler;
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
window,
|
||||
navigate: (url: string) => {
|
||||
const event = { preventDefault: vi.fn() };
|
||||
webContentsHandlers.get("will-navigate")?.(event, url);
|
||||
return event;
|
||||
},
|
||||
openWindow: (url: string) => windowOpenHandler?.({ url }),
|
||||
requestPermission: (permission: string) => {
|
||||
const callback = vi.fn();
|
||||
permissionHandler?.(window.webContents, permission, callback);
|
||||
return callback;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("browser-security", () => {
|
||||
const githubOnly: HttpsHostRule[] = [{ hostname: "github.com" }];
|
||||
const realDebridProvider: HttpsHostRule[] = [{ hostname: "real-debrid.com", includeSubdomains: true }];
|
||||
|
||||
beforeEach(() => {
|
||||
electron.openExternal.mockClear();
|
||||
});
|
||||
|
||||
it("creates a restrictive main-window webPreferences profile with the existing preload", () => {
|
||||
expect(createMainWindowWebPreferences("C:\\MDD\\preload.js")).toEqual({
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
preload: "C:\\MDD\\preload.js"
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a restrictive remote-login webPreferences profile for the requested partition", () => {
|
||||
expect(createRemoteLoginWebPreferences("persist:realdebrid-web")).toEqual({
|
||||
partition: "persist:realdebrid-web",
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
allowRunningInsecureContent: false
|
||||
});
|
||||
});
|
||||
|
||||
it("denies main-window navigation to an untrusted origin", () => {
|
||||
const harness = createWindow();
|
||||
applyMainWindowSecurity(harness.window, {
|
||||
rendererUrl: "http://localhost:5180",
|
||||
externalHosts: githubOnly
|
||||
});
|
||||
|
||||
const event = harness.navigate("https://evil.example/app");
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(electron.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens only exact allowlisted HTTPS external URLs from denied main-window navigation", () => {
|
||||
const harness = createWindow();
|
||||
applyMainWindowSecurity(harness.window, {
|
||||
rendererUrl: "http://localhost:5180",
|
||||
externalHosts: githubOnly
|
||||
});
|
||||
|
||||
const allowed = harness.navigate("https://github.com/Sucukdeluxe/multi-debrid-downloader");
|
||||
const lookalike = harness.navigate("https://github.com.evil.example/Sucukdeluxe");
|
||||
|
||||
expect(allowed.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(lookalike.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(electron.openExternal).toHaveBeenCalledTimes(1);
|
||||
expect(electron.openExternal).toHaveBeenCalledWith("https://github.com/Sucukdeluxe/multi-debrid-downloader");
|
||||
});
|
||||
|
||||
it("denies local-file navigation outside the packaged main renderer file", () => {
|
||||
const harness = createWindow();
|
||||
const rendererUrl = pathToFileURL(path.join("C:", "Program Files", "MDD", "resources", "app.asar", "build", "renderer", "index.html")).toString();
|
||||
const attackerUrl = pathToFileURL(path.join("C:", "Users", "Public", "attacker.html")).toString();
|
||||
applyMainWindowSecurity(harness.window, {
|
||||
rendererUrl,
|
||||
externalHosts: githubOnly
|
||||
});
|
||||
|
||||
const renderer = harness.navigate(rendererUrl);
|
||||
const attacker = harness.navigate(attackerUrl);
|
||||
|
||||
expect(renderer.preventDefault).not.toHaveBeenCalled();
|
||||
expect(attacker.preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("denies popups while allowing exact allowlisted HTTPS popup URLs through the shell", () => {
|
||||
const harness = createWindow();
|
||||
applyMainWindowSecurity(harness.window, {
|
||||
rendererUrl: "http://localhost:5180",
|
||||
externalHosts: githubOnly
|
||||
});
|
||||
|
||||
expect(harness.openWindow("https://github.com/Sucukdeluxe")).toEqual({ action: "deny" });
|
||||
expect(harness.openWindow("https://github.com.evil.example/Sucukdeluxe")).toEqual({ action: "deny" });
|
||||
expect(electron.openExternal).toHaveBeenCalledTimes(1);
|
||||
expect(electron.openExternal).toHaveBeenCalledWith("https://github.com/Sucukdeluxe");
|
||||
});
|
||||
|
||||
it("denies permission requests centrally", () => {
|
||||
const harness = createWindow();
|
||||
applyMainWindowSecurity(harness.window, {
|
||||
rendererUrl: "http://localhost:5180",
|
||||
externalHosts: githubOnly
|
||||
});
|
||||
|
||||
const callback = harness.requestPermission("media");
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("allows remote-login navigation only to exact provider hostnames or their subdomains", () => {
|
||||
const harness = createWindow();
|
||||
applyRemoteLoginSecurity(harness.window, {
|
||||
providerHosts: realDebridProvider,
|
||||
externalHosts: realDebridProvider
|
||||
});
|
||||
|
||||
const provider = harness.navigate("https://real-debrid.com/apitoken");
|
||||
const subdomain = harness.navigate("https://api.real-debrid.com/oauth");
|
||||
const lookalike = harness.navigate("https://real-debrid.com.evil.example/login");
|
||||
|
||||
expect(provider.preventDefault).not.toHaveBeenCalled();
|
||||
expect(subdomain.preventDefault).not.toHaveBeenCalled();
|
||||
expect(lookalike.preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("matches HTTPS allowlists without includes-based hostname shortcuts", () => {
|
||||
expect(isAllowedHttpsUrl("https://real-debrid.com/apitoken", realDebridProvider)).toBe(true);
|
||||
expect(isAllowedHttpsUrl("https://api.real-debrid.com/oauth", realDebridProvider)).toBe(true);
|
||||
expect(isAllowedHttpsUrl("http://real-debrid.com/apitoken", realDebridProvider)).toBe(false);
|
||||
expect(isAllowedHttpsUrl("https://real-debrid.com.evil.example/apitoken", realDebridProvider)).toBe(false);
|
||||
expect(isAllowedHttpsUrl("https://evil-real-debrid.com/apitoken", realDebridProvider)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertTrustedIpcSender } from "../src/main/ipc-security";
|
||||
|
||||
function eventFor(url: string) {
|
||||
return {
|
||||
senderFrame: { url },
|
||||
sender: {
|
||||
getURL: () => url
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("ipc-security", () => {
|
||||
it("accepts IPC from the configured Vite development renderer origin", () => {
|
||||
expect(() => assertTrustedIpcSender(eventFor("http://localhost:5180/settings"), {
|
||||
isPackaged: false,
|
||||
devServerUrl: "http://localhost:5180",
|
||||
appPath: "C:\\Program Files\\MDD"
|
||||
})).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects IPC from development-origin lookalikes", () => {
|
||||
expect(() => assertTrustedIpcSender(eventFor("http://localhost.evil.example:5180/settings"), {
|
||||
isPackaged: false,
|
||||
devServerUrl: "http://localhost:5180",
|
||||
appPath: "C:\\Program Files\\MDD"
|
||||
})).toThrow("IPC-Absender ist nicht vertrauenswürdig");
|
||||
});
|
||||
|
||||
it("accepts IPC from the packaged renderer file tree", () => {
|
||||
const appPath = path.join("C:", "Program Files", "MDD", "resources", "app.asar");
|
||||
const rendererUrl = pathToFileURL(path.join(appPath, "build", "renderer", "index.html")).toString();
|
||||
|
||||
expect(() => assertTrustedIpcSender(eventFor(rendererUrl), {
|
||||
isPackaged: true,
|
||||
devServerUrl: "http://localhost:5180",
|
||||
appPath
|
||||
})).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects IPC from local files outside the packaged renderer tree", () => {
|
||||
const appPath = path.join("C:", "Program Files", "MDD", "resources", "app.asar");
|
||||
const attackerUrl = pathToFileURL(path.join("C:", "Users", "Public", "attacker.html")).toString();
|
||||
|
||||
expect(() => assertTrustedIpcSender(eventFor(attackerUrl), {
|
||||
isPackaged: true,
|
||||
devServerUrl: "http://localhost:5180",
|
||||
appPath
|
||||
})).toThrow("IPC-Absender ist nicht vertrauenswürdig");
|
||||
});
|
||||
|
||||
it("rejects IPC when Electron provides no sender URL", () => {
|
||||
expect(() => assertTrustedIpcSender({
|
||||
senderFrame: null,
|
||||
sender: {
|
||||
getURL: () => ""
|
||||
}
|
||||
}, {
|
||||
isPackaged: false,
|
||||
devServerUrl: "http://localhost:5180",
|
||||
appPath: "C:\\Program Files\\MDD"
|
||||
})).toThrow("IPC-Absender ist nicht vertrauenswürdig");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,9 @@ const {
|
||||
mockExecuteJavaScript,
|
||||
mockLoadURL,
|
||||
mockShow,
|
||||
mockFocus
|
||||
mockFocus,
|
||||
mockSetWindowOpenHandler,
|
||||
mockSetPermissionRequestHandler
|
||||
} = vi.hoisted(() => {
|
||||
const sessionFetch = vi.fn();
|
||||
const clearStorageData = vi.fn();
|
||||
@@ -20,6 +22,8 @@ const {
|
||||
const loadURL = vi.fn(async () => {});
|
||||
const show = vi.fn();
|
||||
const focus = vi.fn();
|
||||
const setWindowOpenHandler = vi.fn();
|
||||
const setPermissionRequestHandler = vi.fn();
|
||||
const webContentsEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||
let destroyed = false;
|
||||
@@ -42,14 +46,18 @@ const {
|
||||
}),
|
||||
webContents: {
|
||||
setUserAgent: vi.fn(),
|
||||
setWindowOpenHandler,
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
webContentsEvents[event] = handler;
|
||||
}),
|
||||
executeJavaScript
|
||||
executeJavaScript,
|
||||
session: {
|
||||
setPermissionRequestHandler
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const BrowserWindowCtor = vi.fn(() => {
|
||||
const BrowserWindowCtor = vi.fn((_options: unknown) => {
|
||||
destroyed = false;
|
||||
return browserWindow;
|
||||
});
|
||||
@@ -64,7 +72,9 @@ const {
|
||||
mockExecuteJavaScript: executeJavaScript,
|
||||
mockLoadURL: loadURL,
|
||||
mockShow: show,
|
||||
mockFocus: focus
|
||||
mockFocus: focus,
|
||||
mockSetWindowOpenHandler: setWindowOpenHandler,
|
||||
mockSetPermissionRequestHandler: setPermissionRequestHandler
|
||||
};
|
||||
});
|
||||
|
||||
@@ -72,7 +82,10 @@ vi.mock("electron", () => ({
|
||||
session: {
|
||||
fromPartition: mockFromPartition
|
||||
},
|
||||
BrowserWindow: mockBrowserWindowCtor
|
||||
BrowserWindow: mockBrowserWindowCtor,
|
||||
shell: {
|
||||
openExternal: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web";
|
||||
@@ -129,6 +142,18 @@ describe("realdebrid-web", () => {
|
||||
retriesUsed: 0
|
||||
});
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user