From 5e08d03b8ed04df78b204b1bff1bca678556a3ed Mon Sep 17 00:00:00 2001 From: rustdesk Date: Sat, 19 Sep 2026 23:17:01 +0800 Subject: [PATCH] client: hold the clipboard until this round's login is accepted 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. The check goes on the round, not the session: a session-level one reads a state and later a sender that a reconnect can have swapped in between, so a broadcast that passed for the round before could still queue on the next. Remote is the round - its queue, and is_connected set once its own PeerInfo is in - so a Clipboard or MultiClipboards that reaches handle_msg_from_ui before then is dropped there, on the line before it would go out. What the login itself sends through the same queue, Auth2FA among it, goes as before. 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 drives a round's Remote over a loopback pair before any login: a clipboard does not reach the far end, a 2FA code does, and once the round is connected the clipboard does too. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab --- src/client/io_loop.rs | 82 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 880a0260f..b4d0a6c64 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -641,6 +641,19 @@ impl Remote { self.check_clipboard_file_context(); } Data::Message(msg) => { + // The Flutter clipboard broadcast is process-wide, so a clipboard can reach this + // round's queue before the round has logged in; it is dropped here, on the round + // itself. + #[cfg(feature = "flutter")] + if !self.is_connected + && matches!( + msg.union.as_ref(), + Some(message::Union::Clipboard(_)) + | Some(message::Union::MultiClipboards(_)) + ) + { + return true; + } match &msg.union { Some(message::Union::Misc(misc)) => match misc.union { Some(misc::Union::RefreshVideo(_)) => { @@ -2643,3 +2656,72 @@ impl Drop for VideoThread { *self.discard_queue.write().unwrap() = true; } } + +#[cfg(test)] +#[cfg(feature = "flutter")] +mod tests { + use super::*; + use crate::flutter::FlutterHandler; + + /// A round's `Remote` over a loopback pair, before any login: what it sends to `peer` + /// arrives at `far`. + async fn remote_and_peer() -> (Remote, Stream, Stream) { + let listener = hbb_common::tcp::new_listener("127.0.0.1:0", false) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + let (peer, accepted) = tokio::join!( + hbb_common::socket_client::connect_tcp(addr.to_string(), 3000), + listener.accept() + ); + let (accepted, far_addr) = accepted.unwrap(); + let far = Stream::Tcp(hbb_common::tcp::FramedStream::from(accepted, far_addr)); + let (sender, receiver) = mpsc::unbounded_channel::(); + let remote = Remote::new(Session::::default(), receiver, sender); + (remote, peer.unwrap(), far) + } + + async fn arrives(far: &mut Stream) -> bool { + matches!(hbb_common::timeout(300, far.next()).await, Ok(Some(Ok(_)))) + } + + fn clipboard() -> Data { + let mut msg = Message::new(); + msg.set_clipboard(Clipboard { + content: b"copied while this login was pending".to_vec().into(), + ..Default::default() + }); + Data::Message(msg) + } + + fn auth_2fa() -> Data { + let mut msg = Message::new(); + msg.set_auth_2fa(Auth2FA { + code: "123456".to_owned(), + ..Default::default() + }); + Data::Message(msg) + } + + // A clipboard queued before this round's login stays here; what the login itself sends + // through the same queue does not. + #[tokio::test] + async fn a_clipboard_queued_before_this_rounds_login_is_dropped() { + let (mut remote, mut peer, mut far) = remote_and_peer().await; + assert!(!remote.is_connected); + assert!(remote.handle_msg_from_ui(clipboard(), &mut peer).await); + assert!( + !arrives(&mut far).await, + "a clipboard went out before the login" + ); + assert!(remote.handle_msg_from_ui(auth_2fa(), &mut peer).await); + assert!(arrives(&mut far).await, "the 2FA code was held back"); + + remote.is_connected = true; + assert!(remote.handle_msg_from_ui(clipboard(), &mut peer).await); + assert!( + arrives(&mut far).await, + "a clipboard after the login was held back" + ); + } +}