Harden updater integrity checks with latest.yml SHA512 fallback
This commit is contained in:
parent
bfbaee8e5c
commit
4bcb069ec7
@ -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" };
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user