From d407db9faed8d7f45184fd8862b5dd728a2ae6fb Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:07:12 +0800 Subject: [PATCH] fix(client): allow switch-sides back-connection in incoming-only mode (#15780) * fix(client): allow switch-sides back-connection in incoming-only mode "Switch sides" makes the controlled client run `--connect --switch_uuid `, which Client::_start rejected outright in incoming-only custom clients, so the feature silently dropped the session and never switched. Exempt exactly that back-connection: a default-conn session carrying a switch uuid may proceed. The uuid is then verified against the local server process in handle_hash(); if it is missing there (forged or expired), an incoming-only client now aborts with an error instead of falling through to password login, so the outgoing-connection restriction cannot be bypassed with a crafted --switch_uuid. Fixes rustdesk/rustdesk#11200 (discussion) Co-Authored-By: Claude Fable 5 * fix(client): validate switch-back grants before connecting - check pending peer/UUID grants before bypassing incoming-only mode - close rejected switch-back connections and suppress retries - keep grant consumption in handle_hash and test non-consuming checks Signed-off-by: 21pages * fix(client): prevent switch-back UUID reuse - claim pending switch-back grants before connecting - retain claimed grants to reject duplicate requests - bind authorization to the peer ID and UUID - use a shared TTL for switch-back grants Signed-off-by: 21pages * fix(client): defer switch UUID consumption until authentication Signed-off-by: 21pages * fix(client): reject repeated hash login in incoming-only mode Signed-off-by: 21pages --------- Signed-off-by: 21pages Co-authored-by: Claude Fable 5 Co-authored-by: 21pages --- src/client.rs | 91 ++++++++++++++++++++++++++++++++++++++-- src/ipc.rs | 23 ++++++++-- src/server/connection.rs | 84 +++++++++++++++++++++++++++---------- 3 files changed, 169 insertions(+), 29 deletions(-) diff --git a/src/client.rs b/src/client.rs index 6f2347868..5f5f34cd0 100644 --- a/src/client.rs +++ b/src/client.rs @@ -252,7 +252,7 @@ impl Client { (i32, String), bool, )> { - if config::is_incoming_only() { + if config::is_incoming_only() && !is_switch_sides_back(conn_type, &interface).await { bail!("Incoming only mode"); } // to-do: remember the port for each peer, so that we can retry easier @@ -3455,9 +3455,55 @@ pub fn handle_login_error( } } +// "Switch sides" requires the incoming-only client to connect back to its +// controlling peer; verify the local pending uuid before opening the connection. #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] -async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool { +async fn is_switch_sides_back(conn_type: ConnType, interface: &impl Interface) -> bool { + if conn_type != ConnType::DEFAULT_CONN { + return false; + } + let (id, uuid) = { + let lch = interface.get_lch(); + let lc = lch.read().unwrap(); + let Some(uuid) = lc.switch_uuid.as_deref() else { + return false; + }; + let Ok(uuid) = Uuid::parse_str(uuid) else { + return false; + }; + (lc.id.clone(), uuid) + }; + if !request_local_switch_sides_uuid( + &id, + &uuid, + crate::ipc::SwitchSidesUuidAction::Check, + ) + .await + { + return false; + } + let lch = interface.get_lch(); + let lc = lch.read().unwrap(); + let current_uuid = lc + .switch_uuid + .as_deref() + .and_then(|value| Uuid::parse_str(value).ok()); + lc.id == id && current_uuid.as_ref() == Some(&uuid) +} + +#[cfg(not(all(feature = "flutter", not(any(target_os = "android", target_os = "ios")))))] +async fn is_switch_sides_back(_conn_type: ConnType, _interface: &impl Interface) -> bool { + false +} + +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +async fn request_local_switch_sides_uuid( + id: &str, + uuid: &Uuid, + action: crate::ipc::SwitchSidesUuidAction, +) -> bool { let Ok(mut conn) = crate::ipc::connect(1000, "").await else { return false; }; @@ -3466,6 +3512,7 @@ async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool { .send(&crate::ipc::Data::SwitchSidesUuid( uuid.clone(), id.to_owned(), + action, None, )) .await @@ -3477,9 +3524,10 @@ async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool { Ok(Some(crate::ipc::Data::SwitchSidesUuid( returned_uuid, returned_id, + returned_action, Some(true), ))) => { - returned_uuid == uuid && returned_id == id + returned_uuid == uuid && returned_id == id && returned_action == action } _ => false, } @@ -3512,7 +3560,13 @@ pub async fn handle_hash( if let Some(uuid) = uuid { if let Ok(uuid) = uuid::Uuid::from_str(&uuid) { let id = lc.read().unwrap().id.clone(); - if !consume_local_switch_sides_uuid(&id, &uuid).await { + if !request_local_switch_sides_uuid( + &id, + &uuid, + crate::ipc::SwitchSidesUuidAction::Consume, + ) + .await + { log::warn!("Ignored untrusted switch_uuid"); } else { lc.write().unwrap().allow_switch_back_once(); @@ -3522,6 +3576,19 @@ pub async fn handle_hash( } } } + // Incoming-only may connect out solely for a verified switch-back; + // never fall through to password login, including on repeated hashes. + if config::is_incoming_only() { + interface.msgbox("error", "Connection Error", "Incoming only mode", ""); + let mut misc = Misc::new(); + misc.set_close_reason( + "Connection not allowed in incoming-only mode".to_owned(), + ); + let mut msg = Message::new(); + msg.set_misc(misc); + allow_err!(peer.send(&msg).await); + return; + } } // last password let mut password = lc.read().unwrap().password.clone(); @@ -4031,9 +4098,25 @@ pub fn check_if_retry(msgtype: &str, title: &str, text: &str, retry_for_relay: b && !text.to_lowercase().contains("mismatch") && !text.to_lowercase().contains("manually") && !text.to_lowercase().contains("restricted") + && !text.to_lowercase().contains("incoming only") && !text.to_lowercase().contains("not allowed"))) } +#[cfg(test)] +mod retry_tests { + use super::check_if_retry; + + #[test] + fn incoming_only_error_is_not_retryable() { + assert!(!check_if_retry( + "error", + "Connection Error", + "Incoming only mode", + false, + )); + } +} + pub async fn hc_connection( feedback: i32, rendezvous_server: String, diff --git a/src/ipc.rs b/src/ipc.rs index b3abeeb55..52e79955d 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -312,6 +312,14 @@ pub enum DataPortableService { CmShowElevation(bool), } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +pub enum SwitchSidesUuidAction { + Check, + Consume, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "t", content = "c")] pub enum Data { @@ -387,7 +395,7 @@ pub enum Data { SwitchSidesRequest(String), #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] - SwitchSidesUuid(String, String, Option), + SwitchSidesUuid(String, String, SwitchSidesUuidAction, Option), #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] SwitchSidesBack, @@ -1050,14 +1058,21 @@ async fn handle(data: Data, stream: &mut Connection) { } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] - Data::SwitchSidesUuid(uuid, id, None) => { + Data::SwitchSidesUuid(uuid, id, action, None) => { let allowed = uuid .parse::() - .map(|uuid| crate::server::remove_pending_switch_sides_uuid(&id, &uuid)) + .map(|uuid| match action { + SwitchSidesUuidAction::Check => { + crate::server::has_pending_switch_sides_uuid(&id, &uuid) + } + SwitchSidesUuidAction::Consume => { + crate::server::claim_pending_switch_sides_uuid(&id, &uuid) + } + }) .unwrap_or(false); allow_err!( stream - .send(&Data::SwitchSidesUuid(uuid, id, Some(allowed))) + .send(&Data::SwitchSidesUuid(uuid, id, action, Some(allowed))) .await ); } diff --git a/src/server/connection.rs b/src/server/connection.rs index 90729fb8d..b461a3eff 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -91,11 +91,15 @@ lazy_static::lazy_static! { static ref WAKELOCK_KEEP_AWAKE_OPTION: Arc::>> = Default::default(); } +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +const SWITCH_SIDES_UUID_TTL: Duration = Duration::from_secs(10); + #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] lazy_static::lazy_static! { static ref SWITCH_SIDES_UUID: Arc::>> = Default::default(); - static ref PENDING_SWITCH_SIDES_UUID: Arc::>> = Default::default(); + static ref PENDING_SWITCH_SIDES_UUID: Arc::>> = Default::default(); } #[cfg(target_os = "windows")] @@ -3085,7 +3089,7 @@ impl Connection { SWITCH_SIDES_UUID .lock() .unwrap() - .retain(|_, v| v.0.elapsed() < Duration::from_secs(10)); + .retain(|_, v| v.0.elapsed() < SWITCH_SIDES_UUID_TTL); let uuid_old = SWITCH_SIDES_UUID.lock().unwrap().remove(&lr.my_id); if let Ok(uuid) = uuid::Uuid::from_slice(_s.uuid.to_vec().as_ref()) { if let Some((_instant, uuid_old)) = uuid_old { @@ -3825,17 +3829,18 @@ impl Connection { #[cfg(not(any(target_os = "android", target_os = "ios")))] Some(misc::Union::SwitchSidesRequest(s)) => { if let Ok(uuid) = uuid::Uuid::from_slice(&s.uuid.to_vec()[..]) { - crate::server::insert_pending_switch_sides_uuid( + if crate::server::insert_pending_switch_sides_uuid( self.lr.my_id.clone(), uuid.clone(), - ); - crate::run_me(vec![ - "--connect", - &self.lr.my_id, - "--switch_uuid", - uuid.to_string().as_ref(), - ]) - .ok(); + ) { + crate::run_me(vec![ + "--connect", + &self.lr.my_id, + "--switch_uuid", + uuid.to_string().as_ref(), + ]) + .ok(); + } self.on_close("switch sides", false).await; return false; } @@ -6139,23 +6144,40 @@ pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) { #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn insert_pending_switch_sides_uuid(id: String, uuid: uuid::Uuid) { +pub fn insert_pending_switch_sides_uuid(id: String, uuid: uuid::Uuid) -> bool { let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap(); - uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10)); - uuids.insert(id, (tokio::time::Instant::now(), uuid)); + uuids.retain(|_, (instant, _, _)| instant.elapsed() < SWITCH_SIDES_UUID_TTL); + if uuids.get(&id).map(|(_, stored_uuid, _)| stored_uuid) == Some(&uuid) { + return false; + } + uuids.insert(id, (tokio::time::Instant::now(), uuid, false)); + true } #[cfg(feature = "flutter")] #[cfg(not(any(target_os = "android", target_os = "ios")))] -pub fn remove_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { +pub fn has_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap(); - uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10)); - if uuids.get(id).map(|(_, stored_uuid)| stored_uuid == uuid) == Some(true) { - uuids.remove(id); - true - } else { - false + uuids.retain(|_, (instant, _, _)| instant.elapsed() < SWITCH_SIDES_UUID_TTL); + uuids + .get(id) + .map(|(_, stored_uuid, claimed)| stored_uuid == uuid && !*claimed) + == Some(true) +} + +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn claim_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool { + let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap(); + uuids.retain(|_, (instant, _, _)| instant.elapsed() < SWITCH_SIDES_UUID_TTL); + // Keep claimed entries until expiry so replaying a request cannot launch another connection. + if let Some((_, stored_uuid, claimed)) = uuids.get_mut(id) { + if stored_uuid == uuid && !*claimed { + *claimed = true; + return true; + } } + false } #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -7094,6 +7116,26 @@ mod test { #[allow(unused)] use super::*; + #[cfg(feature = "flutter")] + #[cfg(not(any(target_os = "android", target_os = "ios")))] + #[test] + fn test_pending_switch_sides_uuid_is_claimed_once() { + let id = uuid::Uuid::new_v4().to_string(); + let uuid = uuid::Uuid::new_v4(); + let other_uuid = uuid::Uuid::new_v4(); + assert!(insert_pending_switch_sides_uuid(id.clone(), uuid.clone())); + + assert!(!insert_pending_switch_sides_uuid(id.clone(), uuid.clone())); + assert!(has_pending_switch_sides_uuid(&id, &uuid)); + assert!(!has_pending_switch_sides_uuid(&id, &other_uuid)); + assert!(!claim_pending_switch_sides_uuid("other-peer", &uuid)); + assert!(!claim_pending_switch_sides_uuid(&id, &other_uuid)); + assert!(claim_pending_switch_sides_uuid(&id, &uuid)); + assert!(!has_pending_switch_sides_uuid(&id, &uuid)); + assert!(!claim_pending_switch_sides_uuid(&id, &uuid)); + assert!(!insert_pending_switch_sides_uuid(id, uuid)); + } + #[test] fn login_scope_latches_session_scope_across_login_retries() { let port_forward = |host: &str| {