Merge commit from fork

* feat(updater): verify the version an update was signed for

The endpoint response is fetched over TLS but is not signed, and the signature
only covers the artifact, so a crafted response could pair an inflated version
with an older release's url and signature to force a downgrade to a genuine
but outdated build.

Read the version back from the signature's trusted comment and reject an
update whose announced version differs. Signatures carrying no version are
only rejected under the new requireSignedVersion option, since older CLIs did
not record one.

* fix tests
This commit is contained in:
Lucas Fernandes Nogueira
2026-09-19 20:57:51 -03:00
committed by GitHub
parent 67cd25a10c
commit 690dcfd694
5 changed files with 507 additions and 34 deletions
+320 -31
View File
@@ -9,12 +9,16 @@ use std::{
fs::File,
path::{Path, PathBuf},
process::Command,
sync::Arc,
sync::{Arc, Mutex},
};
use serde::Serialize;
use tauri::utils::config::{Updater, V1Compatible};
/// Every test here drives `cargo tauri build` against the same crate and target directory, and
/// binds a fixed port, so they cannot overlap.
static BUILD_LOCK: Mutex<()> = Mutex::new(());
const UPDATER_PRIVATE_KEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IHJzaWduIGVuY3J5cHRlZCBzZWNyZXQga2V5ClJXUlRZMEl5TlFOMFpXYzJFOUdjeHJEVXY4WE1TMUxGNDJVUjNrMmk1WlR3UVJVUWwva0FBQkFBQUFBQUFBQUFBQUlBQUFBQUpVK3ZkM3R3eWhyN3hiUXhQb2hvWFVzUW9FbEs3NlNWYjVkK1F2VGFRU1FEaGxuRUtlell5U0gxYS9DbVRrS0YyZVJGblhjeXJibmpZeGJjS0ZKSUYwYndYc2FCNXpHalM3MHcrODMwN3kwUG9SOWpFNVhCSUd6L0E4TGRUT096TEtLR1JwT1JEVFU9Cg==";
const UPDATED_EXIT_CODE: i32 = 0;
const ERROR_EXIT_CODE: i32 = 1;
@@ -24,6 +28,10 @@ const UP_TO_DATE_EXIT_CODE: i32 = 2;
struct Config {
version: &'static str,
bundle: BundleConfig,
/// Merged into `plugins` of the app's `tauri.conf.json`. Only set by the signed version
/// tests, which need to flip `requireSignedVersion` and point at their own server.
#[serde(skip_serializing_if = "Option::is_none")]
plugins: Option<serde_json::Value>,
}
#[derive(Serialize)]
@@ -35,13 +43,13 @@ struct BundleConfig {
#[derive(Serialize)]
struct PlatformUpdate {
signature: String,
url: &'static str,
url: String,
with_elevated_task: bool,
}
#[derive(Serialize)]
struct Update {
version: &'static str,
version: String,
date: String,
platforms: HashMap<String, PlatformUpdate>,
}
@@ -56,6 +64,12 @@ fn build_app(cwd: &Path, config: &Config, target: Option<BundleTarget>) {
.env("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", "")
.current_dir(cwd);
// linuxdeploy ships a `strip` that does not know the `.relr.dyn` sections recent distros emit,
// and it aborts the whole bundle when a strip call fails. Nothing here benefits from a smaller
// AppImage, so skip it.
#[cfg(target_os = "linux")]
command.env("NO_STRIP", "1");
if let Some(target) = target {
command.arg("--bundles").arg(target.name());
} else {
@@ -107,6 +121,61 @@ impl Default for BundleTarget {
}
}
/// Copies a bundle out of the bundler output directory, returning the path to run.
///
/// Every bundler wipes its output before writing — the AppImage one removes `bundle/appimage`
/// entirely, the macOS one the `.app` — so the app built for the initial version only survives
/// until the next `build_app` call. On Linux and macOS the update is also installed over the very
/// bundle being run, which would otherwise leave a 1.0.0 app behind in the bundler output.
///
/// Windows drives the executable `cargo build` leaves in `target/release`, which no bundler
/// touches, so there is nothing to copy there.
fn stage_app_under_test(root_dir: &Path, target: &str) -> PathBuf {
#[cfg(windows)]
{
let _ = target;
return root_dir.join("target/release/app-updater.exe");
}
#[cfg(not(windows))]
{
let bundle_path = test_cases(root_dir, "0.1.0", target.to_string())
.first()
.unwrap()
.1
.clone();
let staging_dir = root_dir.join("target/release/app-under-test");
let _ = std::fs::remove_dir_all(&staging_dir);
std::fs::create_dir_all(&staging_dir).expect("failed to create the staging directory");
let staged = staging_dir.join(bundle_path.file_name().unwrap());
copy_recursively(&bundle_path, &staged).unwrap_or_else(|e| {
panic!(
"failed to copy {} to {}: {e}",
bundle_path.display(),
staged.display()
)
});
return staged;
}
}
/// Copies a file, or a directory such as a macOS `.app`, preserving permissions.
fn copy_recursively(from: &Path, to: &Path) -> std::io::Result<()> {
if from.is_dir() {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
copy_recursively(&entry.path(), &to.join(entry.file_name()))?;
}
Ok(())
} else {
std::fs::copy(from, to).map(|_| ())
}
}
fn target_to_platforms(
update_platform: Option<String>,
signature: String,
@@ -117,7 +186,7 @@ fn target_to_platforms(
platform,
PlatformUpdate {
signature,
url: "http://localhost:3007/download",
url: "http://localhost:3007/download".into(),
with_elevated_task: false,
},
);
@@ -270,6 +339,8 @@ fn test_cases(
#[test]
fn update_app() {
let _lock = BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let target =
tauri_plugin_updater::target().expect("running updater test in an unsupported platform");
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
@@ -281,12 +352,14 @@ fn update_app() {
bundle: BundleConfig {
create_updater_artifacts: Updater::Bool(true),
},
plugins: None,
},
Config {
version: "1.0.0",
bundle: BundleConfig {
create_updater_artifacts: Updater::String(V1Compatible::V1Compatible),
},
plugins: None,
},
] {
let v1_compatible = matches!(
@@ -359,7 +432,7 @@ fn update_app() {
target_to_platforms(update_platform.clone(), signature.clone());
let body = serde_json::to_vec(&Update {
version: "1.0.0",
version: "1.0.0".into(),
date: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
@@ -393,41 +466,38 @@ fn update_app() {
config.version = "0.1.0";
// bundle initial app version
build_app(&manifest_dir, &config, None);
// bundle initial app version; Linux and macOS run the bundle itself, so it cannot be
// skipped there
build_app(
&manifest_dir,
&config,
if cfg!(windows) {
None
} else {
Some(bundle_target)
},
);
let app_path = stage_app_under_test(&root_dir, &target);
for expected_exit_code in status_checks {
let mut binary_cmd = if cfg!(windows) {
Command::new(root_dir.join("target/release/app-updater.exe"))
} else if cfg!(target_os = "macos") {
Command::new(
test_cases(&root_dir, "0.1.0", target.clone())
.first()
.unwrap()
.1
.join("Contents/MacOS/app-updater"),
)
} else if std::env::var("CI").map(|v| v == "true").unwrap_or_default() {
let mut binary_cmd = if cfg!(target_os = "macos") {
Command::new(app_path.join("Contents/MacOS/app-updater"))
} else if cfg!(target_os = "linux")
&& std::env::var("CI").map(|v| v == "true").unwrap_or_default()
{
let mut c = Command::new("xvfb-run");
c.arg("--auto-servernum").arg(
&test_cases(&root_dir, "0.1.0", target.clone())
.first()
.unwrap()
.1,
);
c.arg("--auto-servernum").arg(&app_path);
c
} else {
Command::new(
&test_cases(&root_dir, "0.1.0", target.clone())
.first()
.unwrap()
.1,
)
Command::new(&app_path)
};
binary_cmd.env("TARGET", bundle_target.name());
let status = binary_cmd.status().expect("failed to run app");
let status = binary_cmd
.status()
.unwrap_or_else(|e| panic!("failed to run {}: {e}", app_path.display()));
let code = status.code().unwrap_or(-1);
if code != expected_exit_code {
@@ -447,3 +517,222 @@ fn update_app() {
}
}
}
const SIGNED_VERSION_PORT: u16 = 3008;
/// Fragment of `Error::SignedVersionMismatch`. The app prints the updater error before exiting,
/// and a rejected update exits with the same code as a failed install, so the message is what
/// tells them apart.
const MISMATCH_ERROR: &str = "was signed for version";
struct ServedUpdate {
version: String,
}
fn app_binary(root_dir: &Path) -> PathBuf {
root_dir.join(if cfg!(windows) {
"target/release/app-updater.exe"
} else {
"target/release/app-updater"
})
}
/// Runs the app against the update server, restoring the binary from `pristine` first.
///
/// A case that clears the version check goes on to actually install, and on Linux installing means
/// writing the downloaded bytes over the running executable. Every case therefore has to start
/// from an untouched binary, or the first one that passes leaves the update behind as the app.
fn run_app(root_dir: &Path, pristine: &Path) -> String {
let binary = app_binary(root_dir);
std::fs::copy(pristine, &binary).expect("failed to restore the app binary");
let mut command = if cfg!(target_os = "linux")
&& std::env::var("CI").map(|v| v == "true").unwrap_or_default()
{
let mut c = Command::new("xvfb-run");
c.arg("--auto-servernum").arg(&binary);
c
} else {
Command::new(&binary)
};
let output = command.output().expect("failed to run app");
String::from_utf8_lossy(&output.stdout).into_owned()
}
/// The update endpoint response is not signed, so its `version` field alone does not prove which
/// release the `url` and `signature` point at. This bundles a genuine 1.0.0 release and then varies
/// only the version the endpoint announces for it, which is the shape of a forced downgrade: a
/// tampered response pairing a new version number with an older release's signature.
///
/// The check runs in `Update::download`, before anything is installed, so a rejected case never
/// reaches the installer while an accepted one does. Both exit non-zero, which is why these assert
/// on the error message rather than the exit code.
#[test]
fn update_validates_signed_version() {
let _lock = BUILD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let target =
tauri_plugin_updater::target().expect("running updater test in an unsupported platform");
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let root_dir = manifest_dir.join("../../../..");
// bundle the release the endpoint will point at; the CLI records 1.0.0 in its signature
let bundle_target = BundleTarget::default();
build_app(
&manifest_dir,
&Config {
version: "1.0.0",
bundle: BundleConfig {
create_updater_artifacts: Updater::Bool(true),
},
plugins: None,
},
Some(bundle_target),
);
let out_bundle_path = test_cases(&root_dir, "1.0.0", target.clone())
.first()
.unwrap()
.1
.clone();
let updater_extension = {
let bundle_ext = out_bundle_path
.extension()
.unwrap()
.to_str()
.unwrap()
.to_string();
if cfg!(target_os = "macos") {
format!("{bundle_ext}.tar.gz")
} else {
bundle_ext
}
};
let signature =
std::fs::read_to_string(out_bundle_path.with_extension(format!("{updater_extension}.sig")))
.expect("failed to read signature file");
// move it aside so building the running app cannot clobber it
let out_updater_path = out_bundle_path.with_extension(&updater_extension);
let updater_path = root_dir.join(format!(
"target/release/{}",
out_updater_path.file_name().unwrap().to_str().unwrap()
));
std::fs::rename(&out_updater_path, &updater_path).expect("failed to rename bundle");
let served = Arc::new(Mutex::new(ServedUpdate {
version: "1.0.0".into(),
}));
let server = Arc::new(
tiny_http::Server::http(format!("localhost:{SIGNED_VERSION_PORT}"))
.expect("failed to start updater server"),
);
let server_ = server.clone();
let served_ = served.clone();
let target_ = target.clone();
let updater_path_ = updater_path.clone();
std::thread::spawn(move || {
for request in server_.incoming_requests() {
match request.url() {
"/" => {
let mut platforms = HashMap::new();
platforms.insert(
target_.clone(),
PlatformUpdate {
// always the genuine 1.0.0 signature; only the version above it moves
signature: signature.clone(),
url: format!("http://localhost:{SIGNED_VERSION_PORT}/download"),
with_elevated_task: false,
},
);
let body = serde_json::to_vec(&Update {
version: served_.lock().unwrap().version.clone(),
date: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap(),
platforms,
})
.unwrap();
let len = body.len();
let _ = request.respond(tiny_http::Response::new(
tiny_http::StatusCode(200),
Vec::new(),
std::io::Cursor::new(body),
Some(len),
None,
));
}
"/download" => {
let _ = request.respond(tiny_http::Response::from_file(
File::open(&updater_path_).unwrap_or_else(|_| {
panic!("failed to open updater bundle {}", updater_path_.display())
}),
));
}
_ => (),
}
}
});
// `requireSignedVersion` is baked in by `generate_context!`, so each value needs its own build
let pristine = root_dir.join("target/release/app-updater.pristine");
let build = |require_signed_version: bool| {
build_app(
&manifest_dir,
&Config {
version: "0.1.0",
bundle: BundleConfig {
create_updater_artifacts: Updater::Bool(true),
},
plugins: Some(serde_json::json!({
"updater": {
"endpoints": [format!("http://localhost:{SIGNED_VERSION_PORT}")],
"requireSignedVersion": require_signed_version,
}
})),
},
None,
);
std::fs::copy(app_binary(&root_dir), &pristine)
.expect("failed to keep a pristine copy of the app binary");
};
let check = |announced: &str, expected: Option<&str>, unexpected: Option<&str>| {
served.lock().unwrap().version = announced.to_string();
let output = run_app(&root_dir, &pristine);
if let Some(expected) = expected {
assert!(
output.contains(expected),
"expected {expected:?} when announcing {announced} for a 1.0.0 signature, got: {output}"
);
}
if let Some(unexpected) = unexpected {
assert!(
!output.contains(unexpected),
"unexpected {unexpected:?} when announcing {announced} for a 1.0.0 signature, got: {output}"
);
}
};
build(true);
// the rollback this exists to stop: a 1.0.0 release dressed up as a newer one
check("1.5.0", Some(MISMATCH_ERROR), None);
// the announced version is what the artifact was signed for, so it must go through
check("1.0.0", None, Some(MISMATCH_ERROR));
build(false);
// a signature that names a version is held to it whether or not the option is on
check("1.5.0", Some(MISMATCH_ERROR), None);
server.unblock();
// leave a runnable binary behind rather than whichever update was installed last
let _ = std::fs::copy(&pristine, app_binary(&root_dir));
let _ = std::fs::remove_file(&pristine);
}