From 471a176b1569ba72254261bfb049b57bffff28fc Mon Sep 17 00:00:00 2001 From: rustdesk Date: Sat, 19 Sep 2026 23:17:01 +0800 Subject: [PATCH] client: send the clipboard only to a peer that has accepted the login The clipboard listener is one per process, started by the first session to log in, and its broadcast went to every session - one whose login was still waiting on a password, 2FA or the peer's consent included. What the user copied meanwhile went to a machine that had not admitted them; the peer drops it unread before authorization, but it is in that peer's hands. A session now records the PeerInfo that answers its login, the moment it is in, and is passed over until then - and again from the first act of every connection round, so a reconnect waiting on its password is passed over too. connection_round_state cannot stand in for that: it says Connected as soon as the transport is up. The unauthenticated cap on the other side is how this came up: a clipboard over 128 KiB ended such a login with "Reset by the peer". The small case had always gone through quietly. A test puts two sessions in the table, one with its PeerInfo in and one without, and broadcasts: the first receives, the second not until its PeerInfo is in too. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab --- src/client/io_loop.rs | 2 ++ src/flutter.rs | 65 +++++++++++++++++++++++++++++++++++++ src/ui_session_interface.rs | 5 ++- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 880a0260f..04fc7a2fa 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -149,6 +149,7 @@ impl Remote { } pub async fn io_loop(&mut self, key: &str, token: &str, round: u32) { + self.handler.logged_in.store(false, Ordering::SeqCst); #[cfg(target_os = "windows")] let _file_clip_context_holder = { // `is_port_forward()` will not reach here, but we still check it for clarity. @@ -1450,6 +1451,7 @@ impl Remote { return false; } } + self.handler.logged_in.store(true, Ordering::SeqCst); self.handler.handle_peer_info(pi); #[cfg(all(target_os = "windows", not(feature = "flutter")))] self.check_clipboard_file_context(); diff --git a/src/flutter.rs b/src/flutter.rs index 5c453d468..a18e7bdfe 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -1438,6 +1438,10 @@ fn send_clipboard_msg_impl(msg: Message, _is_file: bool, except_session_id: Opti if !s.is_default() { continue; } + // Nothing goes to a peer that has not accepted the login yet. + if !s.logged_in.load(Ordering::SeqCst) { + continue; + } if let Some(except_session_id) = except_session_id { if s.lc.read().unwrap().session_id == except_session_id { continue; @@ -2381,3 +2385,64 @@ pub(super) mod async_tasks { ); } } + +#[cfg(test)] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +mod tests { + use super::*; + use hbb_common::tokio::sync::mpsc; + + /// A default-connection session as `session_add` builds it, with the io loop's channel in + /// place so that what the session sends can be read back. + fn session(id: &str) -> (SessionID, FlutterSession, mpsc::UnboundedReceiver) { + let session: Session = Session { + server_keyboard_enabled: Arc::new(RwLock::new(true)), + server_clipboard_enabled: Arc::new(RwLock::new(true)), + ..Default::default() + }; + session.lc.write().unwrap().initialize( + id.to_owned(), + ConnType::DEFAULT_CONN, + None, + false, + get_adapter_luid(), + None, + None, + ); + let (tx, rx) = mpsc::unbounded_channel(); + *session.sender.write().unwrap() = Some(tx); + let session = Arc::new(session); + let session_id = SessionID::new_v4(); + sessions::insert_session(session_id, ConnType::DEFAULT_CONN, session.clone()); + (session_id, session, rx) + } + + // The clipboard listener is shared by every session and starts with the first login, so a + // change reaches sessions still waiting on a password or the peer's consent: those get + // nothing until their PeerInfo is in. + #[test] + fn clipboard_goes_only_to_a_peer_that_accepted_the_login() { + let (accepted_id, accepted, mut accepted_rx) = session("clipboard-gate-accepted"); + let (pending_id, pending, mut pending_rx) = session("clipboard-gate-pending"); + accepted.logged_in.store(true, Ordering::SeqCst); + + let mut msg = Message::new(); + msg.set_clipboard(Clipboard { + content: b"copied while one login is still pending".to_vec().into(), + ..Default::default() + }); + send_clipboard_msg_impl(msg.clone(), false, None); + assert!(matches!(accepted_rx.try_recv(), Ok(Data::Message(_)))); + assert!( + pending_rx.try_recv().is_err(), + "a login the peer has not accepted was sent the clipboard" + ); + + pending.logged_in.store(true, Ordering::SeqCst); + send_clipboard_msg_impl(msg, false, None); + assert!(matches!(pending_rx.try_recv(), Ok(Data::Message(_)))); + + sessions::remove_session_by_session_id(&accepted_id); + sessions::remove_session_by_session_id(&pending_id); + } +} diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 37aa7a67e..f29cad28b 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -33,7 +33,7 @@ use std::{ ops::{Deref, DerefMut}, str::FromStr, sync::{ - atomic::{AtomicUsize, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, Arc, Mutex, RwLock, }, time::SystemTime, @@ -66,6 +66,9 @@ pub struct Session { pub server_clipboard_enabled: Arc>, pub last_change_display: Arc>, pub connection_round_state: Arc>, + // The peer's PeerInfo is in. `connection_round_state` is Connected once the transport is, + // while the login it carries may still be waiting on a password or the peer's consent. + pub logged_in: Arc, pub printer_names: Arc>>, // Indicate whether the session is reconnected. // Used to auto start file transfer after reconnection.