feat: add in-app update flow and native e2e verification (#1112)

* feat(app-update): add in-app update flow with native e2e coverage

* docs(ai-workflow): infer closed-issue labels automatically

* fix(app-update): address native updater review findings

* fix(android): validate updater redirect hosts

* fix(electron): harden updater file names
This commit is contained in:
Tommaso Casaburi
2026-03-20 13:31:52 +08:00
committed by GitHub
parent dd4451c8c5
commit 5ecf8fbea8
11 changed files with 321 additions and 42 deletions
@@ -20,6 +20,8 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.util.Locale;
import okhttp3.OkHttpClient;
import okhttp3.Request;
@@ -29,6 +31,8 @@ import okhttp3.Response;
public class AppUpdaterPlugin extends Plugin {
private static final String TAG = "AppUpdaterPlugin";
private static final String EMULATOR_HOST = "10.0.2.2";
private static final String GITHUB_HOST = "github.com";
private static final String GITHUB_CONTENT_HOST = "githubusercontent.com";
@PluginMethod
public void downloadAndInstallUpdate(PluginCall call) {
@@ -63,36 +67,59 @@ public class AppUpdaterPlugin extends Plugin {
return (getContext().getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
}
private String sanitizeUrl(String value) {
static boolean isAllowedDownloadUrl(
String value, boolean allowGitHubContentHosts, boolean allowDebugHosts) {
if (value == null) {
return null;
return false;
}
String sanitized = value.trim();
Uri uri = Uri.parse(sanitized);
if (sanitized.isEmpty()) {
return false;
}
final URI uri;
try {
uri = URI.create(sanitized);
} catch (IllegalArgumentException exception) {
return false;
}
String scheme = uri.getScheme();
String host = uri.getHost();
if (scheme == null || host == null) {
return null;
return false;
}
String normalizedScheme = scheme.toLowerCase();
String normalizedHost = host.toLowerCase();
String normalizedScheme = scheme.toLowerCase(Locale.ROOT);
String normalizedHost = host.toLowerCase(Locale.ROOT);
if ("https".equals(normalizedScheme) && "github.com".equals(normalizedHost)) {
return sanitized;
if ("https".equals(normalizedScheme)
&& (GITHUB_HOST.equals(normalizedHost)
|| (allowGitHubContentHosts
&& (GITHUB_CONTENT_HOST.equals(normalizedHost)
|| normalizedHost.endsWith("." + GITHUB_CONTENT_HOST))))) {
return true;
}
if (isDebugBuild()
if (allowDebugHosts
&& ("http".equals(normalizedScheme) || "https".equals(normalizedScheme))
&& (EMULATOR_HOST.equals(normalizedHost)
|| "127.0.0.1".equals(normalizedHost)
|| "localhost".equals(normalizedHost))) {
return sanitized;
return true;
}
return null;
return false;
}
private String sanitizeUrl(String value) {
if (!isAllowedDownloadUrl(value, false, isDebugBuild())) {
return null;
}
return value.trim();
}
private String sanitizeFileName(String value) {
@@ -126,6 +153,11 @@ public class AppUpdaterPlugin extends Plugin {
throw new IOException("Unexpected response " + response.code());
}
String finalUrl = response.request().url().toString();
if (!isAllowedDownloadUrl(finalUrl, true, isDebugBuild())) {
throw new IOException("Unexpected redirected download host");
}
try (InputStream inputStream = response.body().byteStream();
OutputStream outputStream = new java.io.FileOutputStream(tempFile)) {
byte[] buffer = new byte[8192];
@@ -0,0 +1,69 @@
package fivechan.android;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class AppUpdaterPluginTest {
@Test
public void initialDownload_requiresGithubHost() {
assertTrue(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://github.com/bitsocialnet/5chan/releases/download/v0.7.3/5chan.apk",
false,
false));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://objects.githubusercontent.com/github-production-release-asset.apk",
false,
false));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://evil.example/5chan.apk", false, false));
}
@Test
public void redirectedDownload_allowsGithubContentHostsOnly() {
assertTrue(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://objects.githubusercontent.com/github-production-release-asset.apk",
true,
false));
assertTrue(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://release-assets.githubusercontent.com/file.apk", true, false));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://githubusercontent.evil.example/file.apk", true, false));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://evil.example/file.apk", true, false));
}
@Test
public void debugDownloads_allowOnlyLocalHosts() {
assertTrue(
AppUpdaterPlugin.isAllowedDownloadUrl(
"http://10.0.2.2:56405/5chan.apk", false, true));
assertTrue(
AppUpdaterPlugin.isAllowedDownloadUrl(
"https://localhost:56405/5chan.apk", false, true));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"http://192.168.1.8:56405/5chan.apk", false, true));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"http://10.0.2.2:56405/5chan.apk", false, false));
}
@Test
public void malformedOrUnsupportedUrls_areRejected() {
assertFalse(AppUpdaterPlugin.isAllowedDownloadUrl(null, true, true));
assertFalse(AppUpdaterPlugin.isAllowedDownloadUrl("not a url", true, true));
assertFalse(
AppUpdaterPlugin.isAllowedDownloadUrl(
"ftp://github.com/bitsocialnet/5chan/file.apk", true, true));
}
}