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 <peer>
--switch_uuid <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 <noreply@anthropic.com>

* 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 <sunboeasy@gmail.com>

* 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 <sunboeasy@gmail.com>

* fix(client): defer switch UUID consumption until authentication

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(client): reject repeated hash login in incoming-only mode

Signed-off-by: 21pages <sunboeasy@gmail.com>

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
This commit is contained in:
RustDesk
2026-08-10 16:07:12 +08:00
committed by GitHub
parent 594e63805c
commit d407db9fae
3 changed files with 169 additions and 29 deletions

View File

@@ -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,