Repository navigation
feat(server): default ConnectionPolicy to Preempt under Hybrid - #1934
Anton Mostovoy (antonmos) wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
The unconditional default permits unauthenticated session eviction under Tls and None.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Changes the server’s session-preemption default from opt-in to enabled.
Changes:
- Enables preemption in both builder paths.
- Updates API documentation for the new default.
- Documents how to restore queue-behind behavior.
File summaries
| File | Description |
|---|---|
crates/ironrdp-server/src/server.rs |
Documents the new default and opt-out behavior. |
crates/ironrdp-server/src/builder.rs |
Enables preemption by default in both builder paths. |
Review details
Suppressed comments (1)
crates/ironrdp-server/src/builder.rs:204
- This second initializer also enables unauthenticated session eviction by default for
TlsandNone. It needs the same safe default policy as the display-handler path; otherwise choosingwith_no_display()silently opts the server into the documented takeover DoS exposure.
preempt_existing_session: true,
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
PR flips preempt_existing_session's default to true in both RdpServerBuilder constructors and rewrites the paired rustdoc; no runtime logic changes. Verified: docs match the new default and no stale "off by default" text remains. Substantive survivors: (1) the now-default takeover path sends the MS-RDPBCGR Set Error Info PDU without checking the client's SUPPORT_ERRINFO_PDU opt-in, and the in-code "no acceptor field" excuse is false — AcceptorResult.client_early_capability_flags exists and is already used in this file for heartbeat gating, so the check needs no API change; (2) the new default makes unauthenticated eviction of a live session the out-of-the-box posture under Tls/None, though a mode-aware default was feasible. Minor: no test pins the new default, and the opt-out instruction is duplicated within one doc comment. All four specialist candidates verified and accepted.
- [skeptical] No test pins the new preempt_existing_session default — low 🟡 — crates/ironrdp-server/src/builder.rs
The entire functional change is the literal true in both BuilderDone constructors (builder.rs:171 with_display_handler, :204 with_no_display). Every existing test hardcodes the value instead of exercising the default — with_preempt_existing_session(true) at server.rs:4375 and :4472, true in a test options struct at server.rs:4166 — so a regression back to false in either constructor passes the suite silently. Tests already access private internals via use super::*, so asserting opts.preempt_existing_session is true on both builder paths is trivial and proportionate for the one line this PR exists to change.
|
Thanks for following through on this, Anton. It's a good change, and it's the right call to ask about the security tradeoff rather than leave it implicit. One heads-up on the shape: #1913 landed on 2026-09-11 and replaced the On the default itself, the part I'd want settled first is the one you flagged. Under This comes from a production angle: Our lamco-rdp-server runs its own accept loop over One small related thing I noticed while reading the eviction arm in |
|
Thanks — both points taken, and the branch is now rebuilt on #1913 (merged Mode-aware default: yes, adopted. The KNOWN-GAP comment: fixed, and you're right it was wrong. Tests went into Re lamco-rdp-server driving its own accept loop over 🤖 Addressed by Claude Code |
|
Thanks for the quick turnaround, Anton. Rebuilding on #1913 and making the default mode-aware is a clean result. Two small things, neither of them blocking from my side:
Good change overall. It makes the safe case the easy one and the unauthenticated case an explicit choice. |
|
Thanks again, Anton, and for the note about lamco-rdp-server. I looked at moving it onto What would make it usable there is the same race driven by connections the embedder accepts: I'd do this as a separate PR after this one lands rather than widen this change. Does that direction match how you see |
|
Benoît Cortier (@CBenoit) the above makes sense to me. Could you confirm if you agree with this direction ? |
There was a problem hiding this comment.
The PR delivers what it claims: a mode-aware ConnectionPolicy::default_for (Preempt under Hybrid, Queue under Tls/None) reused from the pre-existing authenticates_before_eviction predicate, applied in both builder initializers with the mode-independent derived Default removed, plus a correct MS-RDPBCGR 3.3.5.7.1 gate threading client_early_capability_flags through client_loop into dispatch_server_events for the eviction and Disconnect arms, with tests pinned in the CI-visible testsuite. However, the 3.3.5.7.1 fix is incomplete: send_access_denied still sends the Set Error Info PDU unconditionally on the auto-reconnect-cookie and credential-validation rejection paths in the same file, where the flag is already in scope, and the PR deleted the KNOWN-GAP comment that documented exactly this residual violation. Secondary: the default is computed as a copy in both initializers with only one pinned end to end, and the per-mode default mapping is restated in several doc sites despite default…
- [protocol + skeptical] Set Error Info PDU gate is incomplete: send_access_denied still sends it unconditionally, and the comment recording the gap was deleted — high 🔴 — crates/ironrdp-server/src/server.rs
MS-RDPBCGR 3.3.5.7.1 forbids sending the Server Set Error Info PDU to a client that did not set RNS_UD_CS_SUPPORT_ERRINFO_PDU (0x0001) in its Client Core Data earlyCapabilityFlags. The PR correctly gates the EvictedByOtherConnection and Disconnect dispatch arms on the flag threaded from AcceptorResult::client_early_capability_flags, but send_access_denied (server.rs:4154) still encodes and writes ServerSetErrorInfoPdu(ServerDeniedConnection) with no check, and it is called on three reachable paths in client_accepted: auto-reconnect cookie rejection (3496) and credential-validation rejection or backend error (3518/3523). A client that did not opt in and fails validation still receives a forbidden PDU, so the conformance defect persists; result.client_early_capability_flags is already in scope there, so the fix needs no new plumbing. Compounding it, the PR deleted the KNOWN-GAP comment that explicitly documented this exact residual gap, removing the in-code record that would otherwise surface it in review. Line fields are null because the defect lives in lines this PR did not add.
|
Pushed 9eec38b, addressing the latest round:
🤖 Addressed by Claude Code |
|
Update: no rebase needed anymore. picky-krb 0.12.5 was yanked from crates.io (re-released as 0.13.0), so the API check builds again without changes to this branch. PR automation has been re-run here and passes. |
Re-expressed on top of Devolutions#1913, which replaced the bool this PR originally flipped with `ConnectionPolicy { Queue, Reject, Preempt }`. The default is now mode-aware rather than a blanket `Preempt`, as every reviewer asked: `ConnectionPolicy::default_for(&security)` returns `Preempt` under `Hybrid` — the one mode that authenticates the client before a candidate could evict anything — and `Queue` under `Tls` and `None`, where the per-mode table shows any peer able to complete the handshake clears the bar and the anti-storm cooldown bars the victim, not the attacker. Takeover stays the least-surprising default where it is safe, and an unauthenticated takeover is an explicit opt-in. The derived `Default` (a mode-independent `Queue`) is removed so there is a single source of truth; both builder initializers call `default_for` with the security already chosen on the builder. Also closes the gap glamberson and the reviewer flagged in the eviction arm: the Set Error Info PDU was sent unconditionally behind a KNOWN-GAP comment claiming `AcceptorResult` exposes no early-capability field. It does (`client_early_capability_flags`, already used in this file to gate heartbeats), so MS-RDPBCGR 3.3.5.7.1 is now honoured with no API change — `SUPPORT_ERR_INFO_PDU` is threaded through `client_loop` into `dispatch_server_events` like the heartbeat flag, and both arms that carry a disconnect reason (`EvictedByOtherConnection` and the public `ErrorInfoDisconnectHandle` path) drop the PDU and just disconnect for a client that did not opt in. Tests, in ironrdp-testsuite-core so they actually run in CI (`ironrdp-server` has `[lib] test = false`): `default_for` pinned for all three modes, and the `with_no_security` default driven end to end through `run` (a second connection is left waiting, not served). The `Tls`/`Hybrid` values are built from a certificate-less `TlsAcceptor` (stub `ResolvesServerCert`), hence the `tokio-rustls` dev-dependency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…O_PDU; pin Hybrid default - send_access_denied (auto-reconnect cookie and credential-validation rejections) now skips the Set Error Info PDU for a client that did not set SUPPORT_ERR_INFO_PDU, per MS-RDPBCGR 3.3.5.7.1, completing the gate already applied to the eviction and ErrorInfoDisconnectHandle arms. - Pin the default policy on the with_display_handler initializer too, so the two builder copies cannot drift apart. - Add an end-to-end Hybrid test: with no with_connection_policy call, an authenticated newcomer takes the session over and the incumbent is closed (fails under Queue). - Field and builder docs point at ConnectionPolicy::default_for instead of restating the per-mode mapping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ad642de to
1050ffd
Compare
|
Benoît Cortier (@CBenoit) rebased onto master (now includes #2087/#2088) — two commits, no merge commits; the Hybrid takeover test from your earlier request is in 🤖 Addressed by Claude Code |
Resolves conflicts with Devolutions#2059 (per-connection state split): the client's SUPPORT_ERR_INFO_PDU opt-in now lives on `ConnectionState` next to the heartbeat flag and is set where that one is, instead of being threaded through `client_loop` and `dispatch_server_events` as a loose parameter. `dispatch_server_events` is back to master's parameter list. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Follow-up to the review discussion on #1476 (discussion_r3962619360), where we agreed takeover should ultimately be the default because it is the least surprising for the single-session servers
ironrdp-servertypically backs.Rebuilt on top of #1913 (which replaced the
preempt_existing_sessionbool withConnectionPolicy { Queue, Reject, Preempt }), and narrowed per the review: the default is mode-aware, not a blanketPreempt.What changes
ConnectionPolicy::default_for(&RdpServerSecurity)—PreemptunderHybrid,QueueunderTlsandNone. BothRdpServerBuilderinitializers call it with the security already chosen on the builder. The derivedDefault(a mode-independentQueue) is removed so there is one source of truth; docs on the enum, the field andwith_connection_policypoint atdefault_for, which carries the per-mode table and the rationale.SUPPORT_ERR_INFO_PDUopt-in (MS-RDPBCGR 3.3.5.7.1). The eviction arm's KNOWN-GAP comment claimedAcceptorResultexposes no early-capability field; it does (client_early_capability_flags, already used in the same file to gate heartbeats), so the check needs no API change. The flag is threaded throughclient_loopintodispatch_server_eventsexactly like the heartbeat flag, and both arms that carry a disconnect reason —EvictedByOtherConnectionand the publicErrorInfoDisconnectHandlepath — drop the PDU and just disconnect for a client that did not opt in. Docs on both note the exception.ironrdp-testsuite-core/tests/server/connection_policy.rs, where they actually run in CI (ironrdp-serverhas[lib] test = false):default_forpinned for all three modes, and thewith_no_securitydefault driven end to end throughrun(a second connection is left waiting, not served).Tls/Hybridvalues come from a certificate-lessTlsAcceptor(stubResolvesServerCert), hence thetokio-rustlsdev-dependency.Why mode-aware
Under
Tls/Nonethe per-mode table says any peer that can complete the handshake clears the bar, and the anti-storm cooldown bars the victim rather than the attacker — so aPreemptdefault there would make an authenticated session remotely evictable out of the box. OnlyHybridgates a takeover on client authentication. KeepingQueuethere preserves the pre-existing behaviour and makes an unauthenticated takeover an explicit choice (the startup warning for that choice is unchanged).Not a released-behaviour break
ConnectionPolicy(#1913) and the option it replaced (#1476) both landed after the lastironrdp-serverrelease (0.13.0, 2026-07-10), so no released consumer depends on either theQueuedefault or the derivedDefaultimpl.🤖 Generated with Claude Code