Harden download integrity, extraction safety, and update security

This commit is contained in:
Sucukdeluxe
2026-03-28 16:27:21 +01:00
parent 792a4249d0
commit 653e756010
13 changed files with 511 additions and 198 deletions
+94 -46
View File
@@ -17,16 +17,48 @@ const VALID_SPEED_MODES = new Set(["global", "per_download"]);
const VALID_THEMES = new Set(["dark", "light"]);
const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]);
const VALID_HISTORY_RETENTION_MODES = new Set<HistoryRetentionMode>(["never", "session", "permanent"]);
const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
]);
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
]);
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
function asText(value: unknown): string {
return String(value ?? "").trim();
}
function asText(value: unknown): string {
return String(value ?? "").trim();
}
function normalizeSessionId(value: unknown): string {
const text = asText(value);
if (!text || !SAFE_SESSION_ID_RE.test(text)) {
return "";
}
return text;
}
function isPathInsideDir(filePath: string, dirPath: string): boolean {
try {
const resolvedFile = path.resolve(filePath);
const resolvedDir = path.resolve(dirPath);
const normalizedFile = process.platform === "win32" ? resolvedFile.toLowerCase() : resolvedFile;
const normalizedDir = process.platform === "win32" ? resolvedDir.toLowerCase() : resolvedDir;
return normalizedFile === normalizedDir || normalizedFile.startsWith(`${normalizedDir}${path.sep}`);
} catch {
return false;
}
}
function normalizeSessionTargetPath(value: unknown, packageOutputDir: string): string {
const targetPath = asText(value);
if (!targetPath || !packageOutputDir || !path.isAbsolute(targetPath)) {
return "";
}
if (!isPathInsideDir(targetPath, packageOutputDir)) {
return "";
}
return path.resolve(targetPath);
}
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
const num = Number(value);
@@ -538,18 +570,18 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
const now = Date.now();
const itemsById: Record<string, DownloadItem> = {};
const rawItems = asRecord(parsed.items) ?? {};
for (const [entryId, rawItem] of Object.entries(rawItems)) {
const item = asRecord(rawItem);
if (!item) {
continue;
}
const id = asText(item.id) || entryId;
const packageId = asText(item.packageId);
const url = asText(item.url);
if (!id || !packageId || !url) {
continue;
}
const rawItems = asRecord(parsed.items) ?? {};
for (const [entryId, rawItem] of Object.entries(rawItems)) {
const item = asRecord(rawItem);
if (!item) {
continue;
}
const id = normalizeSessionId(item.id) || normalizeSessionId(entryId);
const packageId = normalizeSessionId(item.packageId);
const url = asText(item.url);
if (!id || !packageId || !url) {
continue;
}
const statusRaw = asText(item.status) as DownloadStatus;
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
@@ -584,16 +616,16 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
}
const packagesById: Record<string, PackageEntry> = {};
const rawPackages = asRecord(parsed.packages) ?? {};
for (const [entryId, rawPkg] of Object.entries(rawPackages)) {
const pkg = asRecord(rawPkg);
if (!pkg) {
continue;
}
const id = asText(pkg.id) || entryId;
if (!id) {
continue;
}
const rawPackages = asRecord(parsed.packages) ?? {};
for (const [entryId, rawPkg] of Object.entries(rawPackages)) {
const pkg = asRecord(rawPkg);
if (!pkg) {
continue;
}
const id = normalizeSessionId(pkg.id) || normalizeSessionId(entryId);
if (!id) {
continue;
}
const statusRaw = asText(pkg.status) as DownloadStatus;
const status: DownloadStatus = VALID_DOWNLOAD_STATUSES.has(statusRaw) ? statusRaw : "queued";
const rawItemIds = Array.isArray(pkg.itemIds) ? pkg.itemIds : [];
@@ -601,11 +633,11 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
id,
name: asText(pkg.name) || "Paket",
outputDir: asText(pkg.outputDir),
extractDir: asText(pkg.extractDir),
status,
itemIds: rawItemIds
.map((value) => asText(value))
.filter((value) => value.length > 0),
extractDir: asText(pkg.extractDir),
status,
itemIds: rawItemIds
.map((value) => normalizeSessionId(value))
.filter((value) => value.length > 0),
cancelled: Boolean(pkg.cancelled),
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled),
priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal",
@@ -623,9 +655,25 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
delete itemsById[itemId];
}
}
if (orphanedItemCount > 0) {
logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`);
}
if (orphanedItemCount > 0) {
logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`);
}
let droppedUnsafeTargetPathCount = 0;
for (const item of Object.values(itemsById)) {
const pkg = packagesById[item.packageId];
if (!pkg) {
continue;
}
const safeTargetPath = normalizeSessionTargetPath(item.targetPath, pkg.outputDir);
if (!safeTargetPath && asText(item.targetPath)) {
droppedUnsafeTargetPathCount += 1;
}
item.targetPath = safeTargetPath;
}
if (droppedUnsafeTargetPathCount > 0) {
logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`);
}
for (const pkg of Object.values(packagesById)) {
pkg.itemIds = pkg.itemIds.filter((itemId) => {
@@ -634,13 +682,13 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
});
}
const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
const seenOrder = new Set<string>();
const packageOrder = rawOrder
.map((entry) => asText(entry))
.filter((id) => {
if (!(id in packagesById) || seenOrder.has(id)) {
return false;
const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
const seenOrder = new Set<string>();
const packageOrder = rawOrder
.map((entry) => normalizeSessionId(entry))
.filter((id) => {
if (!(id in packagesById) || seenOrder.has(id)) {
return false;
}
seenOrder.add(id);
return true;