Harden download integrity, extraction safety, and update security
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user