From 53cdac1ded484c0fb526d45dc85a2bc813848dfb Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 15 Aug 2026 20:26:12 +0200 Subject: [PATCH] feat(realdebrid): isolate browser account sessions --- src/main/app-controller.ts | 232 +++++++++++++++++++++++++++---- src/main/main.ts | 7 +- src/main/realdebrid-web.ts | 157 ++++++++++++++------- src/preload/preload.ts | 5 +- src/shared/preload-api.ts | 24 +++- tests/account-preload.test.ts | 10 ++ tests/ipc-security.test.ts | 9 ++ tests/realdebrid-web.test.ts | 254 ++++++++++++++++++++++++++++++++-- 8 files changed, 605 insertions(+), 93 deletions(-) diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index fd4646e..c590530 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -38,7 +38,8 @@ import { checkAllDebridAccounts, checkDebridLinkKey, checkMegaDebridAccount, che import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { getRealDebridAccounts } from "../shared/real-debrid-accounts"; +import { getRealDebridAccounts, isRealDebridWebAccountId, type RealDebridWebAccountEntry } from "../shared/real-debrid-accounts"; +import type { RealDebridLoginRequest } from "../shared/preload-api"; import { applyAccountCommand, resolveStoredAccountSecret } from "./account-commands"; import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus, sanitizeDebridAccountStatuses } from "./account-status-sanitizer"; import { createRendererState } from "./renderer-state"; @@ -91,7 +92,13 @@ export class AppController { private megaWebFallback: MegaWebFallback; - private realDebridWebFallback: RealDebridWebFallback; + private realDebridWebFallbacks = new Map(); + + private pendingRealDebridWebAccountIds = new Map(); + + private realDebridWebGenerations = new Map(); + + private realDebridWebAuthenticationTasks = new Map>(); private allDebridWebFallback: AllDebridWebFallback; @@ -139,16 +146,12 @@ export class AppController { login: this.settings.megaLogin, password: this.settings.megaPassword })); - this.realDebridWebFallback = new RealDebridWebFallback( - () => this.settings.rememberToken, - () => { void this.refreshRealDebridWebStatus(); } - ); this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken); this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken); this.manager = new DownloadManager(this.settings, session, this.storagePaths, { megaWebUnrestrict: (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => this.megaWebFallback.unrestrict(link, signal, account), allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), - realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal), + realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.unrestrictWithFirstRealDebridWebAccount(link, signal), bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), invalidateMegaSession: () => this.megaWebFallback.invalidateSession(), protectEmptyClobber: loadResult.status === "empty-unreadable", @@ -497,10 +500,14 @@ export class AppController { changedKeys: Object.keys(sanitizedPatch), accountChanges: diffAccountSummary(previousSettings, this.settings) }); - if (previousSettings.rememberToken && !this.settings.rememberToken) { - void this.realDebridWebFallback.clearSessions().catch((error) => { - logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); - }); + this.pruneRealDebridWebFallbacks(previousSettings, this.settings); + if (previousSettings.rememberToken && !this.settings.rememberToken) { + const accountIds = new Set([...this.settings.realDebridWebAccountIds, ...this.realDebridWebFallbacks.keys()]); + for (const accountId of accountIds) { + void this.cleanupRealDebridWebAccount(accountId, true).catch((error) => { + logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden (${accountId}): ${String(error)}`); + }); + } void this.allDebridWebFallback.clearSessions().catch((error) => { logger.warn(`AllDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); }); @@ -571,7 +578,7 @@ export class AppController { account, undefined, Date.now(), - useWebLogin ? (signal) => this.realDebridWebFallback.probeLoginState(signal) : undefined + useWebLogin ? (signal) => this.getRealDebridWebFallback(account.id).probeLoginState(signal) : undefined ), redactions ); @@ -610,7 +617,7 @@ export class AppController { return this.settings; } - public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings { + public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings { const liveSettings = this.manager.getSettings(); const nextSettings = normalizeSettings({ ...liveSettings, @@ -620,29 +627,192 @@ export class AppController { saveSettings(this.storagePaths, this.settings); this.manager.setSettings(this.settings); this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId }); - return this.settings; - } - - public async openRealDebridLoginWindow(): Promise { - this.audit("INFO", "Real-Debrid Login-Fenster geöffnet"); - await this.realDebridWebFallback.openLoginWindow(); + return this.settings; } - private async refreshRealDebridWebStatus(): Promise { - const account = getRealDebridAccounts(this.settings).find((entry) => entry.kind === "web"); - if (!account) { + private getRealDebridWebPartition(accountId: string): string { + return accountId === "rdw_legacy" + ? "persist:realdebrid-web" + : `persist:realdebrid-web-${accountId}`; + } + + private getRealDebridWebFallback(accountId: string): RealDebridWebFallback { + if (!isRealDebridWebAccountId(accountId)) { + throw new Error("Account-Payload ist ungültig"); + } + const existing = this.realDebridWebFallbacks.get(accountId); + if (existing) { + return existing; + } + const fallback = new RealDebridWebFallback( + this.getRealDebridWebPartition(accountId), + () => this.settings.rememberToken, + () => this.queueRealDebridWebAuthentication(accountId), + () => this.handleRealDebridWebWindowClosed(accountId) + ); + this.realDebridWebFallbacks.set(accountId, fallback); + return fallback; + } + + private queueRealDebridWebAuthentication(accountId: string): void { + if (this.realDebridWebAuthenticationTasks.has(accountId)) { return; } + const generation = this.realDebridWebGenerations.get(accountId) || 0; + const task = this.refreshRealDebridWebStatus(accountId, generation) + .catch((error) => logger.warn(`Real-Debrid Web-Status konnte nicht aktualisiert werden (${accountId}): ${String(error)}`)) + .finally(() => { + if (this.realDebridWebAuthenticationTasks.get(accountId) === task) { + this.realDebridWebAuthenticationTasks.delete(accountId); + } + }); + this.realDebridWebAuthenticationTasks.set(accountId, task); + } + + private handleRealDebridWebWindowClosed(accountId: string): void { + if (!this.pendingRealDebridWebAccountIds.has(accountId)) { + return; + } + void Promise.resolve().then(async () => { + const authenticationTask = this.realDebridWebAuthenticationTasks.get(accountId); + if (authenticationTask) { + await authenticationTask; + } + if (!this.pendingRealDebridWebAccountIds.has(accountId) + || this.settings.realDebridWebAccountIds.includes(accountId)) { + return; + } + await this.cleanupRealDebridWebAccount(accountId, true); + }).catch((error) => { + logger.warn(`Abgebrochener Real-Debrid Web-Login konnte nicht bereinigt werden (${accountId}): ${String(error)}`); + }); + } + + private async cleanupRealDebridWebAccount(accountId: string, clearStorage: boolean): Promise { + const nextGeneration = (this.realDebridWebGenerations.get(accountId) || 0) + 1; + this.realDebridWebGenerations.set(accountId, nextGeneration); + this.pendingRealDebridWebAccountIds.delete(accountId); + const existing = this.realDebridWebFallbacks.get(accountId); + this.realDebridWebFallbacks.delete(accountId); + const fallback = existing || new RealDebridWebFallback( + this.getRealDebridWebPartition(accountId), + () => this.settings.rememberToken + ); + if (clearStorage) { + await fallback.clearSessions(); + } else { + fallback.dispose(); + } + } + + private pruneRealDebridWebFallbacks(previous: AppSettings, current: AppSettings): void { + const currentIds = new Set(current.realDebridWebAccountIds); + for (const accountId of previous.realDebridWebAccountIds) { + if (currentIds.has(accountId)) { + continue; + } + void this.cleanupRealDebridWebAccount(accountId, true).catch((error) => { + logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden (${accountId}): ${String(error)}`); + }); + } + } + + private async unrestrictWithFirstRealDebridWebAccount(link: string, signal?: AbortSignal) { + const account = getRealDebridAccounts(this.settings).find((entry) => entry.kind === "web" && entry.enabled); + return account ? this.unrestrictRealDebridWebAccount(account.id, link, signal) : null; + } + + public async openRealDebridLoginWindow(request: RealDebridLoginRequest): Promise { + const accountId = String(request.accountId || "").trim(); + if (!isRealDebridWebAccountId(accountId)) { + throw new Error("Account-Payload ist ungültig"); + } + const existing = getRealDebridAccounts(this.settings).find((entry) => entry.id === accountId && entry.kind === "web"); + if (request.create && existing) { + throw new Error("Account-Payload ist ungültig"); + } + if (!request.create && !existing && accountId !== "rdw_legacy") { + throw new Error("Account wurde nicht gefunden"); + } + if (!existing) { + const generation = (this.realDebridWebGenerations.get(accountId) || 0) + 1; + this.realDebridWebGenerations.set(accountId, generation); + this.pendingRealDebridWebAccountIds.set(accountId, generation); + } + this.audit("INFO", "Real-Debrid Login-Fenster geöffnet", { accountId, create: !existing }); + try { + await this.getRealDebridWebFallback(accountId).openLoginWindow(); + } catch (error) { + if (!existing) { + await this.cleanupRealDebridWebAccount(accountId, true); + } + throw error; + } + } + + public probeRealDebridWebAccount(accountId: string, signal?: AbortSignal) { + return this.getRealDebridWebFallback(accountId).probeLoginState(signal); + } + + public unrestrictRealDebridWebAccount(accountId: string, link: string, signal?: AbortSignal) { + return this.getRealDebridWebFallback(accountId).unrestrict(link, signal); + } + + public async clearRealDebridWebAccount(accountId: string): Promise { + await this.cleanupRealDebridWebAccount(accountId, true); + } + + private async refreshRealDebridWebStatus(accountId: string, generation = this.realDebridWebGenerations.get(accountId) || 0): Promise { + const account = getRealDebridAccounts(this.settings) + .filter((entry): entry is RealDebridWebAccountEntry => entry.kind === "web") + .find((entry) => entry.id === accountId); + const checkedAccount: RealDebridWebAccountEntry = account || { + id: accountId, + kind: "web", + index: this.settings.realDebridWebAccountIds.length, + label: `Browser-Login ${this.settings.realDebridWebAccountIds.length + 1}`, + maskedLogin: "Geschützter Browser-Login", + enabled: true + }; const status = sanitizeDebridAccountStatus( await checkRealDebridAccount( - account, + checkedAccount, undefined, Date.now(), - (signal) => this.realDebridWebFallback.probeLoginState(signal) + (signal) => this.getRealDebridWebFallback(accountId).probeLoginState(signal) ), collectAccountStatusRedactionValues(this.settings) ); + if (!status.valid) { + return; + } + if ((this.realDebridWebGenerations.get(accountId) || 0) !== generation) { + return; + } + const currentAccount = getRealDebridAccounts(this.settings).find((entry) => entry.id === accountId && entry.kind === "web"); + if (account && !currentAccount) { + return; + } + if (!account) { + if (this.pendingRealDebridWebAccountIds.get(accountId) !== generation) { + return; + } + const applied = applyAccountCommand(this.settings, { + action: "create", + kind: "realdebrid-web", + identity: accountId, + secret: "", + dailyLimitBytes: 0 + }); + this.pendingRealDebridWebAccountIds.delete(accountId); + this.updateSettings(applied.settings); + } this.manager.applyDebridAccountStatuses([status]); + const fallback = this.realDebridWebFallbacks.get(accountId); + if (fallback) { + this.realDebridWebFallbacks.delete(accountId); + fallback.dispose(); + } } public async openAllDebridLoginWindow(): Promise { @@ -679,7 +849,7 @@ export class AppController { await checkAllDebridAccounts( this.settings, undefined, - (_accountId, signal) => this.realDebridWebFallback.probeLoginState(signal), + (accountId, signal) => this.getRealDebridWebFallback(accountId).probeLoginState(signal), scope ), collectAccountStatusRedactionValues(this.settings) @@ -1075,10 +1245,14 @@ export class AppController { stopDebugServer(); abortActiveUpdateDownload(); cancelPendingAsyncSaves(); - this.manager.prepareForShutdown(); - this.megaWebFallback.dispose(); - this.realDebridWebFallback.dispose(); - this.allDebridWebFallback.dispose(); + this.manager.prepareForShutdown(); + this.megaWebFallback.dispose(); + for (const fallback of this.realDebridWebFallbacks.values()) { + fallback.dispose(); + } + this.realDebridWebFallbacks.clear(); + this.pendingRealDebridWebAccountIds.clear(); + this.allDebridWebFallback.dispose(); this.bestDebridWebFallback.dispose(); this.shutdownLogStorage(); this.audit("INFO", "App beendet"); diff --git a/src/main/main.ts b/src/main/main.ts index c3e8071..75f3d21 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -21,6 +21,7 @@ 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"; +import { validateRealDebridLoginRequest } from "../shared/preload-api"; function validateString(value: unknown, name: string): string { if (typeof value !== "string") { @@ -815,9 +816,9 @@ function registerIpcHandlers(): void { } }); - handleTrusted(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => { - await controller.openRealDebridLoginWindow(); - }); + handleTrusted(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async (_event: IpcMainInvokeEvent, rawRequest: unknown) => { + await controller.openRealDebridLoginWindow(validateRealDebridLoginRequest(rawRequest)); + }); handleTrusted(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN, async () => { await controller.openAllDebridLoginWindow(); diff --git a/src/main/realdebrid-web.ts b/src/main/realdebrid-web.ts index 4cfc7a4..7054e41 100644 --- a/src/main/realdebrid-web.ts +++ b/src/main/realdebrid-web.ts @@ -9,8 +9,7 @@ const RD_LOGIN_URL = RD_BASE_URL; const RD_APITOKEN_URL = `${RD_BASE_URL}/apitoken`; const RD_UNRESTRICT_API = `${API_BASE_URL}/unrestrict/link`; const RD_USER_API = `${API_BASE_URL}/user`; -const RD_PERSISTENT_PARTITION = "persist:realdebrid-web"; -const RD_TRANSIENT_PARTITION = "realdebrid-web"; +const RD_PARTITION_PATTERN = /^persist:realdebrid-web(?:-rdw_[A-Za-z0-9_-]{1,96})?$/; const RD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"; type GenerateOutcome = @@ -134,12 +133,32 @@ export class RealDebridWebFallback { private onAuthenticated?: () => void; - public constructor(getRememberSession: () => boolean, onAuthenticated?: () => void) { + private onClosed?: () => void; + + private persistentPartition: string; + + private transientPartition: string; + + private lifecycleGeneration = 0; + + private programmaticClosures = new WeakSet(); + + private disposed = false; + + public constructor(partition: string, getRememberSession: () => boolean, onAuthenticated?: () => void, onClosed?: () => void) { + const normalizedPartition = String(partition || "").trim(); + if (!RD_PARTITION_PATTERN.test(normalizedPartition)) { + throw new Error("Real-Debrid Web-Partition ist ungültig"); + } + this.persistentPartition = normalizedPartition; + this.transientPartition = normalizedPartition.slice("persist:".length); this.getRememberSession = getRememberSession; this.onAuthenticated = onAuthenticated; + this.onClosed = onClosed; } - public async unrestrict(link: string, signal?: AbortSignal): Promise { + public async unrestrict(link: string, signal?: AbortSignal): Promise { + this.throwIfDisposed(); const overallSignal = withTimeoutSignal(signal, 10 * 60 * 1000); return this.runExclusive(async () => { throwIfAborted(overallSignal); @@ -156,6 +175,7 @@ export class RealDebridWebFallback { } public async openLoginWindow(): Promise { + this.throwIfDisposed(); const window = await this.ensureLoginWindow(); if (window.isMinimized()) { window.restore(); @@ -166,6 +186,7 @@ export class RealDebridWebFallback { } public async probeLoginState(signal?: AbortSignal): Promise { + this.throwIfDisposed(); let token: string | null = null; try { token = await this.extractApiToken(signal); @@ -219,11 +240,12 @@ export class RealDebridWebFallback { } } - public async clearSessions(): Promise { - this.disposeLoginWindow(); - this.cachedToken = ""; + public async clearSessions(): Promise { + this.disposed = true; + this.disposeLoginWindow(); + this.cachedToken = ""; this.cachedTokenAt = 0; - for (const partition of [RD_PERSISTENT_PARTITION, RD_TRANSIENT_PARTITION]) { + for (const partition of [this.persistentPartition, this.transientPartition]) { const currentSession = session.fromPartition(partition); try { await currentSession.clearStorageData({ @@ -238,21 +260,32 @@ export class RealDebridWebFallback { } } - public dispose(): void { - this.disposeLoginWindow(); + public dispose(): void { + this.disposed = true; + this.disposeLoginWindow(); + this.cachedToken = ""; + this.cachedTokenAt = 0; } - private getPartition(): string { - return this.getRememberSession() ? RD_PERSISTENT_PARTITION : RD_TRANSIENT_PARTITION; - } + private getPartition(): string { + return this.getRememberSession() ? this.persistentPartition : this.transientPartition; + } + + private throwIfDisposed(): void { + if (this.disposed) { + throw new Error("Real-Debrid Web-Sitzung wurde geschlossen"); + } + } - private disposeLoginWindow(): void { - const current = this.loginWindow; - this.loginWindow = null; - this.loginWindowPartition = ""; - if (current && !current.isDestroyed()) { - current.close(); - } + private disposeLoginWindow(): void { + this.lifecycleGeneration += 1; + const current = this.loginWindow; + this.loginWindow = null; + this.loginWindowPartition = ""; + if (current && !current.isDestroyed()) { + this.programmaticClosures.add(current); + current.close(); + } } private async runExclusive(job: () => Promise, signal?: AbortSignal): Promise { @@ -297,28 +330,45 @@ export class RealDebridWebFallback { }); window.setMenuBarVisibility(false); window.webContents.setUserAgent(RD_USER_AGENT); - const primeFromWindow = (): void => { - void this.primeTokenFromWindow(window); + const windowGeneration = this.lifecycleGeneration; + const primeFromWindow = (): void => { + void this.primeTokenFromWindow(window, windowGeneration); }; - window.webContents.on("did-finish-load", primeFromWindow); - window.webContents.on("did-navigate", primeFromWindow); - window.webContents.on("did-navigate-in-page", primeFromWindow); - window.on("close", () => { - void this.primeTokenFromWindow(window); - }); - window.on("closed", () => { - if (this.loginWindow === window) { - this.loginWindow = null; - this.loginWindowPartition = ""; - } - }); + window.webContents.on("did-finish-load", primeFromWindow); + window.webContents.on("did-navigate", primeFromWindow); + window.webContents.on("did-navigate-in-page", primeFromWindow); + let closingTokenProbe: Promise = Promise.resolve(); + window.on("close", () => { + if (!this.programmaticClosures.has(window)) { + closingTokenProbe = this.primeTokenFromWindow(window, windowGeneration); + } + }); + window.on("closed", () => { + if (this.loginWindow === window) { + this.loginWindow = null; + this.loginWindowPartition = ""; + } + if (!this.programmaticClosures.has(window)) { + void closingTokenProbe.finally(() => this.onClosed?.()); + } + }); this.loginWindow = window; this.loginWindowPartition = partition; - await window.loadURL(RD_LOGIN_URL); - return window; + try { + await window.loadURL(RD_LOGIN_URL); + } catch (error) { + if (this.loginWindow === window) { + this.disposeLoginWindow(); + } + throw error; + } + return window; } - private rememberToken(token: string): string { + private rememberToken(token: string, generation = this.lifecycleGeneration): string | null { + if (this.disposed || generation !== this.lifecycleGeneration) { + return null; + } const changed = token !== this.cachedToken; this.cachedToken = token; this.cachedTokenAt = Date.now(); @@ -339,7 +389,7 @@ export class RealDebridWebFallback { return window; } - private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal): Promise { + private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal, generation = this.lifecycleGeneration): Promise { throwIfAborted(signal); try { @@ -390,8 +440,8 @@ export class RealDebridWebFallback { })(); `, true); const token = String(rawResult || "").trim(); - if (token) { - return this.rememberToken(token); + if (token && generation === this.lifecycleGeneration && !this.programmaticClosures.has(window)) { + return this.rememberToken(token, generation); } } catch { } @@ -399,15 +449,20 @@ export class RealDebridWebFallback { return null; } - private async primeTokenFromWindow(window: BrowserWindow): Promise { - try { - await this.extractApiTokenFromWindow(window); + private async primeTokenFromWindow(window: BrowserWindow, generation = this.lifecycleGeneration): Promise { + try { + await this.extractApiTokenFromWindow(window, undefined, generation); } catch { } } - private async extractApiToken(signal?: AbortSignal): Promise { - throwIfAborted(signal); + private async extractApiToken(signal?: AbortSignal): Promise { + throwIfAborted(signal); + const generation = this.lifecycleGeneration; + + if (this.disposed) { + return null; + } if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) { return this.cachedToken; @@ -430,15 +485,19 @@ export class RealDebridWebFallback { }, signal: withTimeoutSignal(signal, 30_000) }); - const html = await response.text(); + const html = await response.text(); + + if (this.disposed || generation !== this.lifecycleGeneration) { + return null; + } if (!response.ok || response.status === 403) { return null; } - const token = extractPrivateTokenFromHtml(html); - if (token) { - return this.rememberToken(token); + const token = extractPrivateTokenFromHtml(html); + if (token) { + return this.rememberToken(token, generation); } return null; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index c227bf9..a20120a 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -29,7 +29,8 @@ import { UiSnapshot, UpdateCheckResult, UpdateInstallProgress -} from "../shared/types"; +} from "../shared/types"; +import type { RealDebridLoginRequest } from "../shared/preload-api"; import { IPC_CHANNELS } from "../shared/ipc"; import { ElectronApi } from "../shared/preload-api"; @@ -101,7 +102,7 @@ const api: ElectronApi = { enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input), disableRemoteDiagnostics: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS), rotateRemoteDiagnosticsToken: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN), - openRealDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN), + openRealDebridLogin: (request: RealDebridLoginRequest = { accountId: "rdw_legacy" }): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, request), openAllDebridLogin: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), importBestDebridCookies: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), getAllDebridHostInfo: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), diff --git a/src/shared/preload-api.ts b/src/shared/preload-api.ts index fbe1194..9095517 100644 --- a/src/shared/preload-api.ts +++ b/src/shared/preload-api.ts @@ -31,7 +31,27 @@ import type { UpdateCheckResult, UpdateInstallProgress, UpdateInstallResult -} from "./types"; +} from "./types"; +import { isRealDebridWebAccountId } from "./real-debrid-accounts"; + +export interface RealDebridLoginRequest { + accountId: string; + create?: boolean; +} + +export function validateRealDebridLoginRequest(value: unknown): Required { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Account-Payload ist ungültig"); + } + const raw = value as Record; + if (Object.keys(raw).some((key) => key !== "accountId" && key !== "create") + || typeof raw.accountId !== "string" + || !isRealDebridWebAccountId(raw.accountId) + || (raw.create !== undefined && typeof raw.create !== "boolean")) { + throw new Error("Account-Payload ist ungültig"); + } + return { accountId: raw.accountId.trim(), create: raw.create === true }; +} export interface ElectronApi { getSnapshot: () => Promise; @@ -98,7 +118,7 @@ export interface ElectronApi { enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise; disableRemoteDiagnostics: () => Promise; rotateRemoteDiagnosticsToken: () => Promise; - openRealDebridLogin: () => Promise; + openRealDebridLogin: (request?: RealDebridLoginRequest) => Promise; openAllDebridLogin: () => Promise; importBestDebridCookies: () => Promise; getAllDebridHostInfo: () => Promise; diff --git a/tests/account-preload.test.ts b/tests/account-preload.test.ts index 79deb35..b736aa8 100644 --- a/tests/account-preload.test.ts +++ b/tests/account-preload.test.ts @@ -74,6 +74,16 @@ describe("account preload contract", () => { ]); }); + it("forwards account-bound existing and create browser logins", async () => { + await electron.api?.openRealDebridLogin({ accountId: "rdw_existing" }); + await electron.api?.openRealDebridLogin({ accountId: "rdw_reserved", create: true }); + + expect(electron.invoke.mock.calls).toEqual([ + [IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, { accountId: "rdw_existing" }], + [IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, { accountId: "rdw_reserved", create: true }] + ]); + }); + it("reveals a stored secret only through the explicit account channel", async () => { electron.invoke.mockResolvedValueOnce({ secret: "fixture-revealed-secret-7gH8" }); diff --git a/tests/ipc-security.test.ts b/tests/ipc-security.test.ts index e7a2ced..a781a6d 100644 --- a/tests/ipc-security.test.ts +++ b/tests/ipc-security.test.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { assertTrustedIpcSender } from "../src/main/ipc-security"; +import { validateRealDebridLoginRequest } from "../src/shared/preload-api"; function eventFor(url: string) { return { @@ -13,6 +14,14 @@ function eventFor(url: string) { } describe("ipc-security", () => { + it("accepts only opaque Real-Debrid browser account login requests", () => { + expect(validateRealDebridLoginRequest({ accountId: "rdw_existing" })).toEqual({ accountId: "rdw_existing", create: false }); + expect(validateRealDebridLoginRequest({ accountId: "rdw_reserved", create: true })).toEqual({ accountId: "rdw_reserved", create: true }); + expect(() => validateRealDebridLoginRequest({ accountId: "../shared", create: true })).toThrow(/Account-Payload/i); + expect(() => validateRealDebridLoginRequest({ accountId: "rdw_valid", create: "yes" })).toThrow(/Account-Payload/i); + expect(() => validateRealDebridLoginRequest({ accountId: "rdw_valid", create: false, token: "secret" })).toThrow(/Account-Payload/i); + }); + it("accepts IPC from the configured Vite development renderer origin", () => { expect(() => assertTrustedIpcSender(eventFor("http://localhost:5180/settings"), { isPackaged: false, diff --git a/tests/realdebrid-web.test.ts b/tests/realdebrid-web.test.ts index f1089ea..2d5412d 100644 --- a/tests/realdebrid-web.test.ts +++ b/tests/realdebrid-web.test.ts @@ -33,10 +33,11 @@ const { isMinimized: vi.fn(() => false), restore: vi.fn(), show, - focus, - close: vi.fn(() => { - destroyed = true; - windowEvents.closed?.(); + focus, + close: vi.fn(() => { + windowEvents.close?.(); + destroyed = true; + windowEvents.closed?.(); }), setMenuBarVisibility: vi.fn(), loadURL, @@ -88,7 +89,9 @@ vi.mock("electron", () => ({ } })); -import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web"; +import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web"; +import { AppController } from "../src/main/app-controller"; +import { defaultSettings } from "../src/main/constants"; describe("realdebrid-web", () => { const mockSession = { @@ -130,7 +133,7 @@ describe("realdebrid-web", () => { mockExecuteJavaScript.mockResolvedValue("token-from-window"); - const fallback = new RealDebridWebFallback(() => true); + const fallback = new RealDebridWebFallback("persist:realdebrid-web", () => true); await fallback.openLoginWindow(); const result = await fallback.unrestrict("https://rapidgator.net/file/abc"); @@ -173,7 +176,7 @@ describe("realdebrid-web", () => { }), { status: 200 })); vi.stubGlobal("fetch", apiFetch); - const fallback = new RealDebridWebFallback(() => true); + const fallback = new RealDebridWebFallback("persist:realdebrid-web", () => true); await fallback.openLoginWindow(); const status = await fallback.probeLoginState(); @@ -197,9 +200,244 @@ describe("realdebrid-web", () => { it("notifies the controller when a new browser token is detected", async () => { mockExecuteJavaScript.mockResolvedValue("new-browser-token"); const onAuthenticated = vi.fn(); - const fallback = new RealDebridWebFallback(() => true, onAuthenticated); + const fallback = new RealDebridWebFallback("persist:realdebrid-web", () => true, onAuthenticated); await fallback.openLoginWindow(); await vi.waitFor(() => expect(onAuthenticated).toHaveBeenCalledTimes(1)); }); + + it("uses isolated persistent and transient partitions for separate browser accounts", async () => { + const first = new RealDebridWebFallback("persist:realdebrid-web-rdw_first", () => true); + const second = new RealDebridWebFallback("persist:realdebrid-web-rdw_second", () => true); + + await first.openLoginWindow(); + await second.openLoginWindow(); + + expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(2); + expect(mockBrowserWindowCtor.mock.calls.map((call) => (call[0] as any).webPreferences.partition)).toEqual([ + "persist:realdebrid-web-rdw_first", + "persist:realdebrid-web-rdw_second" + ]); + + await first.clearSessions(); + + expect(mockFromPartition).toHaveBeenCalledWith("persist:realdebrid-web-rdw_first"); + expect(mockFromPartition).toHaveBeenCalledWith("realdebrid-web-rdw_first"); + expect(mockFromPartition).not.toHaveBeenCalledWith("persist:realdebrid-web-rdw_second"); + }); + + it("rejects unsafe browser partitions", () => { + expect(() => new RealDebridWebFallback("persist:realdebrid-web-../shared", () => true)).toThrow(/Partition/i); + }); + + it("persists a reserved browser account only after a successful probe", async () => { + const controller = Object.create(AppController.prototype) as any; + const applyStatuses = vi.fn(); + const updateSettings = vi.fn((settings) => { + controller.settings = settings; + }); + const fallback = { + probeLoginState: vi.fn().mockResolvedValueOnce({ + valid: false, + username: "", + email: "", + isPremium: false, + premiumUntilMs: null, + message: "Nicht angemeldet" + }).mockResolvedValueOnce({ + valid: true, + username: "fixture-user", + email: "fixture@example.test", + isPremium: true, + premiumUntilMs: Date.parse("2030-01-02T03:04:05.000Z"), + message: "Premium aktiv" + }), + dispose: vi.fn() + }; + controller.settings = defaultSettings(); + controller.pendingRealDebridWebAccountIds = new Map([["rdw_reserved", 0]]); + controller.realDebridWebGenerations = new Map([["rdw_reserved", 0]]); + controller.realDebridWebFallbacks = new Map([["rdw_reserved", fallback]]); + controller.manager = { applyDebridAccountStatuses: applyStatuses }; + controller.updateSettings = updateSettings; + controller.getRealDebridWebFallback = () => fallback; + + await controller.refreshRealDebridWebStatus("rdw_reserved"); + + expect(updateSettings).not.toHaveBeenCalled(); + expect(applyStatuses).not.toHaveBeenCalled(); + expect(controller.settings.realDebridWebAccountIds).toEqual([]); + + await controller.refreshRealDebridWebStatus("rdw_reserved"); + + expect(updateSettings).toHaveBeenCalledTimes(1); + expect(controller.settings.realDebridWebAccountIds).toEqual(["rdw_reserved"]); + expect(applyStatuses).toHaveBeenCalledWith([ + expect.objectContaining({ accountId: "rdw_reserved", valid: true, username: "fixture-user" }) + ]); + }); + + it("ignores a successful probe that completes after its account was deleted", async () => { + let resolveProbe!: (value: Record) => void; + const probe = new Promise>((resolve) => { + resolveProbe = resolve; + }); + const controller = Object.create(AppController.prototype) as any; + const settings = defaultSettings(); + settings.realDebridWebAccountIds = ["rdw_existing"]; + const applyStatuses = vi.fn(); + controller.settings = settings; + controller.pendingRealDebridWebAccountIds = new Map(); + controller.realDebridWebGenerations = new Map([["rdw_existing", 0]]); + controller.manager = { applyDebridAccountStatuses: applyStatuses }; + controller.getRealDebridWebFallback = () => ({ probeLoginState: () => probe, dispose: vi.fn() }); + + const running = controller.refreshRealDebridWebStatus("rdw_existing", 0); + controller.settings = { ...settings, realDebridWebAccountIds: [] }; + controller.realDebridWebGenerations.set("rdw_existing", 1); + resolveProbe({ + valid: true, + username: "deleted-user", + email: "deleted@example.test", + isPremium: true, + premiumUntilMs: Date.now() + 60_000, + message: "Premium aktiv" + }); + await running; + + expect(applyStatuses).not.toHaveBeenCalled(); + expect(controller.settings.realDebridWebAccountIds).toEqual([]); + }); + + it("clears cold account partitions without retaining a fallback instance", async () => { + const controller = Object.create(AppController.prototype) as any; + controller.settings = defaultSettings(); + controller.realDebridWebFallbacks = new Map(); + controller.pendingRealDebridWebAccountIds = new Map(); + controller.realDebridWebGenerations = new Map(); + controller.realDebridWebAuthenticationTasks = new Map(); + + await controller.cleanupRealDebridWebAccount("rdw_cold", true); + + expect(mockFromPartition).toHaveBeenCalledWith("persist:realdebrid-web-rdw_cold"); + expect(mockFromPartition).toHaveBeenCalledWith("realdebrid-web-rdw_cold"); + expect(controller.realDebridWebFallbacks.size).toBe(0); + }); + + it("cleans a reserved account completely when opening its login window fails", async () => { + mockLoadURL.mockRejectedValueOnce(new Error("load failed")); + const controller = Object.create(AppController.prototype) as any; + controller.settings = defaultSettings(); + controller.realDebridWebFallbacks = new Map(); + controller.pendingRealDebridWebAccountIds = new Map(); + controller.realDebridWebGenerations = new Map(); + controller.realDebridWebAuthenticationTasks = new Map(); + controller.audit = vi.fn(); + + await expect(controller.openRealDebridLoginWindow({ accountId: "rdw_failed", create: true })).rejects.toThrow("load failed"); + + expect(controller.pendingRealDebridWebAccountIds.size).toBe(0); + expect(controller.realDebridWebFallbacks.size).toBe(0); + expect(mockBrowserWindow.close).toHaveBeenCalled(); + expect(mockFromPartition).toHaveBeenCalledWith("persist:realdebrid-web-rdw_failed"); + expect(mockFromPartition).toHaveBeenCalledWith("realdebrid-web-rdw_failed"); + }); + + it("cleans a reserved account when the user closes its login window", async () => { + mockExecuteJavaScript.mockResolvedValue(""); + const controller = Object.create(AppController.prototype) as any; + controller.settings = defaultSettings(); + controller.realDebridWebFallbacks = new Map(); + controller.pendingRealDebridWebAccountIds = new Map(); + controller.realDebridWebGenerations = new Map(); + controller.realDebridWebAuthenticationTasks = new Map(); + controller.audit = vi.fn(); + + await controller.openRealDebridLoginWindow({ accountId: "rdw_closed", create: true }); + mockBrowserWindow.close(); + await vi.waitFor(() => expect(controller.realDebridWebFallbacks.size).toBe(0)); + + expect(controller.pendingRealDebridWebAccountIds.size).toBe(0); + expect(mockFromPartition).toHaveBeenCalledWith("persist:realdebrid-web-rdw_closed"); + expect(mockFromPartition).toHaveBeenCalledWith("realdebrid-web-rdw_closed"); + }); + + it("does not prime or notify while a browser window is cleared programmatically", async () => { + mockExecuteJavaScript.mockResolvedValue(""); + const onAuthenticated = vi.fn(); + const onClosed = vi.fn(); + const fallback = new RealDebridWebFallback("persist:realdebrid-web-rdw_cleanup", () => true, onAuthenticated, onClosed); + await fallback.openLoginWindow(); + await vi.waitFor(() => expect(mockExecuteJavaScript).toHaveBeenCalled()); + mockExecuteJavaScript.mockReset(); + mockExecuteJavaScript.mockResolvedValue("stale-token"); + + await fallback.clearSessions(); + await Promise.resolve(); + + expect(mockExecuteJavaScript).not.toHaveBeenCalled(); + expect(onAuthenticated).not.toHaveBeenCalled(); + expect(onClosed).not.toHaveBeenCalled(); + }); + + it("lets close-time authentication finish before cleaning a reserved account", async () => { + let resolveClosingToken!: (token: string) => void; + const closingToken = new Promise((resolve) => { + resolveClosingToken = resolve; + }); + mockExecuteJavaScript.mockResolvedValueOnce("").mockReturnValueOnce(closingToken); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + username: "close-user", + email: "close-user@example.test", + type: "premium", + expiration: "2030-01-02T03:04:05.000Z" + }), { status: 200 }))); + const controller = Object.create(AppController.prototype) as any; + controller.settings = defaultSettings(); + controller.realDebridWebFallbacks = new Map(); + controller.pendingRealDebridWebAccountIds = new Map(); + controller.realDebridWebGenerations = new Map(); + controller.realDebridWebAuthenticationTasks = new Map(); + controller.manager = { applyDebridAccountStatuses: vi.fn() }; + controller.updateSettings = vi.fn((settings) => { + controller.settings = settings; + }); + controller.audit = vi.fn(); + + await controller.openRealDebridLoginWindow({ accountId: "rdw_close_auth", create: true }); + await vi.waitFor(() => expect(mockExecuteJavaScript).toHaveBeenCalledTimes(1)); + mockBrowserWindow.close(); + resolveClosingToken("close-time-token"); + await vi.waitFor(() => expect(controller.settings.realDebridWebAccountIds).toEqual(["rdw_close_auth"])); + + expect(controller.pendingRealDebridWebAccountIds.size).toBe(0); + expect(mockFromPartition).not.toHaveBeenCalledWith("persist:realdebrid-web-rdw_close_auth"); + expect(controller.manager.applyDebridAccountStatuses).toHaveBeenCalledWith([ + expect.objectContaining({ accountId: "rdw_close_auth", valid: true, username: "close-user" }) + ]); + }); + + it.each(["clear", "dispose"] as const)("ignores a session token that resolves after %s", async (mode) => { + let resolveSessionFetch!: (response: Response) => void; + const delayedResponse = new Promise((resolve) => { + resolveSessionFetch = resolve; + }); + mockSessionFetch.mockReturnValueOnce(delayedResponse); + const onAuthenticated = vi.fn(); + const fallback = new RealDebridWebFallback(`persist:realdebrid-web-rdw_delayed_${mode}`, () => true, onAuthenticated); + + const probing = fallback.probeLoginState(); + await vi.waitFor(() => expect(mockSessionFetch).toHaveBeenCalledTimes(1)); + if (mode === "clear") { + await fallback.clearSessions(); + } else { + fallback.dispose(); + } + resolveSessionFetch(new Response("", { status: 200 })); + await probing; + await Promise.resolve(); + + expect((fallback as any).cachedToken).toBe(""); + expect(onAuthenticated).not.toHaveBeenCalled(); + }); });