From cbc423e4b77bbaa45360635d8c0b4fa92aef951e Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 28 Feb 2026 13:09:59 +0100 Subject: [PATCH] Release v1.4.26 with remaining bug audit fixes - AllDebrid: add HTML response detection to unrestrictLink - Cleanup: skip symlinks/junctions in all directory traversals - Blob URL: increase revoke delay from 0ms to 60s - Extractor: per-package progress file to prevent collision - ADD_CONTAINERS: reject path traversal and relative paths Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src/main/cleanup.ts | 26 ++++++++-- src/main/debrid.ts | 76 ++++++++++++++++++++++------- src/main/download-manager.ts | 2 + src/main/extractor.ts | 88 ++++++++++++++++++++++++++-------- src/main/main.ts | 6 ++- src/renderer/App.tsx | 92 +++++++++++++++++------------------- 7 files changed, 200 insertions(+), 92 deletions(-) diff --git a/package.json b/package.json index 346147e..7b089eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.25", + "version": "1.4.26", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/cleanup.ts b/src/main/cleanup.ts index 5ddacc4..2d02609 100644 --- a/src/main/cleanup.ts +++ b/src/main/cleanup.ts @@ -30,7 +30,7 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number { const current = stack.pop() as string; for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const full = path.join(current, entry.name); - if (entry.isDirectory()) { + if (entry.isDirectory() && !entry.isSymbolicLink()) { stack.push(full); } else if (entry.isFile() && isArchiveOrTempFile(full)) { try { @@ -66,7 +66,7 @@ export async function cleanupCancelledPackageArtifactsAsync(packageDir: string): for (const entry of entries) { const full = path.join(current, entry.name); - if (entry.isDirectory()) { + if (entry.isDirectory() && !entry.isSymbolicLink()) { stack.push(full); } else if (entry.isFile() && isArchiveOrTempFile(full)) { try { @@ -96,7 +96,7 @@ export function removeDownloadLinkArtifacts(extractDir: string): number { const current = stack.pop() as string; for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const full = path.join(current, entry.name); - if (entry.isDirectory()) { + if (entry.isDirectory() && !entry.isSymbolicLink()) { stack.push(full); continue; } @@ -158,6 +158,14 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs for (const entry of entries) { const full = path.join(current, entry.name); if (entry.isDirectory()) { + try { + const stat = fs.lstatSync(full); + if (stat.isSymbolicLink()) { + continue; + } + } catch { + continue; + } dirs.push(full); } else if (entry.isFile()) { count += 1; @@ -171,13 +179,15 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs const current = stack.pop() as string; for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const full = path.join(current, entry.name); - if (entry.isDirectory()) { + if (entry.isDirectory() || entry.isSymbolicLink()) { const base = entry.name.toLowerCase(); if (SAMPLE_DIR_NAMES.has(base)) { sampleDirs.push(full); continue; } - stack.push(full); + if (entry.isDirectory()) { + stack.push(full); + } continue; } if (!entry.isFile()) { @@ -202,6 +212,12 @@ export function removeSampleArtifacts(extractDir: string): { files: number; dirs sampleDirs.sort((a, b) => b.length - a.length); for (const dir of sampleDirs) { try { + const stat = fs.lstatSync(dir); + if (stat.isSymbolicLink()) { + fs.rmSync(dir, { force: true }); + removedDirs += 1; + continue; + } const filesInDir = countFilesRecursive(dir); fs.rmSync(dir, { recursive: true, force: true }); removedFiles += filesInDir; diff --git a/src/main/debrid.ts b/src/main/debrid.ts index f2803d6..500f9a7 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -444,27 +444,61 @@ class AllDebridClient { body.append("link[]", link); } - const response = await fetch(`${ALL_DEBRID_API_BASE}/link/infos`, { - method: "POST", - headers: { - Authorization: `Bearer ${this.token}`, - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "RD-Node-Downloader/1.1.15" - }, - body, - signal: AbortSignal.timeout(API_TIMEOUT_MS) - }); + let payload: Record | null = null; + let chunkResolved = false; + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + let response: Response; + let text = ""; + try { + response = await fetch(`${ALL_DEBRID_API_BASE}/link/infos`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "RD-Node-Downloader/1.1.15" + }, + body, + signal: AbortSignal.timeout(API_TIMEOUT_MS) + }); - const text = await response.text(); - const payload = asRecord(parseJson(text)); - if (!response.ok) { - throw new Error(parseError(response.status, text, payload)); + text = await response.text(); + payload = asRecord(parseJson(text)); + if (!response.ok) { + const reason = parseError(response.status, text, payload); + if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES) { + await sleep(retryDelay(attempt)); + continue; + } + throw new Error(reason); + } + + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text); + if (looksHtml) { + throw new Error("AllDebrid lieferte HTML statt JSON"); + } + if (!payload) { + throw new Error("AllDebrid Antwort ist kein JSON-Objekt"); + } + + const status = pickString(payload, ["status"]); + if (status && status.toLowerCase() === "error") { + const errorObj = asRecord(payload?.error); + throw new Error(pickString(errorObj, ["message", "code"]) || "AllDebrid API error"); + } + + chunkResolved = true; + break; + } catch (error) { + if (attempt >= REQUEST_RETRIES) { + throw error; + } + await sleep(retryDelay(attempt)); + } } - const status = pickString(payload, ["status"]); - if (status && status.toLowerCase() === "error") { - const errorObj = asRecord(payload?.error); - throw new Error(pickString(errorObj, ["message", "code"]) || "AllDebrid API error"); + if (!chunkResolved || !payload) { + throw new Error("AllDebrid Link-Infos konnten nicht geladen werden"); } const data = asRecord(payload?.data); @@ -519,6 +553,12 @@ class AllDebridClient { throw new Error(reason); } + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + const looksHtml = contentType.includes("text/html") || /^\s*<(!doctype\s+html|html\b)/i.test(text); + if (looksHtml) { + throw new Error("AllDebrid lieferte HTML statt JSON"); + } + const status = pickString(payload, ["status"]); if (status && status.toLowerCase() === "error") { const errorObj = asRecord(payload?.error); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index c843cbc..86baaf1 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -3175,6 +3175,7 @@ export class DownloadManager extends EventEmitter { signal, onlyArchives: readyArchives, skipPostCleanup: true, + packageId, onProgress: (progress) => { if (progress.phase === "done") { return; @@ -3312,6 +3313,7 @@ export class DownloadManager extends EventEmitter { removeSamples: this.settings.removeSamplesAfterExtract, passwordList: this.settings.archivePasswordList, signal: extractAbortController.signal, + packageId, onProgress: (progress) => { const label = progress.phase === "done" ? "Entpacken 100%" diff --git a/src/main/extractor.ts b/src/main/extractor.ts index ab34455..fd3093a 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -14,6 +14,7 @@ let resolvedExtractorCommand: string | null = null; let resolveFailureReason = ""; let resolveFailureAt = 0; let externalExtractorSupportsPerfFlags = true; +let resolveExtractorCommandInFlight: Promise | null = null; const EXTRACTOR_RETRY_AFTER_MS = 30_000; const DEFAULT_ZIP_ENTRY_MEMORY_LIMIT_MB = 256; @@ -30,6 +31,7 @@ export interface ExtractOptions { onProgress?: (update: ExtractProgressUpdate) => void; onlyArchives?: Set; skipPostCleanup?: boolean; + packageId?: string; } export interface ExtractProgressUpdate { @@ -227,12 +229,15 @@ function computeExtractTimeoutMs(archivePath: string): number { } } -function extractProgressFilePath(packageDir: string): string { +function extractProgressFilePath(packageDir: string, packageId?: string): string { + if (packageId) { + return path.join(packageDir, `.rd_extract_progress_${packageId}.json`); + } return path.join(packageDir, EXTRACT_PROGRESS_FILE); } -function readExtractResumeState(packageDir: string): Set { - const progressPath = extractProgressFilePath(packageDir); +function readExtractResumeState(packageDir: string, packageId?: string): Set { + const progressPath = extractProgressFilePath(packageDir, packageId); if (!fs.existsSync(progressPath)) { return new Set(); } @@ -245,10 +250,10 @@ function readExtractResumeState(packageDir: string): Set { } } -function writeExtractResumeState(packageDir: string, completedArchives: Set): void { +function writeExtractResumeState(packageDir: string, completedArchives: Set, packageId?: string): void { try { fs.mkdirSync(packageDir, { recursive: true }); - const progressPath = extractProgressFilePath(packageDir); + const progressPath = extractProgressFilePath(packageDir, packageId); const payload: ExtractResumeState = { completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b)) }; @@ -258,9 +263,9 @@ function writeExtractResumeState(packageDir: string, completedArchives: Set { +async function resolveExtractorCommandInternal(): Promise { if (resolvedExtractorCommand) { return resolvedExtractorCommand; } @@ -531,6 +536,25 @@ async function resolveExtractorCommand(): Promise { throw new Error(resolveFailureReason); } +async function resolveExtractorCommand(): Promise { + if (resolvedExtractorCommand) { + return resolvedExtractorCommand; + } + if (resolveExtractorCommandInFlight) { + return resolveExtractorCommandInFlight; + } + + const pending = resolveExtractorCommandInternal(); + resolveExtractorCommandInFlight = pending; + try { + return await pending; + } finally { + if (resolveExtractorCommandInFlight === pending) { + resolveExtractorCommandInFlight = null; + } + } +} + async function runExternalExtract( archivePath: string, targetDir: string, @@ -627,12 +651,38 @@ function extractZipArchive(archivePath: string, targetDir: string, conflictMode: continue; } - const uncompressedSize = Number((entry as unknown as { header?: { size?: number } }).header?.size ?? NaN); + const header = (entry as unknown as { + header?: { + size?: number; + compressedSize?: number; + crc?: number; + dataHeader?: { + size?: number; + compressedSize?: number; + crc?: number; + }; + }; + }).header; + const uncompressedSize = Number(header?.size ?? header?.dataHeader?.size ?? NaN); + const compressedSize = Number(header?.compressedSize ?? header?.dataHeader?.compressedSize ?? NaN); + const crc = Number(header?.crc ?? header?.dataHeader?.crc ?? 0); + if (Number.isFinite(uncompressedSize) && uncompressedSize > memoryLimitBytes) { const entryMb = Math.ceil(uncompressedSize / (1024 * 1024)); const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); } + if (Number.isFinite(compressedSize) && compressedSize > memoryLimitBytes) { + const entryMb = Math.ceil(compressedSize / (1024 * 1024)); + const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024)); + throw new Error(`ZIP-Eintrag komprimiert zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`); + } + if ((!Number.isFinite(uncompressedSize) || uncompressedSize <= 0) + && Number.isFinite(compressedSize) + && compressedSize > 0 + && crc !== 0) { + throw new Error("ZIP-Eintrag ohne sichere Groessenangabe fur internen Entpacker"); + } fs.mkdirSync(path.dirname(outputPath), { recursive: true }); // TOCTOU note: There is a small race between existsSync and writeFileSync below. @@ -872,9 +922,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`); if (candidates.length === 0) { if (!options.onlyArchives) { - const existingResume = readExtractResumeState(options.packageDir); + const existingResume = readExtractResumeState(options.packageDir, options.packageId); if (existingResume.size > 0 && hasAnyEntries(options.targetDir)) { - clearExtractResumeState(options.packageDir); + clearExtractResumeState(options.packageDir, options.packageId); logger.info(`Entpacken übersprungen (Archive bereinigt, Ziel hat Dateien): ${options.packageDir}`); options.onProgress?.({ current: existingResume.size, @@ -885,7 +935,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ }); return { extracted: existingResume.size, failed: 0, lastError: "" }; } - clearExtractResumeState(options.packageDir); + clearExtractResumeState(options.packageDir, options.packageId); } logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`); return { extracted: 0, failed: 0, lastError: "" }; @@ -893,7 +943,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ const conflictMode = effectiveConflictMode(options.conflictMode); let passwordCandidates = archivePasswords(options.passwordList || ""); - const resumeCompleted = readExtractResumeState(options.packageDir); + const resumeCompleted = readExtractResumeState(options.packageDir, options.packageId); const resumeCompletedAtStart = resumeCompleted.size; const allCandidateNames = new Set(allCandidates.map((archivePath) => path.basename(archivePath))); for (const archiveName of Array.from(resumeCompleted.values())) { @@ -902,9 +952,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } } if (resumeCompleted.size > 0) { - writeExtractResumeState(options.packageDir, resumeCompleted); + writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); } else { - clearExtractResumeState(options.packageDir); + clearExtractResumeState(options.packageDir, options.packageId); } const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(path.basename(archivePath))); @@ -1000,7 +1050,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted += 1; extractedArchives.add(archivePath); resumeCompleted.add(archiveName); - writeExtractResumeState(options.packageDir, resumeCompleted); + writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`); archivePercent = 100; emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); @@ -1052,7 +1102,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ } if (failed === 0 && resumeCompleted.size >= allCandidates.length) { - clearExtractResumeState(options.packageDir); + clearExtractResumeState(options.packageDir, options.packageId); } if (!options.skipPostCleanup && options.cleanupMode === "delete" && !hasAnyFilesRecursive(options.packageDir)) { @@ -1074,9 +1124,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ if (failed > 0) { if (resumeCompleted.size > 0) { - writeExtractResumeState(options.packageDir, resumeCompleted); + writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId); } else { - clearExtractResumeState(options.packageDir); + clearExtractResumeState(options.packageDir, options.packageId); } } diff --git a/src/main/main.ts b/src/main/main.ts index 8619c2d..8b56e8c 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -197,7 +197,11 @@ function registerIpcHandlers(): void { validateString(payload?.rawText, "rawText"); return controller.addLinks(payload); }); - ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => controller.addContainers(filePaths ?? [])); + ipcMain.handle(IPC_CHANNELS.ADD_CONTAINERS, async (_event: IpcMainInvokeEvent, filePaths: string[]) => { + const validPaths = validateStringArray(filePaths ?? [], "filePaths"); + const safePaths = validPaths.filter((p) => path.isAbsolute(p) && !p.includes("..")); + return controller.addContainers(safePaths); + }); ipcMain.handle(IPC_CHANNELS.GET_START_CONFLICTS, () => controller.getStartConflicts()); ipcMain.handle(IPC_CHANNELS.RESOLVE_START_CONFLICT, (_event: IpcMainInvokeEvent, packageId: string, policy: "keep" | "skip" | "overwrite") => { validateString(packageId, "packageId"); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a205321..a386e9e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -98,6 +98,17 @@ export function reorderPackageOrderByDrop(order: string[], draggedPackageId: str return next; } +export function sortPackageOrderByName(order: string[], packages: Record, descending: boolean): string[] { + const sorted = [...order]; + sorted.sort((a, b) => { + const nameA = (packages[a]?.name ?? "").toLowerCase(); + const nameB = (packages[b]?.name ?? "").toLowerCase(); + const cmp = nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: "base" }); + return descending ? -cmp : cmp; + }); + return sorted; +} + export function App(): ReactElement { const [snapshot, setSnapshot] = useState(emptySnapshot); const [tab, setTab] = useState("collector"); @@ -120,6 +131,7 @@ export function App(): ReactElement { const draggedPackageIdRef = useRef(null); const [collapsedPackages, setCollapsedPackages] = useState>({}); const [downloadSearch, setDownloadSearch] = useState(""); + const [downloadsSortDescending, setDownloadsSortDescending] = useState(false); const [showAllPackages, setShowAllPackages] = useState(false); const [actionBusy, setActionBusy] = useState(false); const actionBusyRef = useRef(false); @@ -127,7 +139,6 @@ export function App(): ReactElement { const dragDepthRef = useRef(0); const [startConflictPrompt, setStartConflictPrompt] = useState(null); const startConflictResolverRef = useRef<((result: { policy: Extract; applyToAll: boolean } | null) => void) | null>(null); - const startConflictGlobalPolicyRef = useRef | null>(null); const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; @@ -456,7 +467,7 @@ export function App(): ReactElement { const conflicts = await window.rd.getStartConflicts(); let skipped = 0; let overwritten = 0; - let rememberedPolicy = startConflictGlobalPolicyRef.current; + let rememberedPolicy: Extract | null = null; for (const conflict of conflicts) { let decisionPolicy = rememberedPolicy; @@ -468,7 +479,6 @@ export function App(): ReactElement { } decisionPolicy = decision.policy; if (decision.applyToAll) { - startConflictGlobalPolicyRef.current = decision.policy; rememberedPolicy = decision.policy; } } @@ -558,7 +568,7 @@ export function App(): ReactElement { a.href = url; a.download = "rd-queue-export.json"; a.click(); - URL.revokeObjectURL(url); + setTimeout(() => URL.revokeObjectURL(url), 60_000); showToast("Queue exportiert"); }, (error) => { showToast(`Export fehlgeschlagen: ${String(error)}`, 2600); @@ -855,50 +865,36 @@ export function App(): ReactElement { )}
- - - +
+ + +