mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-11-04 00:33:12 +00:00
feat(update): verify signed update metadata (#15914)
* feat(update): verify signed update metadata * fix(update): harden metadata publishing and app names - share strict app-name validation across Windows and macOS - preserve published sidecars until replacements are ready - select macOS test features by target architecture - update the signing dependency and URL validation coverage Signed-off-by: fufesou <linlong1266@gmail.com> * fix(update): prevent replacing published release assets - block reruns after signed update metadata is published - clarify the security rationale for app-name validation Signed-off-by: fufesou <linlong1266@gmail.com> * fix(update): cache TLS responses and avoid double translations Signed-off-by: fufesou <linlong1266@gmail.com> * refact: reduce the diffs Signed-off-by: fufesou <linlong1266@gmail.com> * refact(update): remove obsolete update paths - remove unused FFI update branches - delete legacy Windows and macOS update helpers - simplify macOS daemon updates and redundant gating Signed-off-by: fufesou <linlong1266@gmail.com> * feat(update): secure same-identity macOS offline updates - verify DMGs using signed metadata, size, and SHA-256 - enforce package and bundle identity plus macOS signature checks - support custom release policies and propagate manual update failures Signed-off-by: fufesou <linlong1266@gmail.com> * fix(update): verify Windows installers with Authenticode Reject unsigned or untrusted EXE/MSI updates before elevation, clean up failed verified copies, and fix the macOS manual updater logging import. Signed-off-by: fufesou <linlong1266@gmail.com> * fix(update): clear stale URL on response body errors Keep body-read failures in the common cleanup path and document the cache-only Authenticode verification policy. Signed-off-by: fufesou <linlong1266@gmail.com> * refactor(update): remove OS signature sanity checks Remove Windows Authenticode and macOS codesign/Gatekeeper verification. Keep metadata, artifact integrity, bundle identity, and transaction checks. Signed-off-by: fufesou <linlong1266@gmail.com> * refact(update): move update scripts into res/update Signed-off-by: fufesou <linlong1266@gmail.com> * fix(update): secure Windows MSI handoff against junction races Signed-off-by: fufesou <linlong1266@gmail.com> * fix(update): secure Windows EXE handoff against junction races Signed-off-by: fufesou <linlong1266@gmail.com> * fix(update): preserve legacy custom client app names - allow names starting with digits or underscores - preserve leading hyphens on Windows for compatibility - reject leading hyphens on macOS to avoid pgrep option parsing Signed-off-by: fufesou <linlong1266@gmail.com> * refact(update): remove some simple wrappers Signed-off-by: fufesou <linlong1266@gmail.com> * refact(update): simple refactor * refact(update): comments on update.scpt Signed-off-by: fufesou <linlong1266@gmail.com> * refact(update): comments Signed-off-by: fufesou <linlong1266@gmail.com> * fix(update): create download destinations exclusively Signed-off-by: fufesou <linlong1266@gmail.com> * fix(macos): roll back updates when final readiness checks fail Signed-off-by: fufesou <linlong1266@gmail.com> * fix(msi): propagate MSI installation failures Signed-off-by: fufesou <linlong1266@gmail.com> * chore Signed-off-by: fufesou <linlong1266@gmail.com> * fix(macos): reject stale IPC sockets during updates Require open IPC sockets in addition to launchd running state before committing the privileged update transaction. Inspect sockets with lsof to avoid triggering IPC authentication. Add native socket regression coverage to macOS CI. Remove the unused Windows helper, combine offline metadata exports, and restore unrelated formatting changed by the PR. Signed-off-by: fufesou <linlong1266@gmail.com> * fix(macos): allow offline updates without an installed bundle Validate app-only updates against the running client's bundle identifier and check the destination identity when it exists. Replace source-text assertions with behavioral identity tests and run the macOS update tests in CI. Signed-off-by: fufesou <linlong1266@gmail.com> * ci, gate test with PUBLISH_UPDATE_METADATA == 'true' Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
1 parent
c8532719c0
commit
0921a7432f
40 files changed
+5416
-916
No files matched your search
+172
-32
@@ -37,7 +37,7 @@ use hbb_common::{
|
||||
|
||||
use crate::{
|
||||
hbbs_http::{create_http_client_async, get_url_for_tls},
|
||||
ui_interface::{get_api_server as ui_get_api_server, get_option, is_installed, set_option},
|
||||
ui_interface::{get_api_server as ui_get_api_server, get_option, set_option},
|
||||
};
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
@@ -1020,39 +1020,94 @@ pub fn check_software_update() {
|
||||
}
|
||||
}
|
||||
|
||||
// No need to check `danger_accept_invalid_cert` for now.
|
||||
// Because the url is always `https://api.rustdesk.com/version/latest`.
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
|
||||
let (request, url) =
|
||||
hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string());
|
||||
let proxy_conf = Config::get_socks();
|
||||
let tls_url = get_url_for_tls(&url, &proxy_conf);
|
||||
let tls_type = get_cached_tls_type(tls_url);
|
||||
let is_tls_not_cached = tls_type.is_none();
|
||||
let tls_type = tls_type.unwrap_or(TlsType::Rustls);
|
||||
let client = create_http_client_async(tls_type, false);
|
||||
let latest_release_response = match client.post(&url).json(&request).send().await {
|
||||
Ok(resp) => {
|
||||
upsert_tls_cache(tls_url, tls_type, false);
|
||||
resp
|
||||
}
|
||||
Err(err) => {
|
||||
if is_tls_not_cached && err.is_request() {
|
||||
let tls_type = TlsType::NativeTls;
|
||||
let client = create_http_client_async(tls_type, false);
|
||||
let resp = client.post(&url).json(&request).send().await?;
|
||||
upsert_tls_cache(tls_url, tls_type, false);
|
||||
resp
|
||||
} else {
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
pub(crate) fn release_id_from_update_url(update_url: &str) -> ResultType<String> {
|
||||
let url = url::Url::parse(update_url)?;
|
||||
if url.scheme() != "https" || url.host_str() != Some("github.com") {
|
||||
bail!(
|
||||
"Update URL is not a GitHub HTTPS release URL: {}",
|
||||
update_url
|
||||
);
|
||||
}
|
||||
if url_has_explicit_port(update_url)
|
||||
|| url.port().is_some()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
{
|
||||
bail!(
|
||||
"Update URL must not contain credentials or an explicit port: {}",
|
||||
update_url
|
||||
);
|
||||
}
|
||||
if url.query().is_some() || url.fragment().is_some() {
|
||||
bail!(
|
||||
"Update URL must not contain query or fragment: {}",
|
||||
update_url
|
||||
);
|
||||
}
|
||||
|
||||
let Some(segments) = url.path_segments() else {
|
||||
bail!("Update URL has no path: {}", update_url);
|
||||
};
|
||||
let bytes = latest_release_response.bytes().await?;
|
||||
let segments = segments.collect::<Vec<_>>();
|
||||
let release_id = match segments.as_slice() {
|
||||
["rustdesk", "rustdesk", "releases", "tag", release_id] => *release_id,
|
||||
["rustdesk", "rustdesk", "releases", "tag", release_id, ""] => *release_id,
|
||||
_ => bail!(
|
||||
"Update URL is not a RustDesk release tag URL: {}",
|
||||
update_url
|
||||
),
|
||||
};
|
||||
if release_id.is_empty()
|
||||
|| release_id == "."
|
||||
|| release_id == ".."
|
||||
|| release_id.contains([' ', '/', '\\', '?', '#'])
|
||||
|| release_id.contains('%')
|
||||
{
|
||||
bail!("Update URL has no release id: {}", update_url);
|
||||
}
|
||||
Ok(release_id.to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn url_has_explicit_port(raw_url: &str) -> bool {
|
||||
let Some((_, rest)) = raw_url.split_once("://") else {
|
||||
return false;
|
||||
};
|
||||
let authority = rest
|
||||
.split(|c| matches!(c, '/' | '?' | '#'))
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
let host_port = authority.rsplit('@').next().unwrap_or(authority);
|
||||
host_port.rsplit_once(':').is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn display_version_from_release_id(release_id: &str) -> ResultType<String> {
|
||||
let display_version = release_id.strip_prefix('v').unwrap_or(release_id);
|
||||
let segments = display_version.split('.').collect::<Vec<_>>();
|
||||
if segments.len() != 3
|
||||
|| segments.iter().any(|segment| {
|
||||
segment.is_empty()
|
||||
|| !segment.chars().all(|c| c.is_ascii_digit())
|
||||
|| segment.parse::<u16>().is_err()
|
||||
})
|
||||
{
|
||||
bail!("Release id is not a stable display version: {}", release_id);
|
||||
}
|
||||
if get_version_number(display_version) <= 0 {
|
||||
bail!("Release id has invalid display version: {}", release_id);
|
||||
}
|
||||
Ok(display_version.to_owned())
|
||||
}
|
||||
|
||||
fn clear_software_update_url() {
|
||||
let mut update_url = SOFTWARE_UPDATE_URL.lock().unwrap();
|
||||
*update_url = String::new();
|
||||
}
|
||||
|
||||
fn process_software_update_check_response(bytes: Bytes) -> ResultType<()> {
|
||||
let resp: hbb_common::VersionCheckResponse = serde_json::from_slice(&bytes)?;
|
||||
let response_url = resp.url;
|
||||
let latest_release_version = response_url.rsplit('/').next().unwrap_or_default();
|
||||
let release_id = release_id_from_update_url(&response_url)?;
|
||||
let latest_release_version = display_version_from_release_id(&release_id)?;
|
||||
|
||||
if get_version_number(&latest_release_version) > get_version_number(crate::VERSION) {
|
||||
#[cfg(feature = "flutter")]
|
||||
@@ -1066,11 +1121,60 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
|
||||
}
|
||||
*SOFTWARE_UPDATE_URL.lock().unwrap() = response_url;
|
||||
} else {
|
||||
*SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string();
|
||||
clear_software_update_url();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// No need to check `danger_accept_invalid_cert` for now.
|
||||
// Because the url is always `https://api.rustdesk.com/version/latest`.
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
|
||||
let (request, url) =
|
||||
hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string());
|
||||
let proxy_conf = Config::get_socks();
|
||||
let tls_url = get_url_for_tls(&url, &proxy_conf);
|
||||
let tls_type = get_cached_tls_type(tls_url);
|
||||
let is_tls_not_cached = tls_type.is_none();
|
||||
let tls_type = tls_type.unwrap_or(TlsType::Rustls);
|
||||
let client = create_http_client_async(tls_type, false);
|
||||
let latest_release_response: ResultType<_> = match client.post(&url).json(&request).send().await
|
||||
{
|
||||
Ok(resp) => Ok((tls_type, resp)),
|
||||
Err(err) => {
|
||||
if is_tls_not_cached && err.is_request() {
|
||||
let tls_type = TlsType::NativeTls;
|
||||
let client = create_http_client_async(tls_type, false);
|
||||
match client.post(&url).json(&request).send().await {
|
||||
Ok(resp) => Ok((tls_type, resp)),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
} else {
|
||||
Err(err.into())
|
||||
}
|
||||
}
|
||||
};
|
||||
let response_bytes = match latest_release_response {
|
||||
Ok((used_tls_type, response)) => {
|
||||
upsert_tls_cache(tls_url, used_tls_type, false);
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
Err(anyhow!(
|
||||
"Software update check failed with HTTP status: {status}"
|
||||
))
|
||||
} else {
|
||||
response.bytes().await.map_err(|err| err.into())
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
let result = response_bytes.and_then(process_software_update_check_response);
|
||||
if result.is_err() {
|
||||
clear_software_update_url();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_app_name() -> String {
|
||||
hbb_common::config::APP_NAME.read().unwrap().clone()
|
||||
@@ -2947,6 +3051,42 @@ mod tests {
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn parses_stable_rustdesk_release_url() {
|
||||
let update_url = "https://github.com/rustdesk/rustdesk/releases/tag/v1.4.6";
|
||||
|
||||
assert_eq!(release_id_from_update_url(update_url).unwrap(), "v1.4.6");
|
||||
assert_eq!(display_version_from_release_id("v1.4.6").unwrap(), "1.4.6");
|
||||
assert_eq!(
|
||||
release_id_from_update_url(&format!("{update_url}/")).unwrap(),
|
||||
"v1.4.6"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_untrusted_release_urls_and_unstable_versions() {
|
||||
for update_url in [
|
||||
"http://github.com/rustdesk/rustdesk/releases/tag/v1.4.6",
|
||||
"https://github.com:8443/rustdesk/rustdesk/releases/tag/v1.4.6",
|
||||
"https://github.com:443/rustdesk/rustdesk/releases/tag/v1.4.6",
|
||||
"https://user@github.com/rustdesk/rustdesk/releases/tag/v1.4.6",
|
||||
"https://example.com/rustdesk/rustdesk/releases/tag/v1.4.6",
|
||||
"https://github.com/other/rustdesk/releases/tag/v1.4.6",
|
||||
"https://github.com/rustdesk/other/releases/tag/v1.4.6",
|
||||
"https://github.com/rustdesk/rustdesk/releases/download/v1.4.6/rustdesk.exe",
|
||||
"https://github.com/rustdesk/rustdesk/releases/tag/v1.4.6?x=1",
|
||||
"https://github.com/rustdesk/rustdesk/releases/tag/v1.4.6#asset",
|
||||
"https://github.com/rustdesk/rustdesk/releases/tag/v1.4.6/extra",
|
||||
"https://github.com/rustdesk/rustdesk/releases/tag/v1%2F4%2F6",
|
||||
"https://github.com/rustdesk/rustdesk/releases/tag/%2e%2e",
|
||||
] {
|
||||
assert!(release_id_from_update_url(update_url).is_err());
|
||||
}
|
||||
for release_id in ["v1.4.6-rc1", "nightly", "v9223372036854775807.1.1"] {
|
||||
assert!(display_version_from_release_id(release_id).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cursor_content_id_fits_a_web_client_number() {
|
||||
const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1;
|
||||
|
||||
Reference in new issue
Block a user