fix(rdpeudp): accept version 1/2 SYN offers on the server side too - #1965
Greg Lamberson (glamberson) wants to merge 3 commits into
Conversation
accept() rejected any client offering a protocol version below 3 outright, with a comment noting this crate only implemented the MS-RDPEUDP2 (version 3) data transfer. That implementation gap closed for the client-connects-out role (ConnectionConfig::offer_version, connect()'s SYN, handle_syn_ack()'s negotiation), but accept() itself, the server-accept role, was never updated to match: every real-world Windows client I have observed offers version 1 or 2 in its SYN, so a server built on this crate could never actually negotiate the sideband UDP transport with it, only ever falling back to the main transport. Mirrors handle_syn_ack()'s already-proven version selection exactly: settle on the client's offered version when it's 1 or 2, otherwise settle on our own highest (3), per MS-RDPEUDP 1.7's negotiate-down MUST clause; select WireFormat via UdpVersion::uses_v2_wire_format() the same way. enqueue_syn_ack() now echoes the negotiated version instead of unconditionally claiming 3. Also fixes an adjacent gap in the same code: 3.1.5.1.1 says an invalid cookieHash on a version 3 SYN MUST drop the connection to version 2 rather than refuse it, which the crate could not do until now either; it does that too. Rewrote the two existing tests that encoded the old refuse-on-low- version behavior to assert the new negotiate-down one instead, and added coverage for: settling on version 1, rejecting a genuinely unrecognized low version (there is nothing to negotiate down to below 1), and a full handshake plus bidirectional data exchange with the server on the accept side settling on version 2, exercising the already-existing version 1/2 data path (handle_v1_datagram and its neighbors) from the server-accept role for the first time.
There was a problem hiding this comment.
The PR rewrites accept() to negotiate versions per MS-RDPEUDP: offers at/above 0x0101 settle on 3, exact 1/2 settle there, other sub-3 values are refused; a v3 SYN with a bad cookieHash downgrades to v2 per 3.1.5.1.1, and enqueue_syn_ack echoes the negotiated version. It mirrors the proven handle_syn_ack() selection, reuses uses_v2_wire_format(), and adds solid tests including a bidirectional v2 handshake. Verified independently: v3 behavior is byte-identical, RTO/ACK-delay floors follow the negotiated version, retransmits resend the stored SYN+ACK. Publishing two candidates: the unchecked uUdpVer read when RDPUDP_VERSION_INFO_VALID is clear (protocol-1) and the duplicated wire-format mapping (dup-wire-format-selection). The test-literal dedup candidate is rejected as optional style churn against the file's literal-per-test convention.
- [protocol] accept trusts uUdpVer without RDPUDP_VERSION_INFO_VALID, bypassing the mandatory version-1 fallback — medium 🟠 — crates/ironrdp-rdpeudp/src/connection.rs
MS-RDPEUDP 2.2.2.9 defines RDPUDP_VERSION_INFO_VALID as the flag that makes uUdpVer indicate a supported version; 1.7 makes indicating a version optional and 3.1.5.1.3 fixes version 1 as the settlement when one is not indicated. The rewritten negotiation reads syn_data_ex.udp_ver unconditionally: a SYNEX with the flag clear and 0x0000 is refused where version 1 is mandated, and any other unvalidated value can settle the connection at a version the peer never advertised. The same unchecked read exists in handle_syn_ack, so a fix should cover both roles.
accept() and handle_syn_ack() each independently reimplemented the same negotiated-version-to-WireFormat mapping. Both call sites only ever see a version already restricted to V1/V2/V3, so a private WireFormat::for_version helper is total over every reachable value and behavior-preserving.
There was a problem hiding this comment.
PR #1965 makes the server-accept path negotiate protocol versions instead of refusing sub-3 offers: accept() settles on V3 for offers >= 0x0101, on exactly 1 or 2 otherwise, refuses unrecognized low offers, checks cookieHash only on V3 SYNs with the 3.1.5.1.1 downgrade-to-V2 on mismatch, and enqueue_syn_ack() now echoes the negotiated version. Independent inspection confirms the logic mirrors handle_syn_ack(), WireFormat::for_version() is equivalent at both call sites, the hash is correctly confined to client V3 SYNs per 2.2.2.9, and the new tests cover server-side v1/v2 handshake and data flow. No protocol conformance or correctness defect found. All four valid candidates are published unchanged: one low-severity leftover (accept() still unconditionally requires a configured cookie_hash even for v1/v2 offers that carry none) and three low-severity dedup/simplification nits in accept() and the new tests.
- [skeptical] accept() still requires a configured cookie_hash for version 1/2 offers that carry and check none — low 🟡 — crates/ironrdp-rdpeudp/src/connection.rs
The new negotiation lets accept() settle on version 1 or 2, for which 2.2.2.9 forbids a cookieHash and the added code deliberately skips the check, yet the unconditional config.cookie_hash extraction above the block still errors first with the misleading message 'needed to check the client's SYN'. connect() keys this requirement on offer_version == V3, so the asymmetry is an incomplete removal of the v3-only assumption, and the ConnectionConfig::cookie_hash doc claiming a connection without it cannot be built is stale. Impact is limited today because the only in-repo caller, accept_udp, always sets the hash, but a sans-I/O server serving only v1/v2 clients cannot build a config without a dummy secret. - [code-compressor] accept() re-tests for V3 and uses a mutable temp; fold the hash check into the >=V3 arm — low 🟡 — crates/ironrdp-rdpeudp/src/connection.rs
The negotiated_version == V3 guard is only true when the >= V3 arm took the branch, so the second decision re-derives information the first computed, and negotiated_version is mut only for the V2 downgrade. Folding the hash check into the first arm as an expression removes one branch, the mut binding, and the nested block with identical outcomes: missing hash on a v3 SYN still errors, mismatched hash still downgrades to V2, v1/v2 offers still skip the check, unrecognized offers still error before hash work. Tradeoff: the spec's two-step negotiate-then-downgrade narrative is less literally mirrored.
…iteral full_handshake_and_data_exchange_when_the_server_settles_on_version_2 re-enacted establish_pair()'s SYN -> accept -> SYN+ACK -> final-ACK exchange by hand, differing only in the client config. Extracted handshake(client_config, server_isn), which drives the exchange through the final ACK without draining either side's event queue (poll_event() is a separate queue from poll_transmit(), so callers are free to drain or assert on it however they need). establish_pair() is now a thin wrapper over handshake(default_config(100), 200) that also drains both sides' events, keeping its existing no-argument signature intact for its ~20 existing call sites. Seven tests hand-built the same ~20-line V1Datagram SYN literal, differing only in syn_data_ex's udp_ver and cookie_hash. Extracted syn_datagram(udp_ver, cookie_hash), folding test_syn_data_ex() into a call with UdpVersion::V3 and TEST_COOKIE_HASH. Per-site spec citation comments moved to the call site they annotate. No behavior change: same assertions, same wire bytes, same timing.
Summary
accept() rejected any client offering a protocol version below 3 outright, with a comment noting this crate only implemented the MS-RDPEUDP2 (version 3) data transfer. That gap closed for the client-connects-out role (ConnectionConfig::offer_version, connect(), handle_syn_ack()'s negotiation), but accept(), the server-accept role, was never updated to match. Every real-world Windows client I have observed offers version 1 or 2 in its SYN, so a server built on this crate could never actually negotiate the sideband UDP transport with such a client, only ever fall back to the main transport.
Changes
Test plan
cargo xtask check fmt/lints/tests/typos/locks all pass.