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 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-02-28 13:09:59 +01:00
parent 06a272ccbd
commit cbc423e4b7
7 changed files with 200 additions and 92 deletions

View File

@ -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",

View File

@ -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;

View File

@ -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<string, unknown> | 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);

View File

@ -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%"

View File

@ -14,6 +14,7 @@ let resolvedExtractorCommand: string | null = null;
let resolveFailureReason = "";
let resolveFailureAt = 0;
let externalExtractorSupportsPerfFlags = true;
let resolveExtractorCommandInFlight: Promise<string> | 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<string>;
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<string> {
const progressPath = extractProgressFilePath(packageDir);
function readExtractResumeState(packageDir: string, packageId?: string): Set<string> {
const progressPath = extractProgressFilePath(packageDir, packageId);
if (!fs.existsSync(progressPath)) {
return new Set<string>();
}
@ -245,10 +250,10 @@ function readExtractResumeState(packageDir: string): Set<string> {
}
}
function writeExtractResumeState(packageDir: string, completedArchives: Set<string>): void {
function writeExtractResumeState(packageDir: string, completedArchives: Set<string>, 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<stri
}
}
function clearExtractResumeState(packageDir: string): void {
function clearExtractResumeState(packageDir: string, packageId?: string): void {
try {
fs.rmSync(extractProgressFilePath(packageDir), { force: true });
fs.rmSync(extractProgressFilePath(packageDir, packageId), { force: true });
} catch {
// ignore
}
@ -497,7 +502,7 @@ export function buildExternalExtractArgs(
return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`];
}
async function resolveExtractorCommand(): Promise<string> {
async function resolveExtractorCommandInternal(): Promise<string> {
if (resolvedExtractorCommand) {
return resolvedExtractorCommand;
}
@ -531,6 +536,25 @@ async function resolveExtractorCommand(): Promise<string> {
throw new Error(resolveFailureReason);
}
async function resolveExtractorCommand(): Promise<string> {
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);
}
}

View File

@ -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");

View File

@ -98,6 +98,17 @@ export function reorderPackageOrderByDrop(order: string[], draggedPackageId: str
return next;
}
export function sortPackageOrderByName(order: string[], packages: Record<string, PackageEntry>, 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<UiSnapshot>(emptySnapshot);
const [tab, setTab] = useState<Tab>("collector");
@ -120,6 +131,7 @@ export function App(): ReactElement {
const draggedPackageIdRef = useRef<string | null>(null);
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
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<StartConflictPromptState | null>(null);
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
const startConflictGlobalPolicyRef = useRef<Extract<DuplicatePolicy, "skip" | "overwrite"> | 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<DuplicatePolicy, "skip" | "overwrite"> | 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 {
</div>
)}
<div className="downloads-toolbar">
<button
className="btn"
disabled={packages.length === 0}
onClick={() => {
setCollapsedPackages((prev) => {
const next: Record<string, boolean> = { ...prev };
const targetState = !allPackagesCollapsed;
for (const pkg of packages) {
next[pkg.id] = targetState;
}
return next;
});
}}
>
{allPackagesCollapsed ? "Alles ausklappen" : "Alles einklappen"}
</button>
<button
className="btn"
disabled={packages.length < 2}
onClick={() => {
const sorted = [...snapshot.session.packageOrder].sort((a, b) => {
const nameA = (snapshot.session.packages[a]?.name ?? "").toLowerCase();
const nameB = (snapshot.session.packages[b]?.name ?? "").toLowerCase();
return nameA.localeCompare(nameB);
});
void window.rd.reorderPackages(sorted);
}}
>
A-Z
</button>
<button
className="btn"
disabled={packages.length < 2}
onClick={() => {
const sorted = [...snapshot.session.packageOrder].sort((a, b) => {
const nameA = (snapshot.session.packages[a]?.name ?? "").toLowerCase();
const nameB = (snapshot.session.packages[b]?.name ?? "").toLowerCase();
return nameB.localeCompare(nameA);
});
void window.rd.reorderPackages(sorted);
}}
>
Z-A
</button>
<div className="downloads-toolbar-actions">
<button
className="btn"
disabled={packages.length === 0}
onClick={() => {
setCollapsedPackages((prev) => {
const next: Record<string, boolean> = { ...prev };
const targetState = !allPackagesCollapsed;
for (const pkg of packages) {
next[pkg.id] = targetState;
}
return next;
});
}}
>
{allPackagesCollapsed ? "Alles ausklappen" : "Alles einklappen"}
</button>
<button
className={`btn${downloadsSortDescending ? " btn-active" : ""}`}
disabled={packages.length < 2}
onClick={() => {
const nextDescending = !downloadsSortDescending;
setDownloadsSortDescending(nextDescending);
const sorted = sortPackageOrderByName(snapshot.session.packageOrder, snapshot.session.packages, nextDescending);
void window.rd.reorderPackages(sorted);
}}
>
{downloadsSortDescending ? "Z-A" : "A-Z"}
</button>
</div>
<input
className="search-input"
type="search"