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 <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 e6b0315a9a
commit 471a176b15
3 changed files with 71 additions and 1 deletions

View File

@@ -149,6 +149,7 @@ impl<T: InvokeUiSession> Remote<T> {
}
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<T: InvokeUiSession> Remote<T> {
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();

View File

@@ -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<Data>) {
let session: Session<FlutterHandler> = 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);
}
}

View File

@@ -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<T: InvokeUiSession> {
pub server_clipboard_enabled: Arc<RwLock<bool>>,
pub last_change_display: Arc<Mutex<ChangeDisplayRecord>>,
pub connection_round_state: Arc<Mutex<ConnectionRoundState>>,
// 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<AtomicBool>,
pub printer_names: Arc<RwLock<HashMap<i32, String>>>,
// Indicate whether the session is reconnected.
// Used to auto start file transfer after reconnection.