diff --git a/package.json b/package.json index a236f19..5715e06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.17", + "version": "1.4.18", "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 265f89e..067e418 100644 --- a/src/main/cleanup.ts +++ b/src/main/cleanup.ts @@ -110,8 +110,11 @@ export function removeDownloadLinkArtifacts(extractDir: string): number { if (!shouldDelete && [".txt", ".html", ".htm", ".nfo"].includes(ext)) { if (/[._\- ](links?|downloads?|urls?|dlc)([._\- ]|$)/i.test(name)) { try { - const text = fs.readFileSync(full, "utf8"); - shouldDelete = /https?:\/\//i.test(text); + const stat = fs.statSync(full); + if (stat.size <= 256 * 1024) { + const text = fs.readFileSync(full, "utf8"); + shouldDelete = /https?:\/\//i.test(text); + } } catch { shouldDelete = false; } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index b4863b8..92c3fc6 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -76,7 +76,21 @@ type DownloadManagerOptions = { }; function cloneSession(session: SessionState): SessionState { - return JSON.parse(JSON.stringify(session)) as SessionState; + const clonedItems: Record = {}; + for (const key of Object.keys(session.items)) { + clonedItems[key] = { ...session.items[key] }; + } + const clonedPackages: Record = {}; + for (const key of Object.keys(session.packages)) { + const pkg = session.packages[key]; + clonedPackages[key] = { ...pkg, itemIds: [...pkg.itemIds] }; + } + return { + ...session, + packageOrder: [...session.packageOrder], + packages: clonedPackages, + items: clonedItems + }; } function parseContentRangeTotal(contentRange: string | null): number | null { @@ -1605,6 +1619,8 @@ export class DownloadManager extends EventEmitter { } delete this.session.packages[packageId]; this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); + this.runPackageIds.delete(packageId); + this.runCompletedPackages.delete(packageId); } private async ensureScheduler(): Promise { @@ -2120,7 +2136,13 @@ export class DownloadManager extends EventEmitter { let lastError = ""; let effectiveTargetPath = targetPath; for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { - const existingBytes = fs.existsSync(effectiveTargetPath) ? fs.statSync(effectiveTargetPath).size : 0; + let existingBytes = 0; + try { + const stat = await fs.promises.stat(effectiveTargetPath); + existingBytes = stat.size; + } catch { + // file does not exist + } const headers: Record = {}; if (existingBytes > 0) { headers.Range = `bytes=${existingBytes}-`; @@ -2884,13 +2906,7 @@ export class DownloadManager extends EventEmitter { return; } - for (const itemId of pkg.itemIds) { - delete this.session.items[itemId]; - } - delete this.session.packages[packageId]; - this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); - this.runPackageIds.delete(packageId); - this.runCompletedPackages.delete(packageId); + this.removePackageFromSession(packageId, [...pkg.itemIds]); } private applyCompletedCleanupPolicy(packageId: string, itemId: string): void { @@ -2907,29 +2923,21 @@ export class DownloadManager extends EventEmitter { if (policy === "immediate") { pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); delete this.session.items[itemId]; + if (pkg.itemIds.length === 0) { + this.removePackageFromSession(packageId, []); + } + return; } if (policy === "package_done") { const hasOpen = pkg.itemIds.some((id) => { const item = this.session.items[id]; - if (!item) { - return false; - } - return item.status !== "completed"; + return item != null && item.status !== "completed"; }); if (!hasOpen) { - for (const id of pkg.itemIds) { - delete this.session.items[id]; - } - delete this.session.packages[packageId]; - this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); + this.removePackageFromSession(packageId, [...pkg.itemIds]); } } - - if (pkg.itemIds.length === 0) { - delete this.session.packages[packageId]; - this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId); - } } private finishRun(): void { diff --git a/src/main/extractor.ts b/src/main/extractor.ts index 56ca0ec..cf44385 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -936,7 +936,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{ extractZipArchive(archivePath, options.targetDir, options.conflictMode); archivePercent = 100; } catch { - const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => { + const usedPassword = await runExternalExtract(archivePath, options.targetDir, "overwrite", passwordCandidates, (value) => { archivePercent = Math.max(archivePercent, value); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); }, options.signal); diff --git a/src/main/integrity.ts b/src/main/integrity.ts index 5489050..697925a 100644 --- a/src/main/integrity.ts +++ b/src/main/integrity.ts @@ -52,6 +52,10 @@ export function readHashManifest(packageDir: string): Map 5 * 1024 * 1024) { + continue; + } lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/); } catch { continue; diff --git a/src/main/storage.ts b/src/main/storage.ts index c857e1e..8f8cd5d 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -158,13 +158,26 @@ export function loadSettings(paths: StoragePaths): AppSettings { } } +function syncRenameWithExdevFallback(tempPath: string, targetPath: string): void { + try { + fs.renameSync(tempPath, targetPath); + } catch (renameError: unknown) { + if ((renameError as NodeJS.ErrnoException).code === "EXDEV") { + fs.copyFileSync(tempPath, targetPath); + try { fs.rmSync(tempPath, { force: true }); } catch {} + } else { + throw renameError; + } + } +} + export function saveSettings(paths: StoragePaths, settings: AppSettings): void { ensureBaseDir(paths.baseDir); const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); const payload = JSON.stringify(persisted, null, 2); const tempPath = `${paths.configFile}.tmp`; fs.writeFileSync(tempPath, payload, "utf8"); - fs.renameSync(tempPath, paths.configFile); + syncRenameWithExdevFallback(tempPath, paths.configFile); } export function emptySession(): SessionState { @@ -209,7 +222,7 @@ export function saveSession(paths: StoragePaths, session: SessionState): void { const payload = JSON.stringify({ ...session, updatedAt: Date.now() }); const tempPath = `${paths.sessionFile}.tmp`; fs.writeFileSync(tempPath, payload, "utf8"); - fs.renameSync(tempPath, paths.sessionFile); + syncRenameWithExdevFallback(tempPath, paths.sessionFile); } let asyncSaveRunning = false; diff --git a/src/main/utils.ts b/src/main/utils.ts index da4e094..11d6eaa 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -21,7 +21,7 @@ export function compactErrorText(message: unknown, maxLen = 220): string { } export function sanitizeFilename(name: string): string { - const cleaned = String(name || "").trim().replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim(); + const cleaned = String(name || "").trim().replace(/\0/g, "").replace(/[\\/:*?"<>|]/g, " ").replace(/\s+/g, " ").trim(); return cleaned || "Paket"; } diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 190e604..b54748d 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -421,4 +421,30 @@ describe("extractor", () => { expect(result.failed).toBe(0); expect(result.extracted).toBe(0); }); + + it("rejects zip entries with path traversal", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + const targetDir = path.join(root, "out"); + fs.mkdirSync(packageDir, { recursive: true }); + + const zip = new AdmZip(); + zip.addFile("safe.txt", Buffer.from("safe")); + zip.addFile("../escaped.txt", Buffer.from("malicious")); + zip.writeZip(path.join(packageDir, "traversal.zip")); + + const result = await extractPackageArchives({ + packageDir, + targetDir, + cleanupMode: "none", + conflictMode: "overwrite", + removeLinks: false, + removeSamples: false + }); + + expect(result.extracted).toBe(1); + expect(fs.existsSync(path.join(targetDir, "safe.txt"))).toBe(true); + expect(fs.existsSync(path.join(root, "escaped.txt"))).toBe(false); + }); }); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index cc163fc..dbeb437 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -12,6 +12,8 @@ describe("utils", () => { it("sanitizes filenames", () => { expect(sanitizeFilename("foo/bar:baz*")).toBe("foo bar baz"); expect(sanitizeFilename(" ")).toBe("Paket"); + expect(sanitizeFilename("test\0file.txt")).toBe("testfile.txt"); + expect(sanitizeFilename("\0\0\0")).toBe("Paket"); }); it("parses package markers", () => {