Harden updater integrity checks with latest.yml SHA512 fallback

This commit is contained in:
Sucukdeluxe 2026-03-01 03:57:23 +01:00
parent bfbaee8e5c
commit 4bcb069ec7
2 changed files with 291 additions and 14 deletions

View File

@ -136,6 +136,28 @@ async function readJsonWithTimeout(response: Response, timeoutMs: number): Promi
}
}
async function readTextWithTimeout(response: Response, timeoutMs: number): Promise<string> {
let timer: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
void response.body?.cancel().catch(() => undefined);
reject(new Error(`timeout:${timeoutMs}`));
}, timeoutMs);
});
try {
const payload = await Promise.race([
response.text(),
timeoutPromise
]);
return String(payload || "");
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
function getDownloadBodyIdleTimeoutMs(): number {
const fromEnv = Number(process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS ?? NaN);
if (Number.isFinite(fromEnv) && fromEnv >= 1000 && fromEnv <= 30 * 60 * 1000) {
@ -199,6 +221,88 @@ function pickSetupAsset(assets: ReleaseAsset[]): ReleaseAsset | null {
|| installable[0];
}
function pickLatestYmlAsset(assets: ReleaseAsset[]): ReleaseAsset | null {
return assets.find((asset) => /^latest\.ya?ml$/i.test(asset.name))
|| assets.find((asset) => /latest/i.test(asset.name) && /\.ya?ml$/i.test(asset.name))
|| null;
}
function normalizeAssetNameForDigestMatch(value: string): string {
const trimmed = String(value || "").trim();
if (!trimmed) {
return "";
}
const fileName = trimmed.split(/[\\/]/g).filter(Boolean).pop() || trimmed;
return fileName.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function stripYamlScalar(raw: string): string {
const trimmed = String(raw || "").trim();
if (!trimmed) {
return "";
}
const unquoted = trimmed.replace(/^['"]+|['"]+$/g, "");
return unquoted.trim();
}
function parseSha512FromLatestYml(content: string, setupAssetName: string): string {
const lines = String(content || "").split(/\r?\n/g);
const targetNormalized = normalizeAssetNameForDigestMatch(setupAssetName);
let topLevelPath = "";
let topLevelSha = "";
let currentFileUrl = "";
let firstFileSha = "";
for (const rawLine of lines) {
const line = String(rawLine || "");
const fileUrlItem = line.match(/^\s*-\s*url\s*:\s*(.+)\s*$/i);
if (fileUrlItem?.[1]) {
currentFileUrl = stripYamlScalar(fileUrlItem[1]);
continue;
}
const fileUrl = line.match(/^\s*url\s*:\s*(.+)\s*$/i);
if (fileUrl?.[1]) {
currentFileUrl = stripYamlScalar(fileUrl[1]);
continue;
}
const pathMatch = line.match(/^\s*path\s*:\s*(.+)\s*$/i);
if (pathMatch?.[1]) {
topLevelPath = stripYamlScalar(pathMatch[1]);
continue;
}
const shaMatch = line.match(/^\s*sha512\s*:\s*([A-Za-z0-9+/=]{40,})\s*$/);
if (!shaMatch?.[1]) {
continue;
}
const sha = shaMatch[1].trim();
if (currentFileUrl) {
if (!firstFileSha) {
firstFileSha = sha;
}
if (targetNormalized) {
const fileUrlNormalized = normalizeAssetNameForDigestMatch(currentFileUrl);
if (fileUrlNormalized && fileUrlNormalized === targetNormalized) {
return sha;
}
}
currentFileUrl = "";
continue;
}
if (!topLevelSha) {
topLevelSha = sha;
}
}
if (targetNormalized && topLevelPath && topLevelSha) {
const topLevelPathNormalized = normalizeAssetNameForDigestMatch(topLevelPath);
if (topLevelPathNormalized && topLevelPathNormalized === targetNormalized) {
return topLevelSha;
}
}
return topLevelSha || firstFileSha || "";
}
function parseReleasePayload(payload: Record<string, unknown>, fallback: UpdateCheckResult): UpdateCheckResult {
const latestTag = String(payload.tag_name || `v${APP_VERSION}`).trim();
const latestVersion = latestTag.replace(/^v/i, "") || APP_VERSION;
@ -328,18 +432,34 @@ function deriveUpdateFileName(check: UpdateCheckResult, url: string): string {
}
}
function normalizeSha256Digest(raw: string): string {
type ExpectedDigest = {
algorithm: "sha256" | "sha512";
digest: string;
};
function parseExpectedDigest(raw: string): ExpectedDigest | null {
const text = String(raw || "").trim();
const prefixed = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
if (prefixed) {
return prefixed[1].toLowerCase();
const prefixed256 = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
if (prefixed256) {
return { algorithm: "sha256", digest: prefixed256[1].toLowerCase() };
}
const plain = text.match(/^([a-fA-F0-9]{64})$/);
return plain ? plain[1].toLowerCase() : "";
const prefixed512 = text.match(/^sha512:([a-fA-F0-9]{128})$/i);
if (prefixed512) {
return { algorithm: "sha512", digest: prefixed512[1].toLowerCase() };
}
const plain256 = text.match(/^([a-fA-F0-9]{64})$/);
if (plain256) {
return { algorithm: "sha256", digest: plain256[1].toLowerCase() };
}
const plain512 = text.match(/^([a-fA-F0-9]{128})$/);
if (plain512) {
return { algorithm: "sha512", digest: plain512[1].toLowerCase() };
}
return null;
}
async function sha256File(filePath: string): Promise<string> {
const hash = crypto.createHash("sha256");
async function hashFile(filePath: string, algorithm: "sha256" | "sha512"): Promise<string> {
const hash = crypto.createHash(algorithm);
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
return await new Promise<string>((resolve, reject) => {
stream.on("data", (chunk: string | Buffer) => {
@ -350,15 +470,35 @@ async function sha256File(filePath: string): Promise<string> {
});
}
async function verifyInstallerBinaryShape(targetPath: string): Promise<void> {
const stats = await fs.promises.stat(targetPath);
if (!Number.isFinite(stats.size) || stats.size < 128 * 1024) {
throw new Error("Update-Installer ungültig (Datei zu klein)");
}
const handle = await fs.promises.open(targetPath, "r");
try {
const header = Buffer.alloc(2);
const result = await handle.read(header, 0, 2, 0);
if (result.bytesRead < 2 || header[0] !== 0x4d || header[1] !== 0x5a) {
throw new Error("Update-Installer ungültig (keine EXE-Datei)");
}
} finally {
await handle.close();
}
}
async function verifyDownloadedInstaller(targetPath: string, expectedDigestRaw: string): Promise<void> {
const expectedDigest = normalizeSha256Digest(expectedDigestRaw);
if (!expectedDigest) {
logger.warn("Update-Asset ohne SHA256-Digest aus API; Integritätsprüfung übersprungen");
await verifyInstallerBinaryShape(targetPath);
const expected = parseExpectedDigest(expectedDigestRaw);
if (!expected) {
logger.warn("Update-Asset ohne SHA-Digest; nur EXE-Basisprüfung durchgeführt");
return;
}
const actualDigest = await sha256File(targetPath);
if (actualDigest !== expectedDigest) {
throw new Error("Update-Integritätsprüfung fehlgeschlagen (SHA256 mismatch)");
const actualDigest = await hashFile(targetPath, expected.algorithm);
if (actualDigest !== expected.digest) {
throw new Error(`Update-Integritätsprüfung fehlgeschlagen (${expected.algorithm.toUpperCase()} mismatch)`);
}
}
@ -394,6 +534,57 @@ async function resolveSetupAssetFromApi(safeRepo: string, tagHint: string): Prom
return null;
}
async function resolveSetupDigestFromLatestYml(safeRepo: string, tagHint: string, setupAssetName: string): Promise<string> {
const endpointCandidates = uniqueStrings([
tagHint ? `releases/tags/${encodeURIComponent(tagHint)}` : "",
"releases/latest"
]);
for (const endpoint of endpointCandidates) {
try {
const release = await fetchReleasePayload(safeRepo, endpoint);
if (!release.ok || !release.payload) {
continue;
}
if (isDraftOrPrereleaseRelease(release.payload)) {
continue;
}
const assets = readReleaseAssets(release.payload);
const ymlAsset = pickLatestYmlAsset(assets);
if (!ymlAsset) {
continue;
}
const timeout = timeoutController(RELEASE_FETCH_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(ymlAsset.browser_download_url, {
headers: {
"User-Agent": UPDATE_USER_AGENT
},
signal: timeout.signal
});
} finally {
timeout.clear();
}
if (!response.ok) {
continue;
}
const yamlText = await readTextWithTimeout(response, RELEASE_FETCH_TIMEOUT_MS);
const sha512 = parseSha512FromLatestYml(yamlText, setupAssetName);
if (sha512) {
return `sha512:${sha512}`;
}
} catch {
// ignore and continue with next endpoint candidate
}
}
return "";
}
export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult> {
const safeRepo = normalizeUpdateRepo(repo);
const fallback = createFallbackResult(safeRepo);
@ -684,6 +875,17 @@ export async function installLatestUpdate(
}
}
if (!effectiveCheck.setupAssetDigest && effectiveCheck.setupAssetUrl) {
const digestFromYml = await resolveSetupDigestFromLatestYml(safeRepo, effectiveCheck.latestTag, effectiveCheck.setupAssetName || "");
if (digestFromYml) {
effectiveCheck = {
...effectiveCheck,
setupAssetDigest: digestFromYml
};
logger.info("Update-Integritätsdigest aus latest.yml übernommen");
}
}
const candidates = buildDownloadCandidates(safeRepo, effectiveCheck);
if (candidates.length === 0) {
return { started: false, message: "Setup-Asset nicht gefunden" };

View File

@ -11,6 +11,10 @@ function sha256Hex(buffer: Buffer): string {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
function sha512Hex(buffer: Buffer): string {
return crypto.createHash("sha512").update(buffer).digest("hex");
}
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
@ -287,6 +291,77 @@ describe("update", () => {
expect(result.message).toMatch(/integrit|sha256|mismatch/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");
const requestedUrls: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
requestedUrls.push(url);
if (url.endsWith("/releases/tags/v9.9.9")) {
return new Response(JSON.stringify({
tag_name: "v9.9.9",
draft: false,
prerelease: false,
assets: [
{
name: "Real-Debrid-Downloader Setup 9.9.9.exe",
browser_download_url: "https://example.invalid/setup-no-digest.exe"
},
{
name: "latest.yml",
browser_download_url: "https://example.invalid/latest.yml"
}
]
}), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
if (url.includes("latest.yml")) {
return new Response(
`version: 9.9.9\npath: Real-Debrid-Downloader-Setup-9.9.9.exe\nsha512: ${digestSha512Base64}\n`,
{
status: 200,
headers: { "Content-Type": "text/yaml" }
}
);
}
if (url.includes("setup-no-digest.exe")) {
return new Response(executablePayload, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": String(executablePayload.length)
}
});
}
return new Response("missing", { status: 404 });
}) as typeof fetch;
const prechecked: UpdateCheckResult = {
updateAvailable: true,
currentVersion: APP_VERSION,
latestVersion: "9.9.9",
latestTag: "v9.9.9",
releaseUrl: "https://codeberg.org/owner/repo/releases/tag/v9.9.9",
setupAssetUrl: "https://example.invalid/setup-no-digest.exe",
setupAssetName: "Real-Debrid-Downloader Setup 9.9.9.exe",
setupAssetDigest: ""
};
const result = await installLatestUpdate("owner/repo", prechecked);
expect(result.started).toBe(true);
expect(requestedUrls.some((url) => url.endsWith("/releases/tags/v9.9.9"))).toBe(true);
expect(requestedUrls.some((url) => url.includes("latest.yml"))).toBe(true);
});
it("emits install progress events while downloading and launching update", async () => {
const executablePayload = fs.readFileSync(process.execPath);
const digest = sha256Hex(executablePayload);