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
co-authored by Claude Opus 4.6
parent 06a272ccbd
commit cbc423e4b7
7 changed files with 200 additions and 92 deletions
+69 -19
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);
}
}