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 { AllDebridHostInfo } from "../shared/types";
|
||||||
import { UnrestrictedLink } from "./realdebrid";
|
import { UnrestrictedLink } from "./realdebrid";
|
||||||
import { filenameFromUrl, sleep } from "./utils";
|
import { filenameFromUrl, sleep } from "./utils";
|
||||||
|
import { ALLDEBRID_LOGIN_HOSTS, applyRemoteLoginSecurity, createRemoteLoginWebPreferences } from "./browser-security";
|
||||||
|
|
||||||
const ALLDEBRID_BASE_URL = "https://alldebrid.com";
|
const ALLDEBRID_BASE_URL = "https://alldebrid.com";
|
||||||
const ALLDEBRID_LOGIN_URL = `${ALLDEBRID_BASE_URL}/register/?from=de`;
|
const ALLDEBRID_LOGIN_URL = `${ALLDEBRID_BASE_URL}/register/?from=de`;
|
||||||
@@ -302,11 +303,11 @@ export class AllDebridWebFallback {
|
|||||||
minHeight: 760,
|
minHeight: 760,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
title: "AllDebrid Web-Login",
|
title: "AllDebrid Web-Login",
|
||||||
webPreferences: {
|
webPreferences: createRemoteLoginWebPreferences(partition)
|
||||||
partition,
|
});
|
||||||
contextIsolation: true,
|
applyRemoteLoginSecurity(window, {
|
||||||
nodeIntegration: false
|
providerHosts: ALLDEBRID_LOGIN_HOSTS,
|
||||||
}
|
externalHosts: ALLDEBRID_LOGIN_HOSTS
|
||||||
});
|
});
|
||||||
window.setMenuBarVisibility(false);
|
window.setMenuBarVisibility(false);
|
||||||
window.on("closed", () => {
|
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 fs from "node:fs";
|
||||||
import path from "node:path";
|
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 { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, RendererSettingsUpdate, UpdateInstallProgress } from "../shared/types";
|
||||||
import { AppController } from "./app-controller";
|
import { AppController } from "./app-controller";
|
||||||
import { IPC_CHANNELS } from "../shared/ipc";
|
import { IPC_CHANNELS } from "../shared/ipc";
|
||||||
@@ -18,6 +19,8 @@ import { isMdd2Backup } from "./backup-crypto";
|
|||||||
import { validateAccountCommand, validateAccountCredentialCheckInput } from "./account-commands";
|
import { validateAccountCommand, validateAccountCredentialCheckInput } from "./account-commands";
|
||||||
import { createRendererSettings } from "./renderer-state";
|
import { createRendererSettings } from "./renderer-state";
|
||||||
import { validateRendererSettingsUpdate } from "./renderer-settings";
|
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 {
|
function validateString(value: unknown, name: string): string {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@@ -86,6 +89,36 @@ function isDevMode(): boolean {
|
|||||||
return process.env.NODE_ENV === "development";
|
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
|
// 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
|
// 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).
|
// (an unattended auto-start at boot would race autoResumeOnStart's conflict gate).
|
||||||
@@ -124,11 +157,12 @@ function createWindow(): BrowserWindow {
|
|||||||
backgroundColor: "#070b14",
|
backgroundColor: "#070b14",
|
||||||
title: `${APP_NAME} - v${controller.getVersion()}`,
|
title: `${APP_NAME} - v${controller.getVersion()}`,
|
||||||
icon: resolveAppIconPath(app.isPackaged, app.getAppPath(), process.resourcesPath),
|
icon: resolveAppIconPath(app.isPackaged, app.getAppPath(), process.resourcesPath),
|
||||||
webPreferences: {
|
webPreferences: createMainWindowWebPreferences(path.join(__dirname, "../preload/preload.js"))
|
||||||
contextIsolation: true,
|
});
|
||||||
nodeIntegration: false,
|
|
||||||
preload: path.join(__dirname, "../preload/preload.js")
|
applyMainWindowSecurity(window, {
|
||||||
}
|
rendererUrl: getTrustedRendererUrl(),
|
||||||
|
externalHosts: MAIN_WINDOW_EXTERNAL_HOSTS
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isDevMode()) {
|
if (!isDevMode()) {
|
||||||
@@ -332,10 +366,10 @@ function updateTray(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registerIpcHandlers(): void {
|
function registerIpcHandlers(): void {
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_SNAPSHOT, () => controller.getSnapshot());
|
handleTrusted(IPC_CHANNELS.GET_SNAPSHOT, () => controller.getSnapshot());
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_VERSION, () => controller.getVersion());
|
handleTrusted(IPC_CHANNELS.GET_VERSION, () => controller.getVersion());
|
||||||
ipcMain.handle(IPC_CHANNELS.CHECK_UPDATES, async () => controller.checkUpdates());
|
handleTrusted(IPC_CHANNELS.CHECK_UPDATES, async () => controller.checkUpdates());
|
||||||
ipcMain.handle(IPC_CHANNELS.INSTALL_UPDATE, async () => {
|
handleTrusted(IPC_CHANNELS.INSTALL_UPDATE, async () => {
|
||||||
const result = await controller.installUpdate((progress: UpdateInstallProgress) => {
|
const result = await controller.installUpdate((progress: UpdateInstallProgress) => {
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||||
return;
|
return;
|
||||||
@@ -349,19 +383,10 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => {
|
handleTrusted(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => {
|
||||||
try {
|
return openAllowedExternalUrl(String(rawUrl || "").trim(), MAIN_WINDOW_EXTERNAL_HOSTS);
|
||||||
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.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => {
|
||||||
const validated = validateRendererSettingsUpdate(partial ?? {}, controller.getSettings());
|
const validated = validateRendererSettingsUpdate(partial ?? {}, controller.getSettings());
|
||||||
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
||||||
updateClipboardWatcher();
|
updateClipboardWatcher();
|
||||||
@@ -369,14 +394,14 @@ function registerIpcHandlers(): void {
|
|||||||
armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });
|
armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });
|
||||||
return createRendererSettings(result);
|
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;
|
const validatedProvider = validateString(provider, "provider") as DebridProvider;
|
||||||
if (!RESETTABLE_PROVIDER_KEYS.has(validatedProvider)) {
|
if (!RESETTABLE_PROVIDER_KEYS.has(validatedProvider)) {
|
||||||
throw new Error("provider ist ungültig");
|
throw new Error("provider ist ungültig");
|
||||||
}
|
}
|
||||||
return createRendererSettings(controller.resetProviderDailyUsage(validatedProvider));
|
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();
|
const validatedKeyId = validateString(keyId, "keyId").trim();
|
||||||
if (!validatedKeyId) {
|
if (!validatedKeyId) {
|
||||||
throw new Error("keyId ist ungültig");
|
throw new Error("keyId ist ungültig");
|
||||||
@@ -384,30 +409,30 @@ function registerIpcHandlers(): void {
|
|||||||
return createRendererSettings(controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId));
|
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);
|
const command = validateAccountCommand(rawCommand);
|
||||||
if (command.action !== "create") throw new Error("Account-Payload ist ungültig");
|
if (command.action !== "create") throw new Error("Account-Payload ist ungültig");
|
||||||
return controller.executeAccountCommand(command);
|
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);
|
const command = validateAccountCommand(rawCommand);
|
||||||
if (command.action !== "replace") throw new Error("Account-Payload ist ungültig");
|
if (command.action !== "replace") throw new Error("Account-Payload ist ungültig");
|
||||||
return controller.executeAccountCommand(command);
|
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);
|
const command = validateAccountCommand(rawCommand);
|
||||||
if (command.action !== "update-secret") throw new Error("Account-Payload ist ungültig");
|
if (command.action !== "update-secret") throw new Error("Account-Payload ist ungültig");
|
||||||
return controller.executeAccountCommand(command);
|
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);
|
const command = validateAccountCommand(rawCommand);
|
||||||
if (command.action !== "delete") throw new Error("Account-Payload ist ungültig");
|
if (command.action !== "delete") throw new Error("Account-Payload ist ungültig");
|
||||||
return controller.executeAccountCommand(command);
|
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");
|
validatePlainObject(payload ?? {}, "payload");
|
||||||
validateString(payload?.rawText, "rawText");
|
validateString(payload?.rawText, "rawText");
|
||||||
if (payload.packageName !== undefined) {
|
if (payload.packageName !== undefined) {
|
||||||
@@ -418,13 +443,13 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
return controller.addLinks(payload);
|
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 validPaths = validateStringArray(filePaths ?? [], "filePaths");
|
||||||
const safePaths = validPaths.filter((p) => path.isAbsolute(p));
|
const safePaths = validPaths.filter((p) => path.isAbsolute(p));
|
||||||
return controller.addContainers(safePaths);
|
return controller.addContainers(safePaths);
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts());
|
handleTrusted(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.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => {
|
||||||
validateString(packageId, "packageId");
|
validateString(packageId, "packageId");
|
||||||
validateString(policy, "policy");
|
validateString(policy, "policy");
|
||||||
if (policy !== "keep" && policy !== "skip" && policy !== "overwrite") {
|
if (policy !== "keep" && policy !== "skip" && policy !== "overwrite") {
|
||||||
@@ -432,8 +457,8 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
return controller.resolveStartConflict(packageId, policy);
|
return controller.resolveStartConflict(packageId, policy);
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
|
handleTrusted(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
|
||||||
ipcMain.handle(IPC_CHANNELS.START, () => {
|
handleTrusted(IPC_CHANNELS.START, () => {
|
||||||
if (scheduledStartTimer !== null) {
|
if (scheduledStartTimer !== null) {
|
||||||
clearTimeout(scheduledStartTimer);
|
clearTimeout(scheduledStartTimer);
|
||||||
scheduledStartTimer = null;
|
scheduledStartTimer = null;
|
||||||
@@ -441,21 +466,21 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
return controller.start();
|
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");
|
validateStringArray(packageIds ?? [], "packageIds");
|
||||||
return controller.startPackages(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");
|
validateStringArray(itemIds ?? [], "itemIds");
|
||||||
return controller.startItems(itemIds ?? []);
|
return controller.startItems(itemIds ?? []);
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop());
|
handleTrusted(IPC_CHANNELS.STOP, () => controller.stop());
|
||||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause());
|
handleTrusted(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause());
|
||||||
ipcMain.handle(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => {
|
handleTrusted(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||||
validateString(packageId, "packageId");
|
validateString(packageId, "packageId");
|
||||||
return controller.cancelPackage(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(packageId, "packageId");
|
||||||
validateString(newName, "newName");
|
validateString(newName, "newName");
|
||||||
if (newName.length > RENAME_PACKAGE_MAX_CHARS) {
|
if (newName.length > RENAME_PACKAGE_MAX_CHARS) {
|
||||||
@@ -463,19 +488,19 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
return controller.renamePackage(packageId, newName);
|
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");
|
validateStringArray(packageIds, "packageIds");
|
||||||
return controller.reorderPackages(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");
|
validateString(itemId, "itemId");
|
||||||
return controller.removeItem(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");
|
validateString(packageId, "packageId");
|
||||||
return controller.togglePackage(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 validPackageIds = validateStringArray(packageIds ?? [], "packageIds");
|
||||||
const exported = controller.exportPackageSelection(validPackageIds);
|
const exported = controller.exportPackageSelection(validPackageIds);
|
||||||
if (exported.packageCount === 0 || exported.linkCount === 0) {
|
if (exported.packageCount === 0 || exported.linkCount === 0) {
|
||||||
@@ -492,7 +517,7 @@ function registerIpcHandlers(): void {
|
|||||||
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
|
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
|
||||||
return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath };
|
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 validItemIds = validateStringArray(itemIds ?? [], "itemIds");
|
||||||
const exported = controller.exportItemSelection(validItemIds);
|
const exported = controller.exportItemSelection(validItemIds);
|
||||||
if (exported.packageCount === 0 || exported.linkCount === 0) {
|
if (exported.packageCount === 0 || exported.linkCount === 0) {
|
||||||
@@ -509,19 +534,19 @@ function registerIpcHandlers(): void {
|
|||||||
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
|
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
|
||||||
return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath };
|
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");
|
validateString(packageId, "packageId");
|
||||||
return controller.retryExtraction(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");
|
validateString(packageId, "packageId");
|
||||||
return controller.extractNow(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");
|
validateString(packageId, "packageId");
|
||||||
return controller.resetPackage(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(packageId, "packageId");
|
||||||
validateString(priority, "priority");
|
validateString(priority, "priority");
|
||||||
if (priority !== "high" && priority !== "normal" && priority !== "low") {
|
if (priority !== "high" && priority !== "normal" && priority !== "low") {
|
||||||
@@ -529,28 +554,28 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
return controller.setPackagePriority(packageId, priority);
|
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");
|
validateStringArray(itemIds ?? [], "itemIds");
|
||||||
return controller.skipItems(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");
|
validateStringArray(itemIds ?? [], "itemIds");
|
||||||
return controller.resetItems(itemIds ?? []);
|
return controller.resetItems(itemIds ?? []);
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_HISTORY, () => controller.getHistory());
|
handleTrusted(IPC_CHANNELS.GET_HISTORY, () => controller.getHistory());
|
||||||
ipcMain.handle(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory());
|
handleTrusted(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory());
|
||||||
ipcMain.handle(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => {
|
handleTrusted(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => {
|
||||||
validateString(entryId, "entryId");
|
validateString(entryId, "entryId");
|
||||||
return controller.removeHistoryEntry(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 }, {
|
return revealHistoryEntry({ entryId }, {
|
||||||
loadHistory: () => controller.getHistory(),
|
loadHistory: () => controller.getHistory(),
|
||||||
stat: (directory) => fs.promises.stat(directory),
|
stat: (directory) => fs.promises.stat(directory),
|
||||||
openPath: (directory) => shell.openPath(directory)
|
openPath: (directory) => shell.openPath(directory)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, async () => {
|
handleTrusted(IPC_CHANNELS.EXPORT_QUEUE, async () => {
|
||||||
const options = {
|
const options = {
|
||||||
defaultPath: `rd-queue-export.json`,
|
defaultPath: `rd-queue-export.json`,
|
||||||
filters: [{ name: "Queue Export", extensions: ["json"] }]
|
filters: [{ name: "Queue Export", extensions: ["json"] }]
|
||||||
@@ -563,7 +588,7 @@ function registerIpcHandlers(): void {
|
|||||||
await fs.promises.writeFile(result.filePath, json, "utf8");
|
await fs.promises.writeFile(result.filePath, json, "utf8");
|
||||||
return { saved: true };
|
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");
|
validateString(json, "json");
|
||||||
const bytes = Buffer.byteLength(json, "utf8");
|
const bytes = Buffer.byteLength(json, "utf8");
|
||||||
if (bytes > IMPORT_QUEUE_MAX_BYTES) {
|
if (bytes > IMPORT_QUEUE_MAX_BYTES) {
|
||||||
@@ -571,21 +596,21 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
return controller.importQueue(json);
|
return controller.importQueue(json);
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => {
|
handleTrusted(IPC_CHANNELS.TOGGLE_CLIPBOARD, () => {
|
||||||
const settings = controller.getSettings();
|
const settings = controller.getSettings();
|
||||||
const next = !settings.clipboardWatch;
|
const next = !settings.clipboardWatch;
|
||||||
controller.updateSettings({ clipboardWatch: next });
|
controller.updateSettings({ clipboardWatch: next });
|
||||||
updateClipboardWatcher();
|
updateClipboardWatcher();
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.PICK_FOLDER, async () => {
|
handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => {
|
||||||
const options = {
|
const options = {
|
||||||
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
|
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
|
||||||
};
|
};
|
||||||
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
||||||
return result.canceled ? null : result.filePaths[0] || null;
|
return result.canceled ? null : result.filePaths[0] || null;
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.PICK_CONTAINERS, async () => {
|
handleTrusted(IPC_CHANNELS.PICK_CONTAINERS, async () => {
|
||||||
const options = {
|
const options = {
|
||||||
properties: ["openFile", "multiSelections"] as Array<"openFile" | "multiSelections">,
|
properties: ["openFile", "multiSelections"] as Array<"openFile" | "multiSelections">,
|
||||||
filters: [
|
filters: [
|
||||||
@@ -596,20 +621,20 @@ function registerIpcHandlers(): void {
|
|||||||
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
||||||
return result.canceled ? [] : result.filePaths;
|
return result.canceled ? [] : result.filePaths;
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats());
|
handleTrusted(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats());
|
||||||
ipcMain.handle(IPC_CHANNELS.RESET_SESSION_STATS, () => controller.resetSessionStats());
|
handleTrusted(IPC_CHANNELS.RESET_SESSION_STATS, () => controller.resetSessionStats());
|
||||||
ipcMain.handle(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats());
|
handleTrusted(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats());
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.RESTART, () => {
|
handleTrusted(IPC_CHANNELS.RESTART, () => {
|
||||||
app.relaunch();
|
app.relaunch();
|
||||||
app.quit();
|
app.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.QUIT, () => {
|
handleTrusted(IPC_CHANNELS.QUIT, () => {
|
||||||
app.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 passphrase = validateString(rawPassphrase, "passphrase");
|
||||||
const options = {
|
const options = {
|
||||||
defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`,
|
defaultPath: `${new Date().toISOString().slice(0, 10).split("-").reverse().join("-")}-mdd-backup.mdd`,
|
||||||
@@ -624,9 +649,9 @@ function registerIpcHandlers(): void {
|
|||||||
return { saved: true };
|
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();
|
const key = validateString(rawKey, "key").trim();
|
||||||
if (key.length > 128) {
|
if (key.length > 128) {
|
||||||
throw new Error("Online-Sicherungsschlüssel ist ungültig");
|
throw new Error("Online-Sicherungsschlüssel ist ungültig");
|
||||||
@@ -634,7 +659,7 @@ function registerIpcHandlers(): void {
|
|||||||
return controller.importOnlineBackup(key);
|
return controller.importOnlineBackup(key);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => {
|
handleTrusted(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => {
|
||||||
const options = {
|
const options = {
|
||||||
defaultPath: controller.getSupportBundleDefaultFileName(),
|
defaultPath: controller.getSupportBundleDefaultFileName(),
|
||||||
filters: [{ name: "Support Bundle", extensions: ["zip"] }]
|
filters: [{ name: "Support Bundle", extensions: ["zip"] }]
|
||||||
@@ -648,40 +673,40 @@ function registerIpcHandlers(): void {
|
|||||||
return { saved: true, filePath: result.filePath };
|
return { saved: true, filePath: result.filePath };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => {
|
handleTrusted(IPC_CHANNELS.OPEN_LOG, async () => {
|
||||||
const logPath = getLogFilePath();
|
const logPath = getLogFilePath();
|
||||||
await shell.openPath(logPath);
|
await shell.openPath(logPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => {
|
handleTrusted(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => {
|
||||||
const logPath = controller.getAuditLogPath();
|
const logPath = controller.getAuditLogPath();
|
||||||
if (logPath) {
|
if (logPath) {
|
||||||
await shell.openPath(logPath);
|
await shell.openPath(logPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_RENAME_LOG, async () => {
|
handleTrusted(IPC_CHANNELS.OPEN_RENAME_LOG, async () => {
|
||||||
const logPath = controller.getRenameLogPath();
|
const logPath = controller.getRenameLogPath();
|
||||||
if (logPath) {
|
if (logPath) {
|
||||||
await shell.openPath(logPath);
|
await shell.openPath(logPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => {
|
handleTrusted(IPC_CHANNELS.OPEN_SESSION_LOG, async () => {
|
||||||
const logPath = controller.getSessionLogPath();
|
const logPath = controller.getSessionLogPath();
|
||||||
if (logPath) {
|
if (logPath) {
|
||||||
await shell.openPath(logPath);
|
await shell.openPath(logPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_TRACE_LOG, async () => {
|
handleTrusted(IPC_CHANNELS.OPEN_TRACE_LOG, async () => {
|
||||||
const logPath = controller.getTraceLogPath();
|
const logPath = controller.getTraceLogPath();
|
||||||
if (logPath) {
|
if (logPath) {
|
||||||
await shell.openPath(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");
|
validateString(packageId, "packageId");
|
||||||
const logPath = controller.getPackageLogPath(packageId);
|
const logPath = controller.getPackageLogPath(packageId);
|
||||||
if (logPath) {
|
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");
|
validateString(url, "url");
|
||||||
return sendNotification(url, {
|
return sendNotification(url, {
|
||||||
title: "🔔 Test-Benachrichtigung",
|
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") {
|
if (typeof enabled !== "boolean") {
|
||||||
throw new Error("enabled muss ein Boolean sein");
|
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);
|
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();
|
const rotated = controller.rotateDebugToken();
|
||||||
return { path: rotated.path };
|
return { path: rotated.path };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => {
|
handleTrusted(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => {
|
||||||
return controller.getRemoteDiagnostics();
|
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")) {
|
if (!input || (input.hostMode !== "local" && input.hostMode !== "network")) {
|
||||||
throw new Error("hostMode muss 'local' oder 'network' sein");
|
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();
|
return controller.disableRemoteDiagnostics();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => {
|
handleTrusted(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => {
|
||||||
return controller.rotateRemoteDiagnosticsToken();
|
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");
|
validateString(itemId, "itemId");
|
||||||
const logPath = controller.getItemLogPath(itemId);
|
const logPath = controller.getItemLogPath(itemId);
|
||||||
if (logPath) {
|
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();
|
await controller.openRealDebridLoginWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => {
|
handleTrusted(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => {
|
||||||
await controller.openAllDebridLoginWindow();
|
await controller.openAllDebridLoginWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES, async () => {
|
handleTrusted(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES, async () => {
|
||||||
const options = {
|
const options = {
|
||||||
properties: ["openFile"] as Array<"openFile">,
|
properties: ["openFile"] as Array<"openFile">,
|
||||||
filters: [
|
filters: [
|
||||||
@@ -780,23 +805,23 @@ function registerIpcHandlers(): void {
|
|||||||
return controller.importBestDebridCookies(result.filePaths[0]);
|
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();
|
return controller.getAllDebridHostInfo();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS, async () => {
|
handleTrusted(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS, async () => {
|
||||||
return controller.getDebridLinkHostLimits();
|
return controller.getDebridLinkHostLimits();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => {
|
handleTrusted(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => {
|
||||||
return controller.checkDebridAccounts();
|
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));
|
return controller.checkAccountCredentials(validateAccountCredentialCheckInput(rawInput));
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.SELECT_BACKUP_IMPORT, async () => {
|
handleTrusted(IPC_CHANNELS.SELECT_BACKUP_IMPORT, async () => {
|
||||||
pendingBackupImport = null;
|
pendingBackupImport = null;
|
||||||
const options = {
|
const options = {
|
||||||
properties: ["openFile"] as Array<"openFile">,
|
properties: ["openFile"] as Array<"openFile">,
|
||||||
@@ -821,11 +846,11 @@ function registerIpcHandlers(): void {
|
|||||||
return { selected: true, requiresPassphrase: isMdd2Backup(data) };
|
return { selected: true, requiresPassphrase: isMdd2Backup(data) };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.CANCEL_BACKUP_IMPORT, () => {
|
handleTrusted(IPC_CHANNELS.CANCEL_BACKUP_IMPORT, () => {
|
||||||
pendingBackupImport = null;
|
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;
|
const data = pendingBackupImport;
|
||||||
pendingBackupImport = null;
|
pendingBackupImport = null;
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -845,7 +870,7 @@ function registerIpcHandlers(): void {
|
|||||||
return importResult;
|
return importResult;
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on(IPC_CHANNELS.LOG_RENDERER_ERROR, (_event, rawReport: unknown) => {
|
onTrusted(IPC_CHANNELS.LOG_RENDERER_ERROR, (_event, rawReport: unknown) => {
|
||||||
try {
|
try {
|
||||||
logger.error(formatRendererErrorReport(rawReport));
|
logger.error(formatRendererErrorReport(rawReport));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BrowserWindow, session } from "electron";
|
|||||||
import { UnrestrictedLink } from "./realdebrid";
|
import { UnrestrictedLink } from "./realdebrid";
|
||||||
import { filenameFromUrl, sleep } from "./utils";
|
import { filenameFromUrl, sleep } from "./utils";
|
||||||
import { API_BASE_URL, REQUEST_RETRIES } from "./constants";
|
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_BASE_URL = "https://real-debrid.com";
|
||||||
const RD_LOGIN_URL = RD_BASE_URL;
|
const RD_LOGIN_URL = RD_BASE_URL;
|
||||||
@@ -217,11 +218,11 @@ export class RealDebridWebFallback {
|
|||||||
minHeight: 760,
|
minHeight: 760,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
title: "Real-Debrid Web-Login",
|
title: "Real-Debrid Web-Login",
|
||||||
webPreferences: {
|
webPreferences: createRemoteLoginWebPreferences(partition)
|
||||||
partition,
|
});
|
||||||
contextIsolation: true,
|
applyRemoteLoginSecurity(window, {
|
||||||
nodeIntegration: false
|
providerHosts: REALDEBRID_LOGIN_HOSTS,
|
||||||
}
|
externalHosts: REALDEBRID_LOGIN_HOSTS
|
||||||
});
|
});
|
||||||
window.setMenuBarVisibility(false);
|
window.setMenuBarVisibility(false);
|
||||||
window.webContents.setUserAgent(RD_USER_AGENT);
|
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,
|
mockExecuteJavaScript,
|
||||||
mockLoadURL,
|
mockLoadURL,
|
||||||
mockShow,
|
mockShow,
|
||||||
mockFocus
|
mockFocus,
|
||||||
|
mockSetWindowOpenHandler,
|
||||||
|
mockSetPermissionRequestHandler
|
||||||
} = vi.hoisted(() => {
|
} = vi.hoisted(() => {
|
||||||
const sessionFetch = vi.fn();
|
const sessionFetch = vi.fn();
|
||||||
const clearStorageData = vi.fn();
|
const clearStorageData = vi.fn();
|
||||||
@@ -20,6 +22,8 @@ const {
|
|||||||
const loadURL = vi.fn(async () => {});
|
const loadURL = vi.fn(async () => {});
|
||||||
const show = vi.fn();
|
const show = vi.fn();
|
||||||
const focus = vi.fn();
|
const focus = vi.fn();
|
||||||
|
const setWindowOpenHandler = vi.fn();
|
||||||
|
const setPermissionRequestHandler = vi.fn();
|
||||||
const webContentsEvents: Record<string, (...args: unknown[]) => void> = {};
|
const webContentsEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||||
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
|
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
@@ -42,14 +46,18 @@ const {
|
|||||||
}),
|
}),
|
||||||
webContents: {
|
webContents: {
|
||||||
setUserAgent: vi.fn(),
|
setUserAgent: vi.fn(),
|
||||||
|
setWindowOpenHandler,
|
||||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||||
webContentsEvents[event] = handler;
|
webContentsEvents[event] = handler;
|
||||||
}),
|
}),
|
||||||
executeJavaScript
|
executeJavaScript,
|
||||||
|
session: {
|
||||||
|
setPermissionRequestHandler
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const BrowserWindowCtor = vi.fn(() => {
|
const BrowserWindowCtor = vi.fn((_options: unknown) => {
|
||||||
destroyed = false;
|
destroyed = false;
|
||||||
return browserWindow;
|
return browserWindow;
|
||||||
});
|
});
|
||||||
@@ -64,7 +72,9 @@ const {
|
|||||||
mockExecuteJavaScript: executeJavaScript,
|
mockExecuteJavaScript: executeJavaScript,
|
||||||
mockLoadURL: loadURL,
|
mockLoadURL: loadURL,
|
||||||
mockShow: show,
|
mockShow: show,
|
||||||
mockFocus: focus
|
mockFocus: focus,
|
||||||
|
mockSetWindowOpenHandler: setWindowOpenHandler,
|
||||||
|
mockSetPermissionRequestHandler: setPermissionRequestHandler
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -72,7 +82,10 @@ vi.mock("electron", () => ({
|
|||||||
session: {
|
session: {
|
||||||
fromPartition: mockFromPartition
|
fromPartition: mockFromPartition
|
||||||
},
|
},
|
||||||
BrowserWindow: mockBrowserWindowCtor
|
BrowserWindow: mockBrowserWindowCtor,
|
||||||
|
shell: {
|
||||||
|
openExternal: vi.fn()
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web";
|
import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web";
|
||||||
@@ -129,6 +142,18 @@ describe("realdebrid-web", () => {
|
|||||||
retriesUsed: 0
|
retriesUsed: 0
|
||||||
});
|
});
|
||||||
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
|
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(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com");
|
||||||
expect(mockShow).toHaveBeenCalled();
|
expect(mockShow).toHaveBeenCalled();
|
||||||
expect(mockFocus).toHaveBeenCalled();
|
expect(mockFocus).toHaveBeenCalled();
|
||||||
|
|||||||
Reference in New Issue
Block a user