From 15f53f9c92c27d169cf959934a7cc445b40924e4 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Tue, 11 Aug 2026 23:47:49 +0200 Subject: [PATCH] Sanitize account check IPC statuses Centralize DebridAccountStatus sanitizing for direct account-check responses. The shared sanitizer collects stored and submitted credential variants, including raw, trimmed, URL-encoded, URL-decoded, full credential lines, and login:secret forms, then redacts credential-like query params, key-value echoes, authorization, cookie, API-key, token, password, secret, session, backup passphrase, and archive password text before any status DTO reaches the renderer. Apply the sanitizer to bulk checkDebridAccounts results, single checkAccountCredentials results, and account command credential checks before returning, throwing, or persisting statuses. Reuse the same sanitizer for renderer snapshots so status redaction stays on one path. Replace the Debrid-Link key popup copy action with a truthful non-secret masked-identity copy action and cover the regression so no renderer path copies key.token or reports a secret-copy success without a secret readback. --- src/main/account-status-sanitizer.ts | 113 ++++++++++++++++++++++ src/main/app-controller.ts | 19 ++-- src/main/renderer-state.ts | 50 ++-------- src/renderer/App.tsx | 8 +- tests/account-check-ipc-sanitizer.test.ts | 91 +++++++++++++++++ tests/app-shell.test.tsx | 3 + 6 files changed, 233 insertions(+), 51 deletions(-) create mode 100644 src/main/account-status-sanitizer.ts create mode 100644 tests/account-check-ipc-sanitizer.test.ts diff --git a/src/main/account-status-sanitizer.ts b/src/main/account-status-sanitizer.ts new file mode 100644 index 0000000..2f4a50f --- /dev/null +++ b/src/main/account-status-sanitizer.ts @@ -0,0 +1,113 @@ +import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; +import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; +import type { AppSettings, DebridAccountStatus } from "../shared/types"; + +const REDACTED = "[geschützt]"; + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return ""; + } +} + +function addRedaction(values: Set, value: unknown): void { + if (typeof value !== "string") { + return; + } + const raw = value; + const trimmed = value.trim(); + for (const candidate of [raw, trimmed]) { + if (!candidate) { + continue; + } + values.add(candidate); + const encoded = encodeURIComponent(candidate); + values.add(encoded); + values.add(encoded.replace(/%20/g, "+")); + const decoded = safeDecode(candidate); + if (decoded) { + values.add(decoded); + } + } +} + +function getInputString(input: unknown, key: string): string | undefined { + if (!input || typeof input !== "object") { + return undefined; + } + const value = (input as Record)[key]; + return typeof value === "string" ? value : undefined; +} + +export function collectAccountStatusRedactionValues(settings?: AppSettings, input?: unknown): string[] { + const values = new Set(); + if (settings) { + addRedaction(values, settings.token); + addRedaction(values, settings.megaPassword); + addRedaction(values, settings.megaCredentials); + addRedaction(values, settings.megaDebridApiCredentials); + addRedaction(values, settings.megaDebridWebCredentials); + addRedaction(values, settings.bestToken); + addRedaction(values, settings.allDebridToken); + addRedaction(values, settings.ddownloadPassword); + addRedaction(values, settings.oneFichierApiKey); + addRedaction(values, settings.debridLinkApiKeys); + addRedaction(values, settings.linkSnappyPassword); + addRedaction(values, settings.archivePasswordList); + addRedaction(values, settings.notifyUrl); + for (const raw of [settings.megaCredentials, settings.megaDebridApiCredentials, settings.megaDebridWebCredentials]) { + for (const account of parseMegaDebridAccounts(raw, settings.megaPassword)) { + addRedaction(values, account.password); + addRedaction(values, `${account.login}:${account.password}`); + } + } + for (const key of parseDebridLinkApiKeys(settings.debridLinkApiKeys)) { + addRedaction(values, key.token); + } + } + const inputIdentity = getInputString(input, "identity"); + const inputSecret = getInputString(input, "secret"); + addRedaction(values, inputSecret); + if (inputIdentity && inputSecret) { + addRedaction(values, `${inputIdentity.trim()}:${inputSecret.trim()}`); + } + return [...values].filter(Boolean).sort((left, right) => right.length - left.length); +} + +export function sanitizeAccountStatusText(value: string, redactions: readonly string[]): string { + let result = value; + result = result.replace(/\b(?:Authorization|Proxy-Authorization)\s*:\s*[^\r\n]+/gi, (match) => { + const name = match.slice(0, match.indexOf(":")); + return `${name}: ${REDACTED}`; + }); + result = result.replace(/\b(?:Cookie|Set-Cookie)\s*:\s*[^\r\n]+/gi, (match) => { + const name = match.slice(0, match.indexOf(":")); + return `${name}: ${REDACTED}`; + }); + result = result.replace(/\b(?:X-Api-Key|Api-Key|X-Auth-Token|X-Access-Token|Access-Token|Private-Token)\s*:\s*[^\r\n]+/gi, (match) => { + const name = match.slice(0, match.indexOf(":")); + return `${name}: ${REDACTED}`; + }); + result = result.replace(/((?:[?&;\s,]|^)(?:password|pass|pwd|token|api[_-]?key|apikey|access[_-]?token|private[_-]?token|secret|session|cookie|(?:backup|archive)?[_ -]?passphrase|archive[_ -]?password)\s*[:=]\s*)[^&\s"')]+/gi, `$1${REDACTED}`); + for (const secret of redactions) { + result = result.split(secret).join(REDACTED); + } + return result; +} + +export function sanitizeDebridAccountStatus(status: DebridAccountStatus, redactions: readonly string[]): DebridAccountStatus { + return { + ...status, + accountId: sanitizeAccountStatusText(status.accountId, redactions), + label: sanitizeAccountStatusText(status.label, redactions), + maskedLogin: sanitizeAccountStatusText(status.maskedLogin, redactions), + email: status.email ? sanitizeAccountStatusText(status.email, redactions) : undefined, + message: sanitizeAccountStatusText(status.message, redactions) + }; +} + +export function sanitizeDebridAccountStatuses(statuses: readonly DebridAccountStatus[], redactions: readonly string[]): DebridAccountStatus[] { + return statuses.map((status) => sanitizeDebridAccountStatus(status, redactions)); +} diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 71f07fc..183c053 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -35,6 +35,7 @@ import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { applyAccountCommand } from "./account-commands"; +import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus, sanitizeDebridAccountStatuses } from "./account-status-sanitizer"; import { createRendererState } from "./renderer-state"; import { parseCollectorInput } from "./link-parser"; import { configureLogger, getLogFilePath, logger } from "./logger"; @@ -484,6 +485,7 @@ export class AppController { public async executeAccountCommand(command: AccountCommand): Promise { const applied = applyAccountCommand(this.settings, command); let checkedStatus: DebridAccountStatus | null = null; + const redactions = collectAccountStatusRedactionValues(applied.settings, command); if (command.action !== "delete" && applied.response.accountId && (command.kind === "megadebrid-api" || command.kind === "megadebrid-web")) { const mode = command.kind === "megadebrid-web" ? "web" : "api"; const account = getMegaDebridAccountsForMode(applied.settings, mode).find((entry) => entry.id === applied.response.accountId); @@ -495,10 +497,11 @@ export class AppController { if (!key) throw new Error("Account-Payload ist ungültig"); checkedStatus = await checkDebridLinkKey(key); } + if (checkedStatus) { + checkedStatus = sanitizeDebridAccountStatus(checkedStatus, redactions); + } if (checkedStatus && !checkedStatus.valid) { - const submittedSecret = "secret" in command ? command.secret : undefined; - const safeMessage = submittedSecret ? checkedStatus.message.split(submittedSecret).join("[geschützt]") : checkedStatus.message; - throw new Error(safeMessage || "Zugangsdaten ungültig"); + throw new Error(checkedStatus.message || "Zugangsdaten ungültig"); } this.updateSettings(applied.settings); if (checkedStatus) this.manager.applyDebridAccountStatuses([checkedStatus]); @@ -508,6 +511,7 @@ export class AppController { } public async checkAccountCredentials(input: AccountCredentialCheckInput): Promise { + const redactions = collectAccountStatusRedactionValues(this.settings, input); if (input.kind === "megadebrid-api" || input.kind === "megadebrid-web") { const mode = input.kind === "megadebrid-web" ? "web" : "api"; const account = input.identity?.trim() && input.secret @@ -515,14 +519,14 @@ export class AppController { : getMegaDebridAccountsForMode(this.settings, mode).find((entry) => entry.id === input.accountId); if (!account) throw new Error("Account-Payload ist ungültig"); const status = await checkMegaDebridAccount(account); - return { ...status, message: input.secret ? status.message.split(input.secret).join("[geschützt]") : status.message }; + return sanitizeDebridAccountStatus(status, redactions); } const key = input.secret?.trim() ? parseDebridLinkApiKeys(input.secret)[0] : parseDebridLinkApiKeys(this.settings.debridLinkApiKeys).find((entry) => entry.id === input.accountId); if (!key) throw new Error("Account-Payload ist ungültig"); const status = await checkDebridLinkKey(key); - return { ...status, message: input.secret ? status.message.split(input.secret).join("[geschützt]") : status.message }; + return sanitizeDebridAccountStatus(status, redactions); } public resetProviderDailyUsage(provider: DebridProvider): AppSettings { @@ -586,7 +590,10 @@ export class AppController { } public async checkDebridAccounts(): Promise { - const statuses = await checkAllDebridAccounts(this.settings); + const statuses = sanitizeDebridAccountStatuses( + await checkAllDebridAccounts(this.settings), + collectAccountStatusRedactionValues(this.settings) + ); this.manager.applyDebridAccountStatuses(statuses); this.audit("INFO", "Debrid-Accounts geprueft", { total: statuses.length, diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index a574046..5d51449 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -1,6 +1,7 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; -import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; +import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode } from "../shared/mega-debrid-accounts"; import type { AppSettings, DebridAccountStatus, DebridProvider, RendererAccount, RendererAccountKind, RendererSettings } from "../shared/types"; +import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus } from "./account-status-sanitizer"; function maskValue(value: string, keepStart = 3, keepEnd = 3): string { const trimmed = value.trim(); @@ -13,45 +14,11 @@ function maskValue(value: string, keepStart = 3, keepEnd = 3): string { return `${trimmed.slice(0, keepStart)}${"*".repeat(Math.max(4, trimmed.length - keepStart - keepEnd))}${trimmed.slice(-keepEnd)}`; } -function getSecrets(settings: AppSettings): string[] { - const megaSecrets = [settings.megaCredentials, settings.megaDebridApiCredentials, settings.megaDebridWebCredentials] - .flatMap((credentials) => parseMegaDebridAccounts(credentials, settings.megaPassword)) - .map((account) => account.password); - const debridLinkSecrets = parseDebridLinkApiKeys(settings.debridLinkApiKeys).map((key) => key.token); - return [...new Set([ - settings.token, - settings.megaPassword, - settings.megaCredentials, - settings.megaDebridApiCredentials, - settings.megaDebridWebCredentials, - ...megaSecrets, - settings.bestToken, - settings.allDebridToken, - settings.ddownloadPassword, - settings.oneFichierApiKey, - settings.debridLinkApiKeys, - ...debridLinkSecrets, - settings.linkSnappyPassword, - settings.archivePasswordList, - settings.notifyUrl - ].map((value) => value.trim()).filter(Boolean))].sort((left, right) => right.length - left.length); -} - -function redact(value: string, secrets: readonly string[]): string { - return secrets.reduce((result, secret) => result.split(secret).join("[geschützt]"), value); -} - -function safeStatus(status: DebridAccountStatus | undefined, secrets: readonly string[]): DebridAccountStatus | null { +function safeStatus(status: DebridAccountStatus | undefined, redactions: readonly string[]): DebridAccountStatus | null { if (!status) { return null; } - return { - ...status, - label: redact(status.label, secrets), - maskedLogin: redact(status.maskedLogin, secrets), - email: status.email ? redact(status.email, secrets) : undefined, - message: redact(status.message, secrets) - }; + return sanitizeDebridAccountStatus(status, redactions); } function providerEnabled(settings: AppSettings, provider: DebridProvider): boolean { @@ -83,7 +50,7 @@ function singleAccount( export function createRendererAccounts(settings: AppSettings): RendererAccount[] { const accounts: RendererAccount[] = []; - const secrets = getSecrets(settings); + const redactions = collectAccountStatusRedactionValues(settings); if (settings.realDebridUseWebLogin || settings.token.trim()) { accounts.push(singleAccount( settings, @@ -111,7 +78,7 @@ export function createRendererAccounts(settings: AppSettings): RendererAccount[] dailyLimitBytes: settings.megaDebridAccountDailyLimitBytes[account.id] || 0, dailyUsageBytes: settings.megaDebridAccountDailyUsageBytes[account.id] || 0, totalUsageBytes: settings.megaDebridAccountTotalUsageBytes[account.id] || 0, - status: safeStatus(settings.debridAccountStatuses[account.id], secrets) + status: safeStatus(settings.debridAccountStatuses[account.id], redactions) }); } } @@ -153,7 +120,7 @@ export function createRendererAccounts(settings: AppSettings): RendererAccount[] dailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes[key.id] || 0, dailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes[key.id] || 0, totalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes[key.id] || 0, - status: safeStatus(settings.debridAccountStatuses[key.id], secrets) + status: safeStatus(settings.debridAccountStatuses[key.id], redactions) }); } if (settings.linkSnappyLogin.trim() && settings.linkSnappyPassword) { @@ -164,6 +131,7 @@ export function createRendererAccounts(settings: AppSettings): RendererAccount[] export function createRendererSettings(settings: AppSettings): RendererSettings { const configuredProviders = [...new Set(createRendererAccounts(settings).map((account) => account.provider))]; + const redactions = collectAccountStatusRedactionValues(settings); return { language: settings.language, realDebridUseWebLogin: settings.realDebridUseWebLogin, @@ -250,7 +218,7 @@ export function createRendererSettings(settings: AppSettings): RendererSettings megaDebridAccountDailyLimitBytes: { ...settings.megaDebridAccountDailyLimitBytes }, megaDebridAccountDailyUsageBytes: { ...settings.megaDebridAccountDailyUsageBytes }, megaDebridAccountTotalUsageBytes: { ...settings.megaDebridAccountTotalUsageBytes }, - debridAccountStatuses: Object.fromEntries(Object.entries(settings.debridAccountStatuses).map(([id, status]) => [id, safeStatus(status, getSecrets(settings))])), + debridAccountStatuses: Object.fromEntries(Object.entries(settings.debridAccountStatuses).map(([id, status]) => [id, safeStatus(status, redactions)])), providerDailyUsageDay: settings.providerDailyUsageDay, scheduledStartEpochMs: settings.scheduledStartEpochMs } as RendererSettings; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f52a2da..e735383 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -6141,13 +6141,13 @@ export function App(): ReactElement { <> {ki + 1}