Release v1.4.17 with security fixes, stability hardening and retry improvements

- Fix ZIP path traversal vulnerability (reject entries escaping target dir)
- Add single instance lock (prevent data corruption from multiple instances)
- Add unhandled exception/rejection handlers (prevent silent crashes)
- Fix mainWindow reference cleanup on close
- Add second-instance handler to focus existing window
- Fix claimTargetPath infinite loop (add 10k iteration bound)
- Add duplicate startItem guard (prevent concurrent downloads of same item)
- Clone session in getSnapshot to prevent live-reference mutation bugs
- Clear stateEmitTimer on clearAll to prevent dangling timer emissions
- Add extraction timeout safety (4h deadline with logging)
- Add dedicated unrestrict retry system with longer backoff for Mega-Debrid errors
- Add log rotation (10MB max, keeps one .old backup)
- Fix writeExtractResumeState missing mkdir (prevents crash on deleted dirs)
- Fix saveSessionAsync EXDEV cross-device rename with copy fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-02-28 05:04:21 +01:00
parent ea6301d326
commit d4dd266f6b
6 changed files with 127 additions and 12 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.16", "version": "1.4.17",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -134,6 +134,13 @@ function isFetchFailure(errorText: string): boolean {
return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error"); return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error");
} }
function isUnrestrictFailure(errorText: string): boolean {
const text = String(errorText || "").toLowerCase();
return text.includes("unrestrict") || text.includes("mega-web") || text.includes("mega-debrid")
|| text.includes("bestdebrid") || text.includes("alldebrid") || text.includes("kein debrid")
|| text.includes("session") || text.includes("login");
}
function isFinishedStatus(status: DownloadStatus): boolean { function isFinishedStatus(status: DownloadStatus): boolean {
return status === "completed" || status === "failed" || status === "cancelled"; return status === "completed" || status === "failed" || status === "cancelled";
} }
@ -304,7 +311,7 @@ export class DownloadManager extends EventEmitter {
return { return {
settings: this.settings, settings: this.settings,
session: this.session, session: cloneSession(this.session),
summary: this.summary, summary: this.summary,
stats: this.getStats(now), stats: this.getStats(now),
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`, speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
@ -494,6 +501,10 @@ export class DownloadManager extends EventEmitter {
public clearAll(): void { public clearAll(): void {
this.stop(); this.stop();
this.abortPostProcessing("clear_all"); this.abortPostProcessing("clear_all");
if (this.stateEmitTimer) {
clearTimeout(this.stateEmitTimer);
this.stateEmitTimer = null;
}
this.session.packageOrder = []; this.session.packageOrder = [];
this.session.packages = {}; this.session.packages = {};
this.session.items = {}; this.session.items = {};
@ -1394,8 +1405,8 @@ export class DownloadManager extends EventEmitter {
} }
const parsed = path.parse(preferredPath); const parsed = path.parse(preferredPath);
let index = 0; const maxIndex = 10000;
while (true) { for (let index = 0; index <= maxIndex; index += 1) {
const candidate = index === 0 const candidate = index === 0
? preferredPath ? preferredPath
: path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`); : path.join(parsed.dir, `${parsed.name} (${index})${parsed.ext}`);
@ -1408,8 +1419,11 @@ export class DownloadManager extends EventEmitter {
this.claimedTargetPathByItem.set(itemId, candidate); this.claimedTargetPathByItem.set(itemId, candidate);
return candidate; return candidate;
} }
index += 1;
} }
logger.error(`claimTargetPath: Limit erreicht für ${preferredPath}`);
this.reservedTargetPaths.set(pathKey(preferredPath), itemId);
this.claimedTargetPathByItem.set(itemId, preferredPath);
return preferredPath;
} }
private releaseTargetPath(itemId: string): void { private releaseTargetPath(itemId: string): void {
@ -1804,6 +1818,9 @@ export class DownloadManager extends EventEmitter {
if (!item || !pkg || pkg.cancelled || !pkg.enabled) { if (!item || !pkg || pkg.cancelled || !pkg.enabled) {
return; return;
} }
if (this.activeTasks.has(itemId)) {
return;
}
item.status = "validating"; item.status = "validating";
item.fullStatus = "Link wird umgewandelt"; item.fullStatus = "Link wird umgewandelt";
@ -1844,7 +1861,9 @@ export class DownloadManager extends EventEmitter {
let freshRetryUsed = false; let freshRetryUsed = false;
let stallRetries = 0; let stallRetries = 0;
let genericErrorRetries = 0; let genericErrorRetries = 0;
let unrestrictRetries = 0;
const maxGenericErrorRetries = Math.max(2, REQUEST_RETRIES); const maxGenericErrorRetries = Math.max(2, REQUEST_RETRIES);
const maxUnrestrictRetries = Math.max(3, REQUEST_RETRIES);
while (true) { while (true) {
try { try {
const unrestricted = await this.debridService.unrestrictLink(item.url); const unrestricted = await this.debridService.unrestrictLink(item.url);
@ -2039,6 +2058,23 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
if (isUnrestrictFailure(errorText) && unrestrictRetries < maxUnrestrictRetries) {
unrestrictRetries += 1;
item.retries += 1;
item.status = "queued";
item.fullStatus = `Unrestrict-Fehler, Retry ${unrestrictRetries}/${maxUnrestrictRetries}`;
item.lastError = errorText;
item.attempts = 0;
item.speedBps = 0;
item.updatedAt = nowMs();
active.abortController = new AbortController();
active.abortReason = "none";
this.persistSoon();
this.emitState();
await sleep(Math.min(8000, 2000 * unrestrictRetries));
continue;
}
if (genericErrorRetries < maxGenericErrorRetries) { if (genericErrorRetries < maxGenericErrorRetries) {
genericErrorRetries += 1; genericErrorRetries += 1;
item.retries += 1; item.retries += 1;
@ -2728,6 +2764,10 @@ export class DownloadManager extends EventEmitter {
updateExtractingStatus("Entpacken 0%"); updateExtractingStatus("Entpacken 0%");
this.emitState(); this.emitState();
const extractTimeoutMs = 4 * 60 * 60 * 1000;
const extractDeadline = setTimeout(() => {
logger.error(`Post-Processing Extraction Timeout nach 4h: pkg=${pkg.name}`);
}, extractTimeoutMs);
try { try {
const result = await extractPackageArchives({ const result = await extractPackageArchives({
packageDir: pkg.outputDir, packageDir: pkg.outputDir,
@ -2754,6 +2794,7 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
} }
}); });
clearTimeout(extractDeadline);
logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`); logger.info(`Post-Processing Entpacken Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}, lastError=${result.lastError || ""}`);
if (result.failed > 0) { if (result.failed > 0) {
const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen"); const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen");
@ -2783,6 +2824,7 @@ export class DownloadManager extends EventEmitter {
pkg.status = "completed"; pkg.status = "completed";
} }
} catch (error) { } catch (error) {
clearTimeout(extractDeadline);
const reasonRaw = String(error || ""); const reasonRaw = String(error || "");
if (reasonRaw.includes("aborted:extract")) { if (reasonRaw.includes("aborted:extract")) {
for (const entry of completedItems) { for (const entry of completedItems) {

View File

@ -232,11 +232,16 @@ function readExtractResumeState(packageDir: string): Set<string> {
} }
function writeExtractResumeState(packageDir: string, completedArchives: Set<string>): void { function writeExtractResumeState(packageDir: string, completedArchives: Set<string>): void {
try {
fs.mkdirSync(packageDir, { recursive: true });
const progressPath = extractProgressFilePath(packageDir); const progressPath = extractProgressFilePath(packageDir);
const payload: ExtractResumeState = { const payload: ExtractResumeState = {
completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b)) completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b))
}; };
fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8"); fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8");
} catch (error) {
logger.warn(`ExtractResumeState schreiben fehlgeschlagen: ${String(error)}`);
}
} }
function clearExtractResumeState(packageDir: string): void { function clearExtractResumeState(packageDir: string): void {
@ -582,8 +587,13 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode:
const mode = effectiveConflictMode(conflictMode); const mode = effectiveConflictMode(conflictMode);
const zip = new AdmZip(archivePath); const zip = new AdmZip(archivePath);
const entries = zip.getEntries(); const entries = zip.getEntries();
const resolvedTarget = path.resolve(targetDir);
for (const entry of entries) { for (const entry of entries) {
const outputPath = path.join(targetDir, entry.entryName); const outputPath = path.resolve(targetDir, entry.entryName);
if (!outputPath.startsWith(resolvedTarget + path.sep) && outputPath !== resolvedTarget) {
logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`);
continue;
}
if (entry.isDirectory) { if (entry.isDirectory) {
fs.mkdirSync(outputPath, { recursive: true }); fs.mkdirSync(outputPath, { recursive: true });
continue; continue;

View File

@ -5,6 +5,8 @@ let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
let fallbackLogFilePath: string | null = null; let fallbackLogFilePath: string | null = null;
const LOG_FLUSH_INTERVAL_MS = 120; const LOG_FLUSH_INTERVAL_MS = 120;
const LOG_BUFFER_LIMIT_CHARS = 1_000_000; const LOG_BUFFER_LIMIT_CHARS = 1_000_000;
const LOG_MAX_FILE_BYTES = 10 * 1024 * 1024;
let lastRotateCheckAt = 0;
let pendingLines: string[] = []; let pendingLines: string[] = [];
let pendingChars = 0; let pendingChars = 0;
@ -90,6 +92,29 @@ function scheduleFlush(immediate = false): void {
}, LOG_FLUSH_INTERVAL_MS); }, LOG_FLUSH_INTERVAL_MS);
} }
function rotateIfNeeded(filePath: string): void {
try {
const now = Date.now();
if (now - lastRotateCheckAt < 60_000) {
return;
}
lastRotateCheckAt = now;
const stat = fs.statSync(filePath);
if (stat.size < LOG_MAX_FILE_BYTES) {
return;
}
const backup = `${filePath}.old`;
try {
fs.rmSync(backup, { force: true });
} catch {
// ignore
}
fs.renameSync(filePath, backup);
} catch {
// ignore - file may not exist yet
}
}
async function flushAsync(): Promise<void> { async function flushAsync(): Promise<void> {
if (flushInFlight || pendingLines.length === 0) { if (flushInFlight || pendingLines.length === 0) {
return; return;
@ -101,6 +126,7 @@ async function flushAsync(): Promise<void> {
pendingChars = 0; pendingChars = 0;
try { try {
rotateIfNeeded(logFilePath);
const primary = await appendChunk(logFilePath, chunk); const primary = await appendChunk(logFilePath, chunk);
if (fallbackLogFilePath) { if (fallbackLogFilePath) {
const fallback = await appendChunk(fallbackLogFilePath, chunk); const fallback = await appendChunk(fallbackLogFilePath, chunk);

View File

@ -6,6 +6,20 @@ import { IPC_CHANNELS } from "../shared/ipc";
import { logger } from "./logger"; import { logger } from "./logger";
import { APP_NAME } from "./constants"; import { APP_NAME } from "./constants";
/* ── Single Instance Lock ───────────────────────────────────────── */
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
}
/* ── Unhandled error protection ─────────────────────────────────── */
process.on("uncaughtException", (error) => {
logger.error(`Uncaught Exception: ${String(error?.stack || error)}`);
});
process.on("unhandledRejection", (reason) => {
logger.error(`Unhandled Rejection: ${String(reason)}`);
});
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null; let tray: Tray | null = null;
let clipboardTimer: ReturnType<typeof setInterval> | null = null; let clipboardTimer: ReturnType<typeof setInterval> | null = null;
@ -202,6 +216,16 @@ function registerIpcHandlers(): void {
}; };
} }
app.on("second-instance", () => {
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
}
});
app.whenReady().then(() => { app.whenReady().then(() => {
registerIpcHandlers(); registerIpcHandlers();
mainWindow = createWindow(); mainWindow = createWindow();
@ -216,6 +240,10 @@ app.whenReady().then(() => {
} }
}); });
mainWindow.on("closed", () => {
mainWindow = null;
});
app.on("activate", () => { app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow(); mainWindow = createWindow();

View File

@ -226,7 +226,16 @@ export async function saveSessionAsync(paths: StoragePaths, session: SessionStat
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
const tempPath = `${paths.sessionFile}.tmp`; const tempPath = `${paths.sessionFile}.tmp`;
await fsp.writeFile(tempPath, payload, "utf8"); await fsp.writeFile(tempPath, payload, "utf8");
try {
await fsp.rename(tempPath, paths.sessionFile); await fsp.rename(tempPath, paths.sessionFile);
} catch (renameError: unknown) {
if ((renameError as NodeJS.ErrnoException).code === "EXDEV") {
await fsp.copyFile(tempPath, paths.sessionFile);
await fsp.rm(tempPath, { force: true }).catch(() => {});
} else {
throw renameError;
}
}
} catch (error) { } catch (error) {
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`); logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
} finally { } finally {