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 {
|
function getDownloadBodyIdleTimeoutMs(): number {
|
||||||
const fromEnv = Number(process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS ?? NaN);
|
const fromEnv = Number(process.env.RD_UPDATE_BODY_IDLE_TIMEOUT_MS ?? NaN);
|
||||||
if (Number.isFinite(fromEnv) && fromEnv >= 1000 && fromEnv <= 30 * 60 * 1000) {
|
if (Number.isFinite(fromEnv) && fromEnv >= 1000 && fromEnv <= 30 * 60 * 1000) {
|
||||||
@ -199,6 +221,88 @@ function pickSetupAsset(assets: ReleaseAsset[]): ReleaseAsset | null {
|
|||||||
|| installable[0];
|
|| 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 {
|
function parseReleasePayload(payload: Record<string, unknown>, fallback: UpdateCheckResult): UpdateCheckResult {
|
||||||
const latestTag = String(payload.tag_name || `v${APP_VERSION}`).trim();
|
const latestTag = String(payload.tag_name || `v${APP_VERSION}`).trim();
|
||||||
const latestVersion = latestTag.replace(/^v/i, "") || APP_VERSION;
|
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 text = String(raw || "").trim();
|
||||||
const prefixed = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
|
const prefixed256 = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
|
||||||
if (prefixed) {
|
if (prefixed256) {
|
||||||
return prefixed[1].toLowerCase();
|
return { algorithm: "sha256", digest: prefixed256[1].toLowerCase() };
|
||||||
}
|
}
|
||||||
const plain = text.match(/^([a-fA-F0-9]{64})$/);
|
const prefixed512 = text.match(/^sha512:([a-fA-F0-9]{128})$/i);
|
||||||
return plain ? plain[1].toLowerCase() : "";
|
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> {
|
async function hashFile(filePath: string, algorithm: "sha256" | "sha512"): Promise<string> {
|
||||||
const hash = crypto.createHash("sha256");
|
const hash = crypto.createHash(algorithm);
|
||||||
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
const stream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
||||||
return await new Promise<string>((resolve, reject) => {
|
return await new Promise<string>((resolve, reject) => {
|
||||||
stream.on("data", (chunk: string | Buffer) => {
|
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> {
|
async function verifyDownloadedInstaller(targetPath: string, expectedDigestRaw: string): Promise<void> {
|
||||||
const expectedDigest = normalizeSha256Digest(expectedDigestRaw);
|
await verifyInstallerBinaryShape(targetPath);
|
||||||
if (!expectedDigest) {
|
|
||||||
logger.warn("Update-Asset ohne SHA256-Digest aus API; Integritätsprüfung übersprungen");
|
const expected = parseExpectedDigest(expectedDigestRaw);
|
||||||
|
if (!expected) {
|
||||||
|
logger.warn("Update-Asset ohne SHA-Digest; nur EXE-Basisprüfung durchgeführt");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const actualDigest = await sha256File(targetPath);
|
const actualDigest = await hashFile(targetPath, expected.algorithm);
|
||||||
if (actualDigest !== expectedDigest) {
|
if (actualDigest !== expected.digest) {
|
||||||
throw new Error("Update-Integritätsprüfung fehlgeschlagen (SHA256 mismatch)");
|
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;
|
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> {
|
export async function checkGitHubUpdate(repo: string): Promise<UpdateCheckResult> {
|
||||||
const safeRepo = normalizeUpdateRepo(repo);
|
const safeRepo = normalizeUpdateRepo(repo);
|
||||||
const fallback = createFallbackResult(safeRepo);
|
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);
|
const candidates = buildDownloadCandidates(safeRepo, effectiveCheck);
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return { started: false, message: "Setup-Asset nicht gefunden" };
|
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");
|
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sha512Hex(buffer: Buffer): string {
|
||||||
|
return crypto.createHash("sha512").update(buffer).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@ -287,6 +291,77 @@ describe("update", () => {
|
|||||||
expect(result.message).toMatch(/integrit|sha256|mismatch/i);
|
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 () => {
|
it("emits install progress events while downloading and launching update", async () => {
|
||||||
const executablePayload = fs.readFileSync(process.execPath);
|
const executablePayload = fs.readFileSync(process.execPath);
|
||||||
const digest = sha256Hex(executablePayload);
|
const digest = sha256Hex(executablePayload);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user