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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
This commit is contained in:
rustdesk
2026-09-19 23:17:01 +08:00
parent c200cc81df
commit 5e08d03b8e

View File

@@ -641,6 +641,19 @@ impl<T: InvokeUiSession> Remote<T> {
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<FlutterHandler>, 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::<Data>();
let remote = Remote::new(Session::<FlutterHandler>::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"
);
}
}