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
+13 -7
View File
@@ -4235,7 +4235,8 @@ describe("download manager", () => {
for (const [index, archiveName] of archiveNames.entries()) {
const targetPath = path.join(outputDir, archiveName);
fs.writeFileSync(targetPath, Buffer.from(`part-${index}`));
const partBytes = Buffer.alloc(4096, 0x41 + index);
fs.writeFileSync(targetPath, partBytes);
session.items[itemIds[index]!] = {
id: itemIds[index]!,
packageId,
@@ -4244,8 +4245,8 @@ describe("download manager", () => {
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 4096,
totalBytes: 4096,
downloadedBytes: partBytes.length,
totalBytes: partBytes.length,
progressPercent: 100,
fileName: archiveName,
targetPath,
@@ -5315,9 +5316,9 @@ describe("download manager", () => {
const part2 = path.join(packageDir, "legacy.old.part02.rar");
const part3 = path.join(packageDir, "legacy.old.part03.rar");
const keep = path.join(packageDir, "keep.nfo");
fs.writeFileSync(part1, "part1", "utf8");
fs.writeFileSync(part2, "part2", "utf8");
fs.writeFileSync(part3, "part3", "utf8");
fs.writeFileSync(part1, Buffer.alloc(123, 0x61));
fs.writeFileSync(part2, Buffer.alloc(123, 0x62));
fs.writeFileSync(part3, Buffer.alloc(123, 0x63));
fs.writeFileSync(keep, "keep", "utf8");
fs.writeFileSync(path.join(extractDir, "episode.mkv"), "video", "utf8");
@@ -7846,7 +7847,12 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state"))
);
await waitFor(() => fs.existsSync(path.join(extractDir, "episode.txt")), 25000);
await waitFor(
() =>
fs.existsSync(path.join(extractDir, "episode.txt")) &&
manager.getSnapshot().session.packages[packageId]?.status === "completed",
25000
);
const snapshot = manager.getSnapshot();
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
expect(snapshot.session.items[itemId]?.fullStatus.startsWith("Entpackt - Done")).toBe(true);
+19
View File
@@ -63,4 +63,23 @@ describe("item-log", () => {
expect(content).toContain("archive=episode.part2.rar");
expect(content).toContain("code=missing_parts");
});
it("keeps traversal-like item ids inside the item log directory", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
tempDirs.push(baseDir);
initItemLogs(baseDir);
const logPath = ensureItemLog({
itemId: "..\\..\\outside",
packageId: "pkg-traversal",
packageName: "Traversal Paket",
fileName: "episode.part2.rar",
targetPath: "C:\\downloads\\Traversal Paket\\episode.part2.rar"
});
expect(logPath).not.toBeNull();
const logsDir = path.resolve(path.join(baseDir, "item-logs"));
const resolvedLogPath = path.resolve(logPath!);
expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true);
});
});
+18
View File
@@ -61,4 +61,22 @@ describe("package-log", () => {
expect(content).toContain("archive=episode.part1.rar");
expect(content).toContain("password=\"secret\"");
});
it("keeps traversal-like package ids inside the package log directory", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
tempDirs.push(baseDir);
initPackageLogs(baseDir);
const logPath = ensurePackageLog({
packageId: "..\\..\\outside",
name: "Traversal Paket",
outputDir: "C:\\downloads\\Traversal Paket",
extractDir: "C:\\extract\\Traversal Paket"
});
expect(logPath).not.toBeNull();
const logsDir = path.resolve(path.join(baseDir, "package-logs"));
const resolvedLogPath = path.resolve(logPath!);
expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true);
});
});
+72 -3
View File
@@ -572,7 +572,7 @@ describe("settings storage", () => {
expect(loaded.packageName).toBe("from-backup");
});
it("sanitizes malformed persisted session structures", () => {
it("sanitizes malformed persisted session structures", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
@@ -609,8 +609,77 @@ describe("settings storage", () => {
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages)).toEqual(["pkg-valid"]);
expect(Object.keys(loaded.items)).toEqual(["item-valid"]);
expect(loaded.packageOrder).toEqual(["pkg-valid"]);
});
expect(loaded.packageOrder).toEqual(["pkg-valid"]);
});
it("drops unsafe session ids and target paths outside the package output directory", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
tempDirs.push(dir);
const paths = createStoragePaths(dir);
const outputDir = path.join(dir, "downloads", "safe");
const safeTargetPath = path.join(outputDir, "safe.bin");
const outsideTargetPath = path.join(dir, "outside.bin");
fs.writeFileSync(paths.sessionFile, JSON.stringify({
version: 2,
packageOrder: ["pkg-safe", "../pkg-evil"],
packages: {
"pkg-safe": {
id: "pkg-safe",
name: "Safe Package",
outputDir,
extractDir: path.join(dir, "extract", "safe"),
status: "queued",
itemIds: ["item-safe", "item-outside", "../item-evil"],
cancelled: false,
enabled: true
},
"../pkg-evil": {
id: "../pkg-evil",
name: "Unsafe Package",
outputDir,
extractDir: path.join(dir, "extract", "unsafe"),
status: "queued",
itemIds: ["item-evil"],
cancelled: false,
enabled: true
}
},
items: {
"item-safe": {
id: "item-safe",
packageId: "pkg-safe",
url: "https://example.com/safe",
status: "queued",
fileName: "safe.bin",
targetPath: safeTargetPath
},
"item-outside": {
id: "item-outside",
packageId: "pkg-safe",
url: "https://example.com/outside",
status: "queued",
fileName: "outside.bin",
targetPath: outsideTargetPath
},
"../item-evil": {
id: "../item-evil",
packageId: "pkg-safe",
url: "https://example.com/evil",
status: "queued",
fileName: "evil.bin",
targetPath: safeTargetPath
}
}
}), "utf8");
const loaded = loadSession(paths);
expect(Object.keys(loaded.packages)).toEqual(["pkg-safe"]);
expect(Object.keys(loaded.items).sort()).toEqual(["item-outside", "item-safe"]);
expect(loaded.packageOrder).toEqual(["pkg-safe"]);
expect(path.resolve(loaded.items["item-safe"]?.targetPath || "")).toBe(path.resolve(safeTargetPath));
expect(loaded.items["item-outside"]?.targetPath).toBe("");
});
it("captures async session save payload before later mutations", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-"));
+35 -6
View File
@@ -290,7 +290,7 @@ describe("update", () => {
}
}, 20000);
it("blocks installer start when SHA256 digest mismatches", async () => {
it("blocks installer start when SHA256 digest mismatches", async () => {
const executablePayload = fs.readFileSync(process.execPath);
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
@@ -315,11 +315,40 @@ describe("update", () => {
};
const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(false);
expect(result.message).toMatch(/integrit|sha256|mismatch/i);
});
it("uses latest.yml SHA512 digest when API asset digest is missing", async () => {
expect(result.started).toBe(false);
expect(result.message).toMatch(/integrit|sha256|mismatch/i);
});
it("blocks installer start when no digest can be resolved", async () => {
const executablePayload = fs.readFileSync(process.execPath);
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("unsigned-setup.exe")) {
return new Response(executablePayload, {
status: 200,
headers: { "Content-Type": "application/octet-stream" }
});
}
return new Response("missing", { status: 404 });
}) as typeof fetch;
const prechecked: UpdateCheckResult = {
updateAvailable: true,
currentVersion: APP_VERSION,
latestVersion: "9.9.9",
latestTag: "",
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/unsigned-setup.exe",
setupAssetName: "setup.exe",
setupAssetDigest: ""
};
const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(false);
expect(result.message).toMatch(/digest|integrit|sha/i);
});
it("uses latest.yml SHA512 digest when API asset digest is missing", async () => {
const executablePayload = fs.readFileSync(process.execPath);
const digestSha512Hex = sha512Hex(executablePayload);
const digestSha512Base64 = Buffer.from(digestSha512Hex, "hex").toString("base64");