server: hold an unauthenticated connection to a small message

Until a peer authorizes it sends only a public key, a login request, a test delay
and a close reason, none of them large. Nothing said so: a frame header could
declare up to whatever the transport allowed, 1 GiB on TCP and WebRTC, and a
connection holds its place for up to LOGIN_GRACE before it has to authorize. With
MAX_UNAUTHORIZED_CONNS places to fill, that is 64 GiB of header-declared payload
one peer could make us hold - or, on WebSocket, 1 GiB bought outright with a few
hundred bytes of frame headers, because tungstenite reserves a frame's declared
payload as soon as it passes max_frame_size.

The cap goes on in create_tcp_connection, before the identity handshake, so that
read is bounded too, and comes off once the login is settled. It comes off before
connect_port_forward_if_needed rather than beside the rest of authorization: a
multiplexed tunnel narrows the same knob again for its own framing and has to have
the last word.

128 KiB is several times the largest login request anyone sends - a long hostname,
an os_login, an avatar URL, a file-transfer path - and is also the read buffer
tungstenite allocates per WebSocket connection whatever we do, so on that transport
the bound costs nothing beyond a floor already paid. Together with
MAX_UNAUTHORIZED_CONNS it holds every unauthorized connection to 8 MiB. Redis
answered this same shape in CVE-2021-32675 with 16 KiB, tighter because a
per-message bound is the only one it has; here the connection count is the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZ49AbZJYfm8NTp5yDPMab
This commit is contained in:
rustdesk
2026-09-17 12:01:19 +08:00
parent fd0fe592eb
commit 12d652750b
2 changed files with 13 additions and 0 deletions

View File

@@ -221,6 +221,8 @@ pub async fn create_tcp_connection(
let Some(unauthorized) = admit_unauthorized(id, addr.ip()) else { let Some(unauthorized) = admit_unauthorized(id, addr.ip()) else {
bail!("too many unauthenticated connections from {}", addr.ip()); bail!("too many unauthenticated connections from {}", addr.ip());
}; };
// Before the handshake, so its read is bounded too; lifted again at authorization.
stream.set_max_packet_length(MAX_UNAUTHORIZED_MESSAGE);
tokio::select! { tokio::select! {
handshake = identity_handshake(&mut stream, secure) => handshake?, handshake = identity_handshake(&mut stream, secure) => handshake?,
_ = unauthorized.evicted() => { _ = unauthorized.evicted() => {

View File

@@ -93,6 +93,13 @@ const MAX_UNAUTHORIZED_CONNS: usize = 64;
/// of addresses passes it, and the bound above is what holds. Meaningful only while the /// of addresses passes it, and the bound above is what holds. Meaningful only while the
/// address is the controller's own, which punch and relay messages carry today. /// address is the controller's own, which punch and relay messages carry today.
const MAX_UNAUTHORIZED_CONNS_PER_ADDR: usize = 16; const MAX_UNAUTHORIZED_CONNS_PER_ADDR: usize = 16;
/// The largest message a connection may send before it authorizes. Until then a peer sends only
/// a public key, a login request, a test delay and a close reason, none of which carries an
/// unbounded field - the login request's avatar is a URL. Sized to the read buffer tungstenite
/// allocates per WebSocket connection regardless, so there the cap costs nothing beyond a floor
/// already paid; with MAX_UNAUTHORIZED_CONNS it holds them to 8 MiB in all, against the 1 GiB a
/// single one could make us hold before.
pub const MAX_UNAUTHORIZED_MESSAGE: usize = 128 * 1024;
/// A place among the unauthorized connections, taken before the identity handshake and given /// A place among the unauthorized connections, taken before the identity handshake and given
/// back on drop: at authorization, or when the connection ends first. The count of live /// back on drop: at authorization, or when the connection ends first. The count of live
@@ -1871,6 +1878,10 @@ impl Connection {
if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await { if let Some(keep_alive) = self.prepare_terminal_login_for_authorization().await {
return keep_alive; return keep_alive;
} }
// Lifted here rather than below with the rest of authorization: a multiplexed tunnel
// narrows it again for its own framing (`port_forward_mux::cap_packet_size`), so that
// call has to come after this one, not before.
self.stream.set_max_packet_length(usize::MAX);
if !self.connect_port_forward_if_needed().await { if !self.connect_port_forward_if_needed().await {
return false; return false;
} }