From 87e4b42ed80ecb2ded3f4ab71089f6498801848d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Resul=20=C3=96v=C3=BCr?= <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:12:33 +0300 Subject: [PATCH] fix: third-party GitHub mirrors only serve downloads with a trusted digest (#1057) fix: a third-party GitHub mirror can only serve downloads that carry a trusted digest Downloads without an expected SHA-256 were still sent through gh-proxy, ghfast and other mirrors, and the first answer was accepted, so a mirror could swap an AppImage or a Windows component. Downloader.downloadFile now tries only the original URL when there is no digest. UserApps passes GitHub's own asset digest, and WinComponents passes the snapshot's archive digest, so those large files keep their mirror fallback. Co-authored-by: ovurrsl Co-authored-by: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> --- .../java/com/droiddeck/launcher/core/Downloader.java | 7 ++++++- .../java/com/droiddeck/launcher/runtime/UserApps.kt | 12 ++++++++---- .../com/droiddeck/launcher/session/WinComponents.kt | 4 +++- .../droiddeck/launcher/core/DownloaderResumeTest.kt | 6 ++++++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java index 2527c57e8..183076188 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/Downloader.java +++ b/app/src/main/java/com/droiddeck/launcher/core/Downloader.java @@ -54,7 +54,12 @@ public final class Downloader { java.util.function.Consumer progress) { if (expectedSha256 != null && !expectedSha256.matches("(?i)[0-9a-f]{64}")) throw new IllegalArgumentException("Expected SHA-256 must be 64 hex characters"); - return downloadCandidates(url, DownloadSource.INSTANCE.candidates(url), destination, resume, expectedSha256, progress); + return downloadCandidates(url, sources(url, expectedSha256), destination, resume, expectedSha256, progress); + } + + /** A third-party mirror may only serve bytes that can be checked against a digest from a direct source. */ + static List sources(String url, String expectedSha256) { + return expectedSha256 == null ? java.util.Collections.singletonList(url) : DownloadSource.INSTANCE.candidates(url); } // Package-private for the local multi-source HTTP test. diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt index 3bd000d7d..b7ca15a18 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt @@ -13,6 +13,7 @@ import com.caverock.androidsvg.SVG import com.droiddeck.launcher.R import com.droiddeck.launcher.core.Downloader import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.core.Hashes import com.droiddeck.launcher.store.FlathubApi import org.json.JSONArray import org.json.JSONObject @@ -44,7 +45,7 @@ object UserApps { val fex: String = LinuxFex.AUTO, val arch: String? = null, ) - class Release(val tag: String, val published: String, val asset: String, val url: String) + class Release(val tag: String, val published: String, val asset: String, val url: String, val sha256: String? = null) sealed class UpdateCheck { class Current(val tag: String) : UpdateCheck() @@ -318,9 +319,12 @@ object UserApps { val r = releases.getJSONObject(i) if (r.optBoolean("draft")) return@mapNotNull null val assets = r.optJSONArray("assets") ?: return@mapNotNull null - val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it.getString("browser_download_url") } } + val byName = (0 until assets.length()).associate { j -> assets.getJSONObject(j).let { it.getString("name") to it } } pickAsset(byName.keys.toList())?.let { asset -> - r.optBoolean("prerelease") to Release(r.optString("tag_name", asset), r.optString("published_at"), asset, byName.getValue(asset)) + r.optBoolean("prerelease") to byName.getValue(asset).let { a -> + Release(r.optString("tag_name", asset), r.optString("published_at"), asset, a.getString("browser_download_url"), + Hashes.githubSha256(a.optString("digest"))) + } } } val pick = (candidates.firstOrNull { !it.first } ?: candidates.firstOrNull())?.second @@ -331,7 +335,7 @@ object UserApps { val download = File(context.cacheDir, "github-${release.asset}") try { val label = context.getString(R.string.user_apps_downloading, release.asset) - val ok = Downloader.downloadFile(release.url, download, false) { f -> onProgress(label, if (f < 0) -1 else Math.round(f * 100f)) } + val ok = Downloader.downloadFile(release.url, download, false, release.sha256) { f -> onProgress(label, if (f < 0) -1 else Math.round(f * 100f)) } return if (ok) use(download) else context.getString(R.string.user_apps_download_failed) } finally { download.delete() diff --git a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt index c79171df2..2c5a1aa0d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt @@ -369,7 +369,9 @@ object WinComponents { val archiveName = archive.optString("name") val url = c.snapshot.substringBeforeLast('/') + "/" + archiveName val file = File(work, "${c.name}.files.tar.xz") - if (!Downloader.downloadFile(url, file, false) { f -> onProgress(Progress(c.name, archiveName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) { + // The snapshot came straight from github.com, so its digest may vouch for a mirror's copy of the archive. + val sha = archive.optString("sha256").takeIf { it.matches(Regex("(?i)[0-9a-f]{64}")) } + if (!Downloader.downloadFile(url, file, false, sha) { f -> onProgress(Progress(c.name, archiveName, Phase.DOWNLOAD, if (f < 0) -1 else Math.round(f * 100f))) }) { return null to "download failed: $archiveName" } archive.optString("sha256").takeIf { it.length == 64 }?.let { sha -> diff --git a/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt b/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt index a3faef070..73fd0161c 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/DownloaderResumeTest.kt @@ -167,4 +167,10 @@ class DownloaderResumeTest { assertEquals(listOf(catalog), DownloadSource.candidates(catalog)) } + + @Test fun withoutADigestOnlyTheOriginalUrlIsTried() { + val url = "https://github.com/owner/repo/releases/download/v1/app.AppImage" + assertEquals(listOf(url), Downloader.sources(url, null)) + assertEquals(DownloadSource.candidates(url), Downloader.sources(url, "a".repeat(64))) + } }