diff --git a/Cargo.lock b/Cargo.lock index 9dfa30a6a2..bcd384dddc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2602,6 +2602,7 @@ dependencies = [ name = "ironrdp-acceptor" version = "0.10.0" dependencies = [ + "async-trait", "ironrdp-async", "ironrdp-connector", "ironrdp-core 0.2.1", diff --git a/crates/ironrdp-acceptor/Cargo.toml b/crates/ironrdp-acceptor/Cargo.toml index 6f08e9661e..3dafd1c8f4 100644 --- a/crates/ironrdp-acceptor/Cargo.toml +++ b/crates/ironrdp-acceptor/Cargo.toml @@ -23,6 +23,7 @@ ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public ironrdp-connector = { path = "../ironrdp-connector", version = "0.10" } # public ironrdp-async = { path = "../ironrdp-async", version = "0.10" } # public rand = "0.9" +async-trait = "0.1" tracing = { version = "0.1", features = ["log"] } [lints] diff --git a/crates/ironrdp-acceptor/src/connection.rs b/crates/ironrdp-acceptor/src/connection.rs index c342625d0b..bcac64496d 100644 --- a/crates/ironrdp-acceptor/src/connection.rs +++ b/crates/ironrdp-acceptor/src/connection.rs @@ -1,6 +1,7 @@ use core::any::TypeId; use core::mem; +use ironrdp_connector::sspi::{AuthIdentity, UsernameParts}; use ironrdp_connector::{ ConnectorError, ConnectorErrorExt as _, ConnectorResult, DesktopSize, MonotonicInstant, Sequence, State, Written, encode_x224_packet, general_err, reason_err, @@ -51,7 +52,8 @@ pub struct Acceptor { static_channels: StaticChannelSet, saved_for_reactivation: AcceptorState, pub(crate) creds: Option, - received_credentials: Option, + received_credentials: Option, + credentials_handled: bool, received_auto_reconnect: Option, reactivation: bool, honor_client_desktop_size: Option, @@ -141,6 +143,37 @@ fn set_bitmap_desktop_size(capabilities: &mut [CapabilitySet], size: DesktopSize } } +/// Protocol source and handshake-authentication status of received credentials. +/// +/// Servers must not infer this from their configured security mode: the origin +/// records what the acceptor actually received during negotiation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CredentialOrigin { + /// Received in the ClientInfoPdu (MS-RDPBCGR 2.2.1.11). + /// + /// These credentials are client-supplied and have not been authenticated by + /// the protocol handshake. A server should validate them before using them + /// to select identity-bound resources. + ClientInfo, + /// Delegated TSPasswordCreds decrypted by CredSSP (MS-CSSP). + /// + /// CredSSP authenticated the principal during the exchange. A server may + /// still run authorization policy before starting or selecting a session. + CredSspDelegated, +} + +/// Credentials received by the acceptor together with their protocol origin. +/// +/// Keeping both values in one type makes it impossible to expose credentials +/// without the provenance required to interpret their authentication status. +#[derive(Clone, Debug)] +pub struct ReceivedCredentials { + /// CredSSP UPNs remain intact in `username`, with no `domain`. + /// Down-level names separate the account name and optional NetBIOS domain. + pub credentials: Credentials, + pub origin: CredentialOrigin, +} + #[derive(Debug)] pub struct AcceptorResult { pub static_channels: StaticChannelSet, @@ -198,11 +231,11 @@ pub struct AcceptorResult { /// Credentials received from the client during SecureSettingsExchange. /// /// Present for TLS-mode connections where the client sends credentials - /// in the ClientInfoPdu. `None` for CredSSP/Hybrid connections (where - /// authentication happens during the CredSSP exchange instead). + /// in the ClientInfoPdu. For CredSSP/Hybrid connections, credentials are + /// handled before capability exchange and are not exposed here. /// /// Servers that need to validate credentials (e.g., via PAM or LDAP) - /// can use this field for post-handshake validation. + /// should use a [`ConnectionSetupHandler`](crate::ConnectionSetupHandler). pub credentials: Option, /// Client Auto-Reconnect Packet received in the Client Info PDU. /// @@ -237,6 +270,7 @@ impl Acceptor { saved_for_reactivation: Default::default(), creds, received_credentials: None, + credentials_handled: false, received_auto_reconnect: None, reactivation: false, honor_client_desktop_size: None, @@ -472,6 +506,7 @@ impl Acceptor { saved_for_reactivation, creds: consumed.creds, received_credentials: consumed.received_credentials, + credentials_handled: consumed.credentials_handled, received_auto_reconnect: consumed.received_auto_reconnect, reactivation: true, honor_client_desktop_size: consumed.honor_client_desktop_size, @@ -542,6 +577,94 @@ impl Acceptor { matches!(self.state, AcceptorState::Credssp { .. }) } + pub(crate) fn desktop_size(&self) -> DesktopSize { + self.desktop_size + } + + pub(crate) fn is_reactivation(&self) -> bool { + self.reactivation + } + + /// Whether the client supplied a well-formed Client Auto-Reconnect Packet. + /// + /// The server still has to verify its security verifier before accepting + /// the connection. + pub(crate) fn is_auto_reconnect_attempt(&self) -> bool { + self.received_auto_reconnect.is_some() + } + + pub(crate) fn is_ready_for_capability_exchange(&self) -> bool { + matches!(self.state, AcceptorState::CapabilitiesSendServer { .. }) + } + + /// Returns credentials received during the current handshake, if any. + pub(crate) fn received_credentials(&self) -> Option<&ReceivedCredentials> { + self.received_credentials.as_ref() + } + + /// Whether credentials need handling after channel joins and ClientInfo. + pub(crate) fn credentials_need_handling(&self) -> bool { + matches!(self.state, AcceptorState::LicensingExchange { .. }) + && !self.is_auto_reconnect_attempt() + && self.received_credentials.is_some() + && !self.credentials_handled + } + + pub(crate) fn mark_credentials_handled(&mut self) { + self.credentials_handled = true; + } + + fn credentials_for_result(&mut self) -> Option { + self.received_credentials + .take() + .filter(|received| received.origin == CredentialOrigin::ClientInfo) + .map(|received| received.credentials) + } + + /// Encodes a Set Error Info PDU for a user authorization failure ([MS-RDPBCGR] section 2.2.5.1.1). + /// + /// Returns zero without writing if the client did not advertise error-info support. + /// + /// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/ + pub fn encode_access_denied(&self, output: &mut WriteBuf) -> ConnectorResult { + if !self + .early_capability_flags + .contains(gcc::ClientEarlyCapabilityFlags::SUPPORT_ERR_INFO_PDU) + { + debug!("Omitting access-denied PDU because the client did not advertise error-info support"); + return Ok(0); + } + + let info = wrap_share_data( + rdp::headers::ShareDataPdu::ServerSetErrorInfo(ServerSetErrorInfoPdu(ErrorInfo::ProtocolIndependentCode( + ProtocolIndependentCode::ServerInsufficientPrivileges, + ))), + 0, + ); + debug!(message = ?info, "Send"); + util::encode_send_data_indication(self.user_channel_id, self.io_channel_id, &info, output) + } + + /// Stores CredSSP/NLA credentials for the same validation and binding path as TLS ClientInfo credentials. + pub(crate) fn set_received_credssp_credentials(&mut self, identity: AuthIdentity) { + let (username, domain) = match identity.username.parts() { + UsernameParts::UserPrincipalName(parts) => (parts.upn().to_owned(), None), + UsernameParts::DownLevelLogonName(parts) => ( + parts.account_name().to_owned(), + parts.netbios_domain().map(str::to_owned), + ), + }; + self.received_credentials = Some(ReceivedCredentials { + credentials: Credentials { + username, + password: identity.password.as_ref().clone(), + domain, + }, + origin: CredentialOrigin::CredSspDelegated, + }); + self.credentials_handled = false; + } + /// # Panics /// /// Panics if state is not [AcceptorState::Credssp]. @@ -576,7 +699,7 @@ impl Acceptor { multitransport_response_success: self.received_multitransport_response, client_early_capability_flags: self.early_capability_flags, reactivation: self.reactivation, - credentials: self.received_credentials.take(), + credentials: self.credentials_for_result(), auto_reconnect: self.received_auto_reconnect.take(), }), previous_state => { @@ -1088,22 +1211,17 @@ impl Sequence for Acceptor { if let Some(expected) = &self.creds { if expected != &creds { - // FIXME: How authorization should be denied with standard RDP security? - // Since standard RDP security is not a priority, we just send a ServerDeniedConnection ServerSetErrorInfo PDU. - let info = ServerSetErrorInfoPdu(ErrorInfo::ProtocolIndependentCode( - ProtocolIndependentCode::ServerDeniedConnection, - )); - - debug!(message = ?info, "Send"); - - util::encode_send_data_indication(self.user_channel_id, self.io_channel_id, &info, output)?; - + self.encode_access_denied(output)?; return Err(ConnectorError::general("invalid credentials")); } } // Store credentials for later retrieval via AcceptorResult. - self.received_credentials = Some(creds); + self.received_credentials = Some(ReceivedCredentials { + credentials: creds, + origin: CredentialOrigin::ClientInfo, + }); + self.credentials_handled = false; } ( @@ -1435,3 +1553,71 @@ fn create_gcc_blocks( .map(|flags| gcc::MultiTransportChannelData { flags }), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn credentials(username: &str) -> Credentials { + Credentials { + username: username.to_owned(), + password: "secret".to_owned(), + domain: None, + } + } + + #[test] + fn acceptor_result_exposes_only_client_info_credentials() { + let mut acceptor = Acceptor::new( + SecurityProtocol::SSL, + DesktopSize { + width: 1024, + height: 768, + }, + Vec::new(), + None, + ); + + acceptor.received_credentials = Some(ReceivedCredentials { + credentials: credentials("nla-user"), + origin: CredentialOrigin::CredSspDelegated, + }); + assert!(acceptor.credentials_for_result().is_none()); + + acceptor.received_credentials = Some(ReceivedCredentials { + credentials: credentials("tls-user"), + origin: CredentialOrigin::ClientInfo, + }); + assert_eq!( + acceptor + .credentials_for_result() + .expect("ClientInfo credentials should remain available") + .username, + "tls-user" + ); + } + + #[test] + fn auto_reconnect_defers_client_info_credentials() { + let mut acceptor = Acceptor::new( + SecurityProtocol::SSL, + DesktopSize { + width: 1024, + height: 768, + }, + Vec::new(), + None, + ); + acceptor.received_credentials = Some(ReceivedCredentials { + credentials: credentials("reconnect-user"), + origin: CredentialOrigin::ClientInfo, + }); + acceptor.received_auto_reconnect = Some(ClientAutoReconnect { + logon_id: 7, + security_verifier: [0x5A; 16], + }); + + assert!(acceptor.is_auto_reconnect_attempt()); + assert!(!acceptor.credentials_need_handling()); + } +} diff --git a/crates/ironrdp-acceptor/src/credssp.rs b/crates/ironrdp-acceptor/src/credssp.rs index e665724dbf..1be36eb09d 100644 --- a/crates/ironrdp-acceptor/src/credssp.rs +++ b/crates/ironrdp-acceptor/src/credssp.rs @@ -153,18 +153,19 @@ impl<'a> CredsspSequence<'a> { &mut self, result: Result, output: &mut WriteBuf, - ) -> ConnectorResult { - let (ts_request, next_state) = match result { - Ok(ServerState::ReplyNeeded(ts_request)) => (Some(ts_request), CredsspState::Ongoing), - Ok(ServerState::Finished(_id)) => (None, CredsspState::Finished), + ) -> ConnectorResult<(Written, Option)> { + let (ts_request, next_state, credentials) = match result { + Ok(ServerState::ReplyNeeded(ts_request)) => (Some(ts_request), CredsspState::Ongoing, None), + Ok(ServerState::Finished(id)) => (None, CredsspState::Finished, Some(id)), Err(err) => ( err.ts_request.map(|ts_request| *ts_request), CredsspState::ServerError(err.error), + None, ), }; self.state = next_state; - if let Some(ts_request) = ts_request { + let written = if let Some(ts_request) = ts_request { debug!(?ts_request, "Send"); let length = usize::from(ts_request.buffer_len()); let unfilled_buffer = output.unfilled_to(length); @@ -175,9 +176,11 @@ impl<'a> CredsspSequence<'a> { output.advance(length); - Ok(Written::from_size(length)?) + Written::from_size(length)? } else { - Ok(Written::Nothing) - } + Written::Nothing + }; + + Ok((written, credentials)) } } diff --git a/crates/ironrdp-acceptor/src/lib.rs b/crates/ironrdp-acceptor/src/lib.rs index 8f186029b8..04ef26af5b 100644 --- a/crates/ironrdp-acceptor/src/lib.rs +++ b/crates/ironrdp-acceptor/src/lib.rs @@ -4,7 +4,7 @@ use ironrdp_async::{Framed, FramedRead, FramedWrite, NetworkClient, StreamWrapper, single_sequence_step}; use ironrdp_connector::sspi::credssp::EarlyUserAuthResult; use ironrdp_connector::sspi::{AuthIdentity, KerberosServerConfig, Username}; -use ironrdp_connector::{ConnectorResult, ServerName, custom_err, general_err}; +use ironrdp_connector::{ServerName, custom_err, general_err}; use ironrdp_core::WriteBuf; use tracing::{debug, instrument, trace}; @@ -14,11 +14,13 @@ pub mod credssp; mod finalization; mod util; -pub use ironrdp_connector::DesktopSize; +pub use ironrdp_connector::{ConnectorError, ConnectorErrorExt, ConnectorResult, DesktopSize}; use ironrdp_pdu::nego; pub use self::channel_connection::{ChannelConnectionSequence, ChannelConnectionState}; -pub use self::connection::{Acceptor, AcceptorResult, AcceptorState, MultitransportSecurityRng}; +pub use self::connection::{ + Acceptor, AcceptorResult, AcceptorState, CredentialOrigin, MultitransportSecurityRng, ReceivedCredentials, +}; pub use self::finalization::{FinalizationSequence, FinalizationState}; use crate::credssp::resolve_generator; @@ -30,6 +32,30 @@ where Continue(Framed), } +/// Async hooks that complete authenticated connection setup at protocol-safe points. +/// +/// Credential handling runs before HYBRID_EX reports success. +/// For TLS and plain HYBRID, it runs after channel joins and ClientInfo. +/// Capability setup runs after the operational desktop size is known but before capability exchange. +#[async_trait::async_trait(?Send)] +pub trait ConnectionSetupHandler { + async fn handle_credentials(&mut self, credentials: Option) -> ConnectorResult<()>; + + async fn prepare_capability_exchange(&mut self, desktop_size: DesktopSize) -> ConnectorResult<()> { + let _ = desktop_size; + Ok(()) + } +} + +struct NoopConnectionSetupHandler; + +#[async_trait::async_trait(?Send)] +impl ConnectionSetupHandler for NoopConnectionSetupHandler { + async fn handle_credentials(&mut self, _credentials: Option) -> ConnectorResult<()> { + Ok(()) + } +} + pub async fn accept_begin(mut framed: Framed, acceptor: &mut Acceptor) -> ConnectorResult> where S: FramedRead + FramedWrite + StreamWrapper, @@ -63,17 +89,44 @@ where S: FramedRead + FramedWrite, N: NetworkClient, { - let mut buf = WriteBuf::new(); + accept_credssp_with( + framed, + acceptor, + network_client, + client_computer_name, + public_key, + kerberos_config, + &mut NoopConnectionSetupHandler, + ) + .await +} +/// Runs CredSSP and invokes `credentials_handler` before HYBRID_EX reports success. +/// +/// Plain HYBRID defers credential handling to [`accept_finalize_with`] after channel joins. +pub async fn accept_credssp_with( + framed: &mut Framed, + acceptor: &mut Acceptor, + network_client: &mut N, + client_computer_name: ServerName, + public_key: Vec, + kerberos_config: Option, + credentials_handler: &mut H, +) -> ConnectorResult<()> +where + S: FramedRead + FramedWrite, + N: NetworkClient, + H: ConnectionSetupHandler, +{ if acceptor.should_perform_credssp() { perform_credssp_step( framed, acceptor, network_client, - &mut buf, client_computer_name, public_key, kerberos_config, + credentials_handler, ) .await } else { @@ -88,7 +141,20 @@ pub async fn accept_finalize( where S: FramedRead + FramedWrite, { - accept_finalize_with_multitransport(framed, acceptor, |_, _| async {}).await + accept_finalize_with(framed, acceptor, &mut NoopConnectionSetupHandler).await +} + +/// Finalizes the RDP handshake and invokes `credentials_handler` before capability exchange. +pub async fn accept_finalize_with( + framed: Framed, + acceptor: &mut Acceptor, + credentials_handler: &mut H, +) -> ConnectorResult<(Framed, AcceptorResult)> +where + S: FramedRead + FramedWrite, + H: ConnectionSetupHandler, +{ + accept_finalize_with_handlers(framed, acceptor, credentials_handler, |_, _| async {}).await } /// Completes the connection sequence, notifying `multitransport_handler` each @@ -114,13 +180,40 @@ where /// after `multitransport_request()` returned `Some`, which the two methods' /// own contract does not allow. pub async fn accept_finalize_with_multitransport( - mut framed: Framed, + framed: Framed, acceptor: &mut Acceptor, - mut multitransport_handler: H, + multitransport_handler: H, ) -> ConnectorResult<(Framed, AcceptorResult)> where S: FramedRead + FramedWrite, H: AsyncFnMut(ironrdp_pdu::rdp::multitransport::MultitransportRequestPdu, bool), +{ + accept_finalize_with_handlers( + framed, + acceptor, + &mut NoopConnectionSetupHandler, + multitransport_handler, + ) + .await +} + +/// Finalizes the handshake with both connection-setup and multitransport handlers. +/// +/// The handlers follow the timing described by [`accept_finalize_with`] and [`accept_finalize_with_multitransport`]. +/// +/// # Panics +/// +/// Panics if [`Acceptor::multitransport_soft_sync_negotiated`] returns `None` for an existing multitransport request. +pub async fn accept_finalize_with_handlers( + mut framed: Framed, + acceptor: &mut Acceptor, + credentials_handler: &mut H, + mut multitransport_handler: M, +) -> ConnectorResult<(Framed, AcceptorResult)> +where + S: FramedRead + FramedWrite, + H: ConnectionSetupHandler, + M: AsyncFnMut(ironrdp_pdu::rdp::multitransport::MultitransportRequestPdu, bool), { let mut buf = WriteBuf::new(); // `multitransport_request()` borrows rather than consumes (the field it @@ -142,7 +235,40 @@ where } single_sequence_step(&mut framed, acceptor, &mut buf).await?; - + if acceptor.credentials_need_handling() { + if let Err(error) = credentials_handler + .handle_credentials(acceptor.received_credentials().cloned()) + .await + { + buf.clear(); + let written = acceptor.encode_access_denied(&mut buf)?; + if written != 0 { + framed + .write_all(&buf[..written]) + .await + .map_err(|e| ironrdp_connector::custom_err!("write access denied", e))?; + } + return Err(error); + } + acceptor.mark_credentials_handled(); + } + if !acceptor.is_reactivation() + && !acceptor.is_auto_reconnect_attempt() + && acceptor.is_ready_for_capability_exchange() + && let Err(error) = credentials_handler + .prepare_capability_exchange(acceptor.desktop_size()) + .await + { + buf.clear(); + let written = acceptor.encode_access_denied(&mut buf)?; + if written != 0 { + framed + .write_all(&buf[..written]) + .await + .map_err(|e| ironrdp_connector::custom_err!("write access denied", e))?; + } + return Err(error); + } if !notified && let Some(request) = acceptor.multitransport_request() { let request = request.clone(); let soft_sync = acceptor @@ -155,20 +281,22 @@ where } #[instrument(level = "trace", skip_all, ret)] -async fn perform_credssp_step( +async fn perform_credssp_step( framed: &mut Framed, acceptor: &mut Acceptor, network_client: &mut N, - buf: &mut WriteBuf, client_computer_name: ServerName, public_key: Vec, kerberos_config: Option, + credentials_handler: &mut H, ) -> ConnectorResult<()> where S: FramedRead + FramedWrite, N: NetworkClient, + H: ConnectionSetupHandler, { assert!(acceptor.should_perform_credssp()); + let mut buf = WriteBuf::new(); let AcceptorState::Credssp { protocol, .. } = acceptor.state else { unreachable!() }; @@ -177,13 +305,27 @@ where framed, acceptor, network_client, - buf, + &mut buf, client_computer_name, public_key, kerberos_config, ) .await; + let result = match result { + Ok(()) if protocol.intersects(nego::SecurityProtocol::HYBRID_EX) => match credentials_handler + .handle_credentials(acceptor.received_credentials().cloned()) + .await + { + Ok(()) => { + acceptor.mark_credentials_handled(); + Ok(()) + } + Err(error) => Err(error), + }, + result => result, + }; + if protocol.intersects(nego::SecurityProtocol::HYBRID_EX) { trace!(?result, "HYBRID_EX"); @@ -195,7 +337,7 @@ where buf.clear(); result - .to_buffer(&mut *buf) + .to_buffer(&mut buf) .map_err(|e| ironrdp_connector::custom_err!("to_buffer", e))?; let response = &buf[..result.buffer_len()]; framed @@ -264,7 +406,10 @@ where }; // drop generator buf.clear(); - let written = sequence.handle_process_result(result, buf)?; + let (written, delegated_credentials) = sequence.handle_process_result(result, buf)?; + if let Some(credentials) = delegated_credentials { + acceptor.set_received_credssp_credentials(credentials); + } if let Some(response_len) = written.size() { let response = &buf[..response_len]; diff --git a/crates/ironrdp-server/src/builder.rs b/crates/ironrdp-server/src/builder.rs index d1d3e93f6e..ec4371cc5b 100644 --- a/crates/ironrdp-server/src/builder.rs +++ b/crates/ironrdp-server/src/builder.rs @@ -13,8 +13,8 @@ use super::display::{DesktopSize, RdpServerDisplay}; use super::gfx::GfxServerFactory; use super::handler::{KeyboardEvent, MouseEvent, RdpServerInputHandler}; use super::server::{ - ConnectionHandler, ConnectionPolicy, CredentialValidator, RdpServer, RdpServerOptions, RdpServerSecurity, - StaticChannelFactory, + ConnectionBinder, ConnectionHandler, ConnectionPolicy, CredentialValidator, RdpServer, RdpServerOptions, + RdpServerSecurity, StaticChannelFactory, }; use crate::error::ServerResult; #[cfg(feature = "usb")] @@ -52,6 +52,7 @@ pub struct BuilderDone { rdpeai_factory: Option>, connection_handler: Option>, credential_validator: Option>, + connection_binder: Option>, #[cfg(feature = "egfx")] gfx_factory: Option>, #[cfg(feature = "usb")] @@ -166,6 +167,7 @@ impl RdpServerBuilder { rdpeai_factory: None, connection_handler: None, credential_validator: None, + connection_binder: None, codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE, #[cfg(feature = "egfx")] @@ -203,6 +205,7 @@ impl RdpServerBuilder { rdpeai_factory: None, connection_handler: None, credential_validator: None, + connection_binder: None, codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE, #[cfg(feature = "egfx")] @@ -373,25 +376,29 @@ impl RdpServerBuilder { self } - /// Set a credential validator for TLS-mode connections. + /// Set a credential validator for accepted client credentials. /// - /// When set, credentials received from the client during - /// `SecureSettingsExchange` (`ClientInfoPdu`) are passed to this - /// validator before the session is established. Rejection or a backend - /// error closes the connection. Pass `None` (the default) to skip - /// validation entirely. + /// When set, credentials surfaced by the acceptor are passed to this + /// validator before the session is established, together with their + /// origin. This includes `SecureSettingsExchange` (`ClientInfoPdu`) + /// credentials and, when available, CredSSP/Hybrid delegated credentials. + /// Rejection or a backend error closes the connection. Pass `None` (the + /// default) to skip validation entirely. /// /// A valid Server Auto-Reconnect Cookie bypasses this validator. Applications /// that must validate every connection should leave automatic reconnection /// disabled. - /// - /// Not used for CredSSP/Hybrid connections (those use pre-loaded - /// credentials for NTLM challenge-response). pub fn with_credential_validator(mut self, validator: Option>) -> Self { self.state.credential_validator = validator; self } + /// Set a binder that replaces display/input handlers after credentials are accepted. + pub fn with_connection_binder(mut self, binder: Option>) -> Self { + self.state.connection_binder = binder; + self + } + /// Inject a shared NetworkAutoDetect RTT handle (milliseconds, `u32::MAX` /// until the first measurement). The server writes the latest measured RTT /// to the same instance the backend reads. When not called, the server @@ -551,6 +558,7 @@ impl RdpServerBuilder { ); server.set_credential_validator(self.state.credential_validator); server.set_auto_reconnect_cookie(self.state.auto_reconnect_cookie); + server.set_connection_binder(self.state.connection_binder); server } } diff --git a/crates/ironrdp-server/src/lib.rs b/crates/ironrdp-server/src/lib.rs index b1f9554080..9e9b6ea426 100644 --- a/crates/ironrdp-server/src/lib.rs +++ b/crates/ironrdp-server/src/lib.rs @@ -39,7 +39,7 @@ pub use gfx::{EgfxServerMessage, GfxDvcBridge, GfxServerFactory, GfxServerHandle pub use handler::{KeyboardEvent, MouseButton, MouseEvent, RdpServerInputHandler}; #[cfg(feature = "helper")] pub use helper::TlsIdentityCtx; -pub use ironrdp_acceptor::Acceptor; +pub use ironrdp_acceptor::{Acceptor, CredentialOrigin, ReceivedCredentials}; pub use ironrdp_pdu::rdp::server_error_info::ErrorInfo; pub use ironrdp_pdu::rdp::session_info::ServerAutoReconnect; #[cfg(feature = "usb")] @@ -53,8 +53,8 @@ pub use rdpei::{ TouchEventPdu, TouchFrame, }; pub use server::{ - AutoReconnectCookieHandle, ConnectionHandler, ConnectionInfo, ConnectionPolicy, CredentialDecision, - CredentialValidationError, CredentialValidator, Credentials, ErrorInfoDisconnectHandle, + AutoReconnectCookieHandle, BoundConnection, ConnectionBinder, ConnectionHandler, ConnectionInfo, ConnectionPolicy, + CredentialDecision, CredentialValidationError, CredentialValidator, Credentials, ErrorInfoDisconnectHandle, ExactMatchCredentialValidator, PostConnectionAction, RdpServer, RdpServerOptions, RdpServerSecurity, ServerEvent, ServerEventSender, StaticChannelFactory, TransportTls, pick_remotefx_entropy_coder, }; diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index 37af847670..c1dadc23ed 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -7,15 +7,17 @@ use core::time::Duration; use std::collections::HashMap; use std::collections::VecDeque; use std::rc::Rc; -use std::sync::Arc; -use std::sync::LazyLock; +use std::sync::{Arc, LazyLock, Mutex as StdMutex}; use std::time::Instant; -use ironrdp_acceptor::{Acceptor, AcceptorResult, BeginResult, DesktopSize}; +use ironrdp_acceptor::{ + Acceptor, AcceptorResult, BeginResult, ConnectionSetupHandler, ConnectorError, ConnectorErrorExt as _, + CredentialOrigin, DesktopSize, ReceivedCredentials, +}; use ironrdp_async::Framed; use ironrdp_cliprdr::CliprdrServer; use ironrdp_cliprdr::backend::ClipboardMessage; -use ironrdp_core::{decode, encode_vec, impl_as_any}; +use ironrdp_core::{WriteBuf, decode, encode_vec, impl_as_any}; use ironrdp_displaycontrol::pdu::{DisplayControlCapabilities, DisplayControlMonitorLayout}; use ironrdp_displaycontrol::server::{DisplayControlHandler, DisplayControlServer}; use ironrdp_dvc as dvc; @@ -54,13 +56,13 @@ use tracing::{debug, error, info, trace, warn}; use crate::autodetect::{AutoDetectManager, AutoDetectOutcome}; use crate::clipboard::CliprdrServerFactory; -use crate::display::{DisplayUpdate, RdpServerDisplay}; +use crate::display::{DisplayUpdate, RdpServerDisplay, RdpServerDisplayUpdates}; use crate::echo::{EchoDvcBridge, EchoServerHandle, EchoServerMessage, build_echo_request}; use crate::encoder::{UpdateEncoder, UpdateEncoderCodecs}; use crate::error::{ServerError, ServerErrorExt as _, ServerErrorKind, ServerResult}; #[cfg(feature = "egfx")] use crate::gfx::{EgfxServerMessage, GfxServerFactory}; -use crate::handler::RdpServerInputHandler; +use crate::handler::{KeyboardEvent, MouseEvent, RdpServerInputHandler}; use crate::heartbeat::HeartbeatConfig; use crate::multitransport; use crate::rdpeai::RdpeaiServerFactory; @@ -245,14 +247,17 @@ impl core::error::Error for CredentialValidationError { } } -/// Server-side credential validator for TLS-mode connections. +/// Server-side credential validator for accepted client credentials. /// -/// Called during connection setup when the server receives client credentials -/// via `ClientInfoPdu`. Not used for CredSSP/Hybrid connections (those use -/// pre-loaded credentials for NTLM challenge-response). +/// Called during connection setup when the acceptor surfaces credentials from +/// either `ClientInfoPdu` or CredSSP delegated TSPasswordCreds. Use the +/// [`CredentialValidator::validate_received`] hook to distinguish unauthenticated +/// ClientInfo credentials from CredSSP-delegated credentials authenticated by the exchange. /// -/// Implement this trait to validate credentials against external systems -/// (PAM, LDAP, database, etc.). For blocking backends, wrap the call in +/// Implement this trait to validate or authorize credentials against external systems +/// (PAM, LDAP, database, etc.). ClientInfo credentials require authentication; +/// CredSSP-delegated credentials may still require server-specific authorization. +/// For blocking backends, wrap the call in /// `tokio::task::spawn_blocking` to avoid stalling the async runtime. /// /// # Example @@ -281,7 +286,7 @@ impl core::error::Error for CredentialValidationError { /// ``` #[async_trait::async_trait] pub trait CredentialValidator: Send + Sync { - /// Validate credentials received from the client. + /// Validate or authorize credentials received from the client. /// /// Return `Ok(CredentialDecision::Accept)` to permit the connection, /// `Ok(CredentialDecision::Reject)` to refuse it. Return @@ -293,6 +298,196 @@ pub trait CredentialValidator: Send + Sync { /// `tokio::task::spawn_blocking`, so the returned future does not stall the /// caller's executor. Native-async backends can simply `.await`. async fn validate(&self, credentials: &Credentials) -> Result; + + /// Validate credentials with their transport/authentication provenance. + /// + /// Existing validators remain source-compatible through the default + /// implementation. Validators that need different ClientInfo and CredSSP + /// policies can override this method. + async fn validate_received( + &self, + credentials: &Credentials, + origin: CredentialOrigin, + ) -> Result { + let _ = origin; + self.validate(credentials).await + } +} + +/// Display/input objects bound after the server authenticates a client. +/// +/// Servers with per-user desktop/session isolation can start with placeholder +/// display and input handlers, authenticate the client's credentials, and then +/// replace those placeholders with handlers attached to the authenticated +/// user's session before the RDP client loop starts. +pub struct BoundConnection { + pub display: Box, + pub input: Box, +} + +/// Async post-auth connection binder for identity-bound display and input resources. +/// +/// A multi-user server can keep protocol ownership inside IronRDP while keeping +/// account authorization and session lifecycle outside the RDP state machine. +/// It may start with placeholder handlers, authenticate or authorize the received +/// credentials, then use this hook to start or locate the user's session and +/// install the returned handlers before static channels, display updates, or +/// input dispatch begin. +/// +/// The binder runs once for the initial acceptance of a TCP connection, after +/// the operational desktop size has been negotiated. `desktop_size` is the +/// exact size the returned display must expose. During Deactivation-Reactivation +/// (for example, a later client resize), the existing bound handlers remain +/// installed and the binder is not called again. +#[async_trait::async_trait] +pub trait ConnectionBinder: Send + Sync { + async fn bind_connection( + &self, + credentials: &Credentials, + desktop_size: DesktopSize, + ) -> ServerResult; +} + +struct BoundDisplaySlot { + default: Box, + // Async display methods lease the bound display from this slot. The lease + // restores it on cancellation unless a newer connection binding replaced it. + bound: Arc>, +} + +#[derive(Default)] +struct BoundDisplayState { + display: Option>, + generation: Arc<()>, +} + +impl BoundDisplayState { + fn clear(&mut self) { + self.display.take(); + self.generation = Arc::new(()); + } +} + +struct BoundDisplayLease { + slot: Arc>, + display: Option>, + generation: Arc<()>, +} + +impl BoundDisplayLease { + fn take(slot: &Arc>) -> Option { + let mut state = slot.lock().expect("bound display lock poisoned"); + let display = state.display.take()?; + Some(Self { + slot: Arc::clone(slot), + display: Some(display), + generation: Arc::clone(&state.generation), + }) + } + + fn display_mut(&mut self) -> &mut dyn RdpServerDisplay { + self.display + .as_deref_mut() + .expect("bound display lease always owns a display") + } +} + +impl Drop for BoundDisplayLease { + fn drop(&mut self) { + let mut slot = self.slot.lock().expect("bound display lock poisoned"); + if Arc::ptr_eq(&slot.generation, &self.generation) && slot.display.is_none() { + slot.display = self.display.take(); + } + } +} + +impl BoundDisplaySlot { + fn new(default: Box, bound: Arc>) -> Self { + Self { default, bound } + } +} + +#[async_trait::async_trait] +impl RdpServerDisplay for BoundDisplaySlot { + async fn size(&mut self) -> DesktopSize { + if let Some(mut lease) = BoundDisplayLease::take(&self.bound) { + lease.display_mut().size().await + } else { + self.default.size().await + } + } + + async fn request_initial_size(&mut self, client_size: DesktopSize) -> DesktopSize { + if let Some(mut lease) = BoundDisplayLease::take(&self.bound) { + lease.display_mut().request_initial_size(client_size).await + } else { + self.default.request_initial_size(client_size).await + } + } + + async fn monitor_count(&mut self) -> u32 { + if let Some(mut lease) = BoundDisplayLease::take(&self.bound) { + lease.display_mut().monitor_count().await + } else { + self.default.monitor_count().await + } + } + + async fn updates(&mut self) -> ServerResult> { + if let Some(mut lease) = BoundDisplayLease::take(&self.bound) { + lease.display_mut().updates().await + } else { + self.default.updates().await + } + } + + fn request_layout(&mut self, layout: DisplayControlMonitorLayout) { + let mut bound = self.bound.lock().expect("bound display lock poisoned"); + if let Some(display) = bound.display.as_mut() { + display.request_layout(layout); + } else { + drop(bound); + self.default.request_layout(layout); + } + } +} + +struct BoundInputSlot { + default: Box, + // Kept parallel to BoundDisplaySlot. The outer tokio::Mutex around + // RdpServer::handler serializes access before input dispatch reaches this slot. + bound: Arc>>>, +} + +impl BoundInputSlot { + fn new( + default: Box, + bound: Arc>>>, + ) -> Self { + Self { default, bound } + } +} + +impl RdpServerInputHandler for BoundInputSlot { + fn keyboard(&mut self, event: KeyboardEvent) { + let mut bound = self.bound.lock().expect("bound input lock poisoned"); + if let Some(handler) = bound.as_mut() { + handler.keyboard(event); + } else { + drop(bound); + self.default.keyboard(event); + } + } + + fn mouse(&mut self, event: MouseEvent) { + let mut bound = self.bound.lock().expect("bound input lock poisoned"); + if let Some(handler) = bound.as_mut() { + handler.mouse(event); + } else { + drop(bound); + self.default.mouse(event); + } + } } /// A built-in [`CredentialValidator`] that accepts exactly one fixed set of credentials. @@ -577,6 +772,7 @@ impl RdpServerSecurity { struct AInputHandler { handler: Arc>>, + active: Arc, } impl_as_any!(AInputHandler); @@ -594,13 +790,16 @@ impl dvc::DvcProcessor for AInputHandler { Ok(vec![Box::new(pdu)]) } - fn close(&mut self, _channel_id: u32) {} + fn close(&mut self, _channel_id: u32) { + self.active.store(false, Ordering::Release); + } fn process(&mut self, _channel_id: u32, payload: &[u8]) -> PduResult> { use ironrdp_ainput::ClientPdu; match decode(payload).map_err(|e| decode_err!(e))? { ClientPdu::Mouse(pdu) => { + self.active.store(true, Ordering::Release); let handler = Arc::clone(&self.handler); task::spawn_blocking(move || { handler.blocking_lock().mouse(pdu.into()); @@ -736,6 +935,15 @@ pub struct RdpServer { handler: Arc>>, display: Arc>>, static_channel_factories: Vec>, + /// Advanced Input owns mouse delivery while its DVC is active. Core mouse + /// events from the overlapping FastPath path are then suppressed. + advanced_input_active: Arc, + // ConnectionBinder installs per-user handlers into these slots. The + // default handler/display above stay stable for the server lifetime, while + // the slots are cleared at connection entry and after each connection to + // avoid cross-user reuse. + bound_handler: Arc>>>, + bound_display: Arc>, sound_factory: Option>, cliprdr_factory: Option>, rdpei_factory: Option>, @@ -752,6 +960,8 @@ pub struct RdpServer { ev_receiver: Arc>>, creds: Option, credential_validator: Option>, + connection_binder: Option>, + pending_authenticated_credentials: Arc>>, local_addr: Option, /// The local address the current client reached. See /// [`Self::set_connection_local_addr`]. @@ -1064,6 +1274,53 @@ enum NegotiatedTransport { struct PendingConnection { security: RdpServerSecurity, acceptor: Acceptor, + credentials_handler: ServerCredentialsHandler, +} + +#[derive(Default)] +struct ServerCredentialsHandler { + validator: Option>, + require_authenticated: bool, + authenticated_credentials: Option, +} + +#[async_trait::async_trait(?Send)] +impl ConnectionSetupHandler for ServerCredentialsHandler { + async fn handle_credentials(&mut self, credentials: Option) -> Result<(), ConnectorError> { + let authenticated = resolve_authenticated_credentials(self.validator.clone(), credentials.as_ref()) + .await + .map_err(|error| ConnectorError::custom("credential validation failed", error))?; + if self.validator.is_some() && authenticated.is_none() { + return Err(ConnectorError::general("no credentials available for validation")); + } + if self.require_authenticated + && self.validator.is_none() + && credentials.as_ref().map(|received| received.origin) != Some(CredentialOrigin::CredSspDelegated) + { + return Err(ConnectorError::general( + "connection binder requires authenticated credentials from a validator or CredSSP", + )); + } + self.authenticated_credentials = authenticated.cloned(); + Ok(()) + } +} + +struct BoundConnectionGuard { + display: Arc>, + input: Arc>>>, + credentials: Arc>>, +} + +impl Drop for BoundConnectionGuard { + fn drop(&mut self) { + self.display.lock().expect("bound display lock poisoned").clear(); + self.input.lock().expect("bound input lock poisoned").take(); + self.credentials + .lock() + .expect("authenticated credentials lock poisoned") + .take(); + } } impl PendingConnection { @@ -1074,6 +1331,7 @@ impl PendingConnection { creds: Option, honor_client_desktop_size: Option, udp_bind_addr: Option, + credentials_handler: ServerCredentialsHandler, ) -> Self { let mut acceptor = Acceptor::new(security.flag(), desktop_size, capabilities, creds); acceptor.set_honor_client_desktop_size(honor_client_desktop_size); @@ -1091,7 +1349,11 @@ impl PendingConnection { | ironrdp_pdu::gcc::MultiTransportFlags::SOFT_SYNC_TCP_TO_UDP, )); } - Self { security, acceptor } + Self { + security, + acceptor, + credentials_handler, + } } /// Mutable access to the acceptor for the one thing that must happen @@ -1122,7 +1384,11 @@ impl PendingConnection { where S: AsyncRead + AsyncWrite + Send + Sync + Unpin, { - let PendingConnection { security, mut acceptor } = self; + let PendingConnection { + security, + mut acceptor, + mut credentials_handler, + } = self; let security = &security; let framed = TokioFramed::new(stream); @@ -1153,11 +1419,12 @@ impl PendingConnection { } }; let mut framed = TokioFramed::new(accept); - complete_security_upgrade(security, &mut framed, &mut acceptor).await?; + complete_security_upgrade(security, &mut framed, &mut acceptor, &mut credentials_handler).await?; Ok(Some(NegotiatedConnection { transport: NegotiatedTransport::Tls(Box::new(framed)), acceptor, local_addr: None, + authenticated_credentials: credentials_handler.authenticated_credentials, })) } // The stream is already past TLS (terminated at a lower @@ -1165,11 +1432,12 @@ impl PendingConnection { // tls_acceptor.accept on it. TransportTls::AlreadyDone => { let mut framed = TokioFramed::new(stream); - complete_security_upgrade(security, &mut framed, &mut acceptor).await?; + complete_security_upgrade(security, &mut framed, &mut acceptor, &mut credentials_handler).await?; Ok(Some(NegotiatedConnection { transport: NegotiatedTransport::Offloaded(framed), acceptor, local_addr: None, + authenticated_credentials: credentials_handler.authenticated_credentials, })) } }, @@ -1178,6 +1446,7 @@ impl PendingConnection { transport: NegotiatedTransport::Continued(framed), acceptor, local_addr: None, + authenticated_credentials: credentials_handler.authenticated_credentials, })), } } @@ -1193,6 +1462,7 @@ struct NegotiatedConnection { /// The local address the client reached, when the stream is a socket /// the server accepted itself; see [`RdpServer::set_connection_local_addr`]. local_addr: Option, + authenticated_credentials: Option, } /// State that belongs to a single client connection. @@ -1300,6 +1570,7 @@ async fn complete_security_upgrade( security: &RdpServerSecurity, framed: &mut TokioFramed, acceptor: &mut Acceptor, + credentials_handler: &mut ServerCredentialsHandler, ) -> ServerResult<()> where S: AsyncRead + AsyncWrite + Send + Sync + Unpin, @@ -1312,13 +1583,14 @@ where // uses this value in practice. let client_name = "rdp-client".to_owned(); - ironrdp_acceptor::accept_credssp( + ironrdp_acceptor::accept_credssp_with( framed, acceptor, &mut ironrdp_tokio::reqwest::ReqwestNetworkClient::new(), client_name.into(), pub_key.clone(), None, + credentials_handler, ) .await .map_err_kind("accept_credssp", ServerErrorKind::Connector)?; @@ -1464,6 +1736,8 @@ struct NegotiationContext { opts: RdpServerOptions, creds: Option, display: Arc>>, + credential_validator: Option>, + requires_authenticated_credentials: bool, } /// A candidate that has negotiated AND authenticated, and so has earned the @@ -1498,6 +1772,11 @@ async fn negotiate_candidate( ctx.creds.clone(), ctx.opts.honor_client_desktop_size, ctx.opts.udp_bind_addr, + ServerCredentialsHandler { + validator: ctx.credential_validator.clone(), + require_authenticated: ctx.requires_authenticated_credentials, + ..ServerCredentialsHandler::default() + }, ); // NOTE: deliberately NO channel attachment here. Building the cliprdr / @@ -1610,11 +1889,22 @@ impl RdpServer { if let Some(gfx) = gfx_factory.as_mut() { gfx.set_sender(ev_sender.clone()); } + let bound_handler = Arc::new(StdMutex::new(None)); + let bound_display = Arc::new(StdMutex::new(BoundDisplayState::default())); Self { opts, - handler: Arc::new(Mutex::new(handler)), - display: Arc::new(Mutex::new(display)), + handler: Arc::new(Mutex::new(Box::new(BoundInputSlot::new( + handler, + Arc::clone(&bound_handler), + )))), + display: Arc::new(Mutex::new(Box::new(BoundDisplaySlot::new( + display, + Arc::clone(&bound_display), + )))), + advanced_input_active: Arc::new(AtomicBool::new(false)), + bound_handler, + bound_display, static_channel_factories, sound_factory, cliprdr_factory, @@ -1632,6 +1922,8 @@ impl RdpServer { ev_receiver: Arc::new(Mutex::new(ev_receiver)), creds: None, credential_validator: None, + connection_binder: None, + pending_authenticated_credentials: Arc::new(StdMutex::new(None)), local_addr: None, connection_local_addr: None, autodetect_enabled: false, @@ -1667,11 +1959,12 @@ impl RdpServer { builder::RdpServerBuilder::new() } - /// Set or clear the credential validator for TLS-mode connections. + /// Set or clear the credential validator for accepted client credentials. /// - /// When set, credentials received from the client during - /// `SecureSettingsExchange` are validated through this callback before - /// the session is established. If the validator returns + /// When set, credentials surfaced by the acceptor are validated through + /// this callback before the session is established. This includes + /// `SecureSettingsExchange` (`ClientInfoPdu`) credentials and, when + /// available, CredSSP/Hybrid delegated credentials. If the validator returns /// [`CredentialDecision::Reject`] (or a [`CredentialValidationError`]), /// the connection is rejected. Passing `None` clears any previously /// configured validator. @@ -1684,8 +1977,6 @@ impl RdpServer { /// the builder's `with_credential_validator` method /// ([`RdpServer::builder`]); this setter exists for dynamic /// post-construction reconfiguration. - /// - /// Not used for CredSSP/Hybrid connections (those use pre-loaded credentials). pub fn set_credential_validator(&mut self, validator: Option>) { self.credential_validator = validator; } @@ -1905,6 +2196,32 @@ impl RdpServer { Ok(()) } + /// Set or clear a post-auth connection binder. + /// + /// When set, the binder is called only when authenticated credentials are + /// available. The returned display/input handlers replace the server + /// defaults for the accepted connection. + pub fn set_connection_binder(&mut self, binder: Option>) { + self.connection_binder = binder; + } + + fn install_bound_connection(&mut self, bound: BoundConnection) { + let mut display = self.bound_display.lock().expect("bound display lock poisoned"); + display.clear(); + display.display = Some(bound.display); + *self.bound_handler.lock().expect("bound input lock poisoned") = Some(bound.input); + } + + fn clear_bound_connection(&mut self) { + self.bound_display.lock().expect("bound display lock poisoned").clear(); + self.bound_handler.lock().expect("bound input lock poisoned").take(); + self.pending_authenticated_credentials + .lock() + .expect("authenticated credentials lock poisoned") + .take(); + self.advanced_input_active.store(false, Ordering::Release); + } + pub fn event_sender(&self) -> &mpsc::UnboundedSender { &self.ev_sender } @@ -2057,6 +2374,7 @@ impl RdpServer { let dvc = dvc::DrdynvcServer::new() .with_dynamic_channel(AInputHandler { handler: Arc::clone(&self.handler), + active: Arc::clone(&self.advanced_input_active), }) .with_dynamic_channel(DisplayControlServer::new(Box::new(dcs_backend))); @@ -2178,6 +2496,8 @@ impl RdpServer { opts: self.opts.clone(), creds: self.creds.clone(), display: Arc::clone(&self.display), + credential_validator: self.credential_validator.clone(), + requires_authenticated_credentials: self.connection_binder.is_some(), } } @@ -2327,6 +2647,7 @@ impl RdpServer { where S: AsyncRead + AsyncWrite + Send + Sync + Unpin, { + self.clear_bound_connection(); // Per-connection state must start fresh: if the previous client // disconnected while it had sent `SuppressOutput { None }` (e.g., // closed the mstsc window while minimized so the matching resume @@ -2353,6 +2674,11 @@ impl RdpServer { self.creds.clone(), self.opts.honor_client_desktop_size, self.opts.udp_bind_addr, + ServerCredentialsHandler { + validator: self.credential_validator.clone(), + require_authenticated: self.connection_binder.is_some(), + ..ServerCredentialsHandler::default() + }, ); self.attach_channels(pending.acceptor_mut(), monitor_count); @@ -2381,7 +2707,18 @@ impl RdpServer { transport, acceptor, local_addr, + authenticated_credentials, } = negotiated; + self.clear_bound_connection(); + let _binding = BoundConnectionGuard { + display: Arc::clone(&self.bound_display), + input: Arc::clone(&self.bound_handler), + credentials: Arc::clone(&self.pending_authenticated_credentials), + }; + *self + .pending_authenticated_credentials + .lock() + .expect("authenticated credentials lock poisoned") = authenticated_credentials; if local_addr.is_some() { self.connection_local_addr = local_addr; } @@ -2604,6 +2941,7 @@ impl RdpServer { } continue; } + self.clear_bound_connection(); let started = tokio::time::Instant::now(); @@ -2797,7 +3135,7 @@ impl RdpServer { // taken by the outer loop, not rejected here. let result = match entry { Entry::Fresh(stream, _) => { - let mut conn = core::pin::pin!(self.run_connection_inner(stream, TransportTls::Managed)); + let mut conn = Box::pin(self.run_connection_inner(stream, TransportTls::Managed)); loop { tokio::select! { biased; @@ -2813,7 +3151,7 @@ impl RdpServer { } } // Unreachable: Reject never produces a Negotiated candidate. - Entry::Negotiated(candidate, _) => self.serve_negotiated(candidate).await, + Entry::Negotiated(candidate, _) => Box::pin(self.serve_negotiated(candidate)).await, }; (result, None) } @@ -2821,10 +3159,12 @@ impl RdpServer { let result = match entry { // Same anyhow-vs-ServerResult reasoning as the preemption // branch above. - Entry::Fresh(stream, _) => self.run_connection_inner(stream, TransportTls::Managed).await, + Entry::Fresh(stream, _) => { + Box::pin(self.run_connection_inner(stream, TransportTls::Managed)).await + } // Unreachable in practice: `pending` is only ever populated // by the preemption branch above. - Entry::Negotiated(candidate, _) => self.serve_negotiated(candidate).await, + Entry::Negotiated(candidate, _) => Box::pin(self.serve_negotiated(candidate)).await, }; (result, None) } @@ -4174,12 +4514,29 @@ impl RdpServer { state } + async fn prepare_authenticated_connection( + &mut self, + received_credentials: Option, + ) -> Result<(), ConnectorError> { + let mut handler = ServerCredentialsHandler { + validator: self.credential_validator.clone(), + require_authenticated: self.connection_binder.is_some(), + ..ServerCredentialsHandler::default() + }; + handler.handle_credentials(received_credentials).await?; + *self + .pending_authenticated_credentials + .lock() + .expect("authenticated credentials lock poisoned") = handler.authenticated_credentials; + Ok(()) + } + async fn client_accepted( &mut self, conn: &mut ConnectionState, reader: &mut Framed, writer: &mut Framed, - result: AcceptorResult, + (result, acceptor): (AcceptorResult, &Acceptor), udp_transport: Rc>>, pending_udp_accept: Option>>, ) -> ServerResult @@ -4189,51 +4546,44 @@ impl RdpServer { { debug!("Client accepted"); - // MS-RDPBCGR 3.3.5.7.1: a Set Error Info PDU MUST NOT be sent to a client that did not - // set `SUPPORT_ERR_INFO_PDU`; such a client is just disconnected. - let supports_err_info = result - .client_early_capability_flags - .contains(ironrdp_pdu::gcc::ClientEarlyCapabilityFlags::SUPPORT_ERR_INFO_PDU); - let is_auto_reconnect = if let Some(reconnect) = result.auto_reconnect.as_ref() { if !self.verify_auto_reconnect_cookie(reconnect) { warn!("Auto-reconnect cookie validation rejected"); - send_access_denied(result.io_channel_id, result.user_channel_id, supports_err_info, writer).await?; + send_access_denied(acceptor, writer).await?; return Err(ServerError::reason("auto-reconnect validation", "cookie rejected")); } + if self.connection_binder.is_some() { + warn!("Auto-reconnect cannot restore a credential-bound connection safely"); + send_access_denied(acceptor, writer).await?; + return Err(ServerError::reason( + "auto-reconnect validation", + "unsupported with a connection binder", + )); + } + debug!("Auto-reconnect cookie validation accepted"); true } else { false }; - // Validate credentials if a validator is configured. The validator runs here, in the - // async server layer, rather than in the sans-I/O acceptor, because real validators - // (PAM/LDAP/DB) are I/O-bound. On rejection, deny with a ServerSetErrorInfoPdu before - // closing, matching the acceptor's exact-match denial path. - if !is_auto_reconnect && let Some(validator) = self.credential_validator.clone() { - if let Some(creds) = &result.credentials { - match validator.validate(creds).await { - Ok(CredentialDecision::Accept) => { - debug!("Credential validation accepted"); - } - Ok(CredentialDecision::Reject) => { - warn!("Credential validation rejected"); - send_access_denied(result.io_channel_id, result.user_channel_id, supports_err_info, writer) - .await?; - return Err(ServerError::reason("credential validation", "rejected by validator")); - } - Err(e) => { - error!(error = %e, "Credential validator backend error"); - send_access_denied(result.io_channel_id, result.user_channel_id, supports_err_info, writer) - .await?; - return Err(ServerError::custom("credential validation", e)); - } - } - } else { - debug!("Skipping credential validation (no credentials in AcceptorResult)"); - } + if result.reactivation { + debug!("Reactivation reuses the authenticated connection binding"); + } else if !is_auto_reconnect + && (self.credential_validator.is_some() || self.connection_binder.is_some()) + && self + .pending_authenticated_credentials + .lock() + .expect("authenticated credentials lock poisoned") + .is_none() + { + warn!("Initial acceptance reached activation without prepared credentials"); + send_access_denied(acceptor, writer).await?; + return Err(ServerError::reason( + "credential validation", + "credentials were not prepared before activation", + )); } if !result.reactivation { @@ -4522,15 +4872,21 @@ impl RdpServer { } FastPathInputEvent::MouseEvent(mouse) => { - handler.mouse(mouse.into()); + if !self.advanced_input_active.load(Ordering::Acquire) { + handler.mouse(mouse.into()); + } } FastPathInputEvent::MouseEventEx(mouse) => { - handler.mouse(mouse.into()); + if !self.advanced_input_active.load(Ordering::Acquire) { + handler.mouse(mouse.into()); + } } FastPathInputEvent::MouseEventRel(mouse) => { - handler.mouse(mouse.into()); + if !self.advanced_input_active.load(Ordering::Acquire) { + handler.mouse(mouse.into()); + } } FastPathInputEvent::QoeEvent(quality) => { @@ -4768,15 +5124,21 @@ impl RdpServer { } ironrdp_pdu::input::InputEvent::Mouse(mouse) => { - handler.mouse(mouse.into()); + if !self.advanced_input_active.load(Ordering::Acquire) { + handler.mouse(mouse.into()); + } } ironrdp_pdu::input::InputEvent::MouseX(mouse) => { - handler.mouse(mouse.into()); + if !self.advanced_input_active.load(Ordering::Acquire) { + handler.mouse(mouse.into()); + } } ironrdp_pdu::input::InputEvent::MouseRel(mouse) => { - handler.mouse(mouse.into()); + if !self.advanced_input_active.load(Ordering::Acquire) { + handler.mouse(mouse.into()); + } } ironrdp_pdu::input::InputEvent::Unused(_) => {} @@ -4817,7 +5179,6 @@ impl RdpServer { // synchronously as part of finalize, so establishing it must never // block the RDP handshake finalize itself is driving. let mut pending_udp_accept: Option>> = None; - loop { // Bounded: see `FINALIZE_TIMEOUT`. The bound belongs on THIS call // and not on `accept_finalize` itself or its callers — the loop @@ -4833,9 +5194,10 @@ impl RdpServer { // called, see `PendingConnection::new`), so the handler below // simply never runs and this is exactly `accept_finalize`'s own // behavior. - let finalize = ironrdp_acceptor::accept_finalize_with_multitransport( + let finalize = ironrdp_acceptor::accept_finalize_with_handlers( framed, &mut acceptor, + self, async |request, _soft_sync| { // Defensive only: reached only if the acceptor sent a // request despite one of these being unset, which @@ -4871,7 +5233,7 @@ impl RdpServer { conn, &mut reader, &mut writer, - result, + (result, &acceptor), Rc::clone(&udp_transport), pending_udp_accept.take(), ) @@ -4926,6 +5288,47 @@ impl RdpServer { } } +fn validate_bound_display_size(negotiated_size: DesktopSize, bound_size: DesktopSize) -> Result<(), ConnectorError> { + if bound_size == negotiated_size { + Ok(()) + } else { + Err(ConnectorError::reason( + "bound display dimensions differ from negotiated dimensions", + format!("negotiated {negotiated_size:?}, bound {bound_size:?}"), + )) + } +} + +async fn resolve_authenticated_credentials( + credential_validator: Option>, + received_credentials: Option<&ReceivedCredentials>, +) -> ServerResult> { + if let Some(received) = received_credentials { + let creds = &received.credentials; + if let Some(validator) = credential_validator { + match validator.validate_received(creds, received.origin).await { + Ok(CredentialDecision::Accept) => { + debug!("Credential validation accepted"); + Ok(Some(creds)) + } + Ok(CredentialDecision::Reject) => { + warn!("Credential validation rejected"); + Err(ServerError::reason("credential validation", "rejected by validator")) + } + Err(e) => { + error!(error = %e, "Credential validator backend error"); + Err(ServerError::custom("credential validation", e)) + } + } + } else { + Ok(Some(creds)) + } + } else { + debug!("Skipping credential validation (no credentials in AcceptorResult)"); + Ok(None) + } +} + /// Encode a server-initiated Auto-Detect Request PDU for the MCS message channel. /// /// The request is framed by a Basic Security Header (SEC_AUTODETECT_REQ) per @@ -5051,36 +5454,17 @@ fn with_connection_handler( handler.map(|h| f(h.borrow_mut().as_mut())) } -/// Send a `ServerSetErrorInfoPdu(ServerDeniedConnection)` to the client, then return. -/// -/// Used to deny a connection after credential validation rejects it, mirroring the -/// acceptor's exact-match denial so both paths refuse the same spec-defined way. -/// -/// Sends nothing when the client did not opt in via `SUPPORT_ERR_INFO_PDU` -/// (MS-RDPBCGR 3.3.5.7.1); the caller still closes the connection. -async fn send_access_denied( - io_channel_id: u16, - user_channel_id: u16, - supports_err_info: bool, - writer: &mut impl FramedWrite, -) -> ServerResult<()> { - if !supports_err_info { - return Ok(()); - } - let info = ServerSetErrorInfoPdu(ErrorInfo::ProtocolIndependentCode( - ProtocolIndependentCode::ServerDeniedConnection, - )); - let user_data = encode_vec(&info).map_err(ServerError::encode)?.into(); - let pdu = SendDataIndication { - initiator_id: user_channel_id, - channel_id: io_channel_id, - user_data, - }; - let msg = encode_vec(&X224(pdu)).map_err(ServerError::encode)?; - writer - .write_all(&msg) - .await - .map_err(|e| ServerError::io("write access_denied", e))?; +async fn send_access_denied(acceptor: &Acceptor, writer: &mut impl FramedWrite) -> ServerResult<()> { + let mut buf = WriteBuf::new(); + let written = acceptor + .encode_access_denied(&mut buf) + .map_err_kind("encode access denied", ServerErrorKind::Connector)?; + if written != 0 { + writer + .write_all(&buf[..written]) + .await + .map_err(|e| ServerError::io("write access denied", e))?; + } Ok(()) } @@ -5165,6 +5549,52 @@ impl<'a, W: FramedWrite> SharedWriter<'a, W> { } } +#[async_trait::async_trait(?Send)] +impl ConnectionSetupHandler for RdpServer { + async fn handle_credentials(&mut self, credentials: Option) -> Result<(), ConnectorError> { + self.prepare_authenticated_connection(credentials).await + } + + async fn prepare_capability_exchange(&mut self, desktop_size: DesktopSize) -> Result<(), ConnectorError> { + if (self.credential_validator.is_some() || self.connection_binder.is_some()) + && self + .pending_authenticated_credentials + .lock() + .expect("authenticated credentials lock poisoned") + .is_none() + { + return Err(ConnectorError::general( + "credentials were not prepared before capability exchange", + )); + } + + let Some(binder) = self.connection_binder.clone() else { + return Ok(()); + }; + let credentials = self + .pending_authenticated_credentials + .lock() + .expect("authenticated credentials lock poisoned") + .clone() + .ok_or_else(|| ConnectorError::general("no authenticated credentials for connection binding"))?; + let bound = binder + .bind_connection(&credentials, desktop_size) + .await + .map_err(|error| ConnectorError::custom("connection binder failed", error))?; + + let mut bound_display = bound.display; + let bound_size = bound_display.size().await; + validate_bound_display_size(desktop_size, bound_size)?; + + self.install_bound_connection(BoundConnection { + display: bound_display, + input: bound.input, + }); + debug!(?bound_size, "Connection binder installed display/input handlers"); + Ok(()) + } +} + #[cfg(test)] mod preempt_tests { use core::net::Ipv4Addr; @@ -5184,7 +5614,7 @@ mod preempt_tests { height: 768, } } - async fn updates(&mut self) -> ServerResult> { + async fn updates(&mut self) -> ServerResult> { unreachable!("negotiation never asks for updates") } } @@ -5203,6 +5633,8 @@ mod preempt_tests { }, creds: None, display: Arc::new(Mutex::new(Box::new(NoDisplay))), + credential_validator: None, + requires_authenticated_credentials: false, } } @@ -5969,3 +6401,308 @@ mod cliprdr_error_tests { assert!(writer.0.is_empty(), "a refused message has nothing to put on the wire"); } } + +#[cfg(test)] +mod credential_binding_tests { + use super::*; + + struct FailingValidator; + + #[async_trait::async_trait] + impl CredentialValidator for FailingValidator { + async fn validate(&self, _credentials: &Credentials) -> Result { + Err(CredentialValidationError::new(std::io::Error::other( + "backend unavailable", + ))) + } + } + + fn creds(username: &str) -> Credentials { + Credentials { + username: username.to_owned(), + password: "secret".to_owned(), + domain: None, + } + } + + #[test] + fn bound_display_size_must_match_negotiated_size() { + let negotiated = DesktopSize { + width: 1280, + height: 720, + }; + assert!(validate_bound_display_size(negotiated, negotiated).is_ok()); + + let incompatible = DesktopSize { + width: 1920, + height: 1080, + }; + assert!(validate_bound_display_size(negotiated, incompatible).is_err()); + } + + #[tokio::test] + async fn credential_validator_backend_error_is_preserved() { + let validator: Arc = Arc::new(FailingValidator); + let received_credentials = ReceivedCredentials { + credentials: creds("alice"), + origin: CredentialOrigin::ClientInfo, + }; + + let error = resolve_authenticated_credentials(Some(validator), Some(&received_credentials)) + .await + .expect_err("backend failure should be returned"); + let validation_error = core::error::Error::source(&error) + .expect("validator source should be preserved") + .downcast_ref::() + .expect("credential validation error should remain downcastable"); + let source = core::error::Error::source(validation_error).expect("backend source should be preserved"); + assert_eq!(source.to_string(), "backend unavailable"); + } + + #[tokio::test] + async fn candidate_credential_validation_does_not_change_the_active_identity() { + let mut server = RdpServer::builder() + .with_addr(([127, 0, 0, 1], 0)) + .with_no_security() + .with_no_input() + .with_no_display() + .build(); + server.set_credential_validator(Some(Arc::new(ExactMatchCredentialValidator::new(creds("alice"))))); + *server.pending_authenticated_credentials.lock().unwrap() = Some(creds("bob")); + let context = server.negotiation_context(); + let mut accepted = ServerCredentialsHandler { + validator: context.credential_validator.clone(), + ..ServerCredentialsHandler::default() + }; + accepted + .handle_credentials(Some(ReceivedCredentials { + credentials: creds("alice"), + origin: CredentialOrigin::CredSspDelegated, + })) + .await + .unwrap(); + assert_eq!(accepted.authenticated_credentials, Some(creds("alice"))); + + let mut rejected = ServerCredentialsHandler { + validator: context.credential_validator, + ..ServerCredentialsHandler::default() + }; + assert!( + rejected + .handle_credentials(Some(ReceivedCredentials { + credentials: creds("mallory"), + origin: CredentialOrigin::CredSspDelegated, + })) + .await + .is_err() + ); + assert!(rejected.authenticated_credentials.is_none()); + assert_eq!( + *server.pending_authenticated_credentials.lock().unwrap(), + Some(creds("bob")) + ); + } +} + +#[cfg(test)] +mod tests { + use core::future::pending; + + use ironrdp_core::{Encode as _, WriteCursor, impl_as_any}; + use ironrdp_dvc::DvcProcessor as _; + use ironrdp_pdu::gcc::ChannelName; + use ironrdp_svc::{SvcMessage, SvcServerProcessor}; + + use super::*; + + /// A channel backend that owns a resource, released on drop the way + /// `RdpsndServer` stops its handler. + #[derive(Debug)] + struct ResourceChannel(Arc); + + impl Drop for ResourceChannel { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } + } + + impl_as_any!(ResourceChannel); + + impl SvcProcessor for ResourceChannel { + fn channel_name(&self) -> ChannelName { + ChannelName::from_static(b"testchn\0") + } + + fn process(&mut self, _payload: &[u8]) -> PduResult> { + Ok(Vec::new()) + } + } + + impl SvcServerProcessor for ResourceChannel {} + + struct NoopInput; + + impl RdpServerInputHandler for NoopInput { + fn keyboard(&mut self, _event: KeyboardEvent) {} + fn mouse(&mut self, _event: MouseEvent) {} + } + + #[tokio::test] + async fn advanced_input_owns_mouse_only_after_first_pdu() { + use ironrdp_ainput::{ClientPdu, MouseEventFlags, MousePdu}; + + let active = Arc::new(AtomicBool::new(false)); + let mut handler = AInputHandler { + handler: Arc::new(Mutex::new(Box::new(NoopInput))), + active: Arc::clone(&active), + }; + + handler.start(1).expect("AInput start failed"); + assert!(!active.load(Ordering::Acquire)); + + let pdu = ClientPdu::Mouse(MousePdu { + time: 0, + flags: MouseEventFlags::MOVE, + x: 1, + y: 1, + }); + let mut payload = vec![0; pdu.size()]; + pdu.encode(&mut WriteCursor::new(&mut payload)) + .expect("AInput encoding failed"); + handler.process(1, &payload).expect("AInput processing failed"); + assert!(active.load(Ordering::Acquire)); + + handler.close(1); + assert!(!active.load(Ordering::Acquire)); + } + + struct PendingDisplay; + + #[async_trait::async_trait] + impl RdpServerDisplay for PendingDisplay { + async fn size(&mut self) -> DesktopSize { + pending().await + } + + async fn updates(&mut self) -> ServerResult> { + pending().await + } + } + + struct FixedDisplay(DesktopSize); + + #[async_trait::async_trait] + impl RdpServerDisplay for FixedDisplay { + async fn size(&mut self) -> DesktopSize { + self.0 + } + + async fn updates(&mut self) -> ServerResult> { + pending().await + } + + async fn monitor_count(&mut self) -> u32 { + 2 + } + } + + #[tokio::test] + async fn cancelling_bound_display_call_restores_authenticated_display() { + let expected = DesktopSize { + width: 1280, + height: 720, + }; + let bound = Arc::new(StdMutex::new(BoundDisplayState { + display: Some(Box::new(PendingDisplay)), + ..BoundDisplayState::default() + })); + let mut slot = BoundDisplaySlot::new(Box::new(FixedDisplay(expected)), Arc::clone(&bound)); + + assert!(tokio::time::timeout(Duration::ZERO, slot.size()).await.is_err()); + assert!(bound.lock().expect("bound display lock poisoned").display.is_some()); + } + + #[tokio::test] + async fn bound_display_forwards_monitor_count() { + let bound = Arc::new(StdMutex::new(BoundDisplayState { + display: Some(Box::new(FixedDisplay(DesktopSize { + width: 1280, + height: 720, + }))), + ..BoundDisplayState::default() + })); + let mut slot = BoundDisplaySlot::new(Box::new(PendingDisplay), Arc::clone(&bound)); + assert_eq!(slot.monitor_count().await, 2); + bound.lock().unwrap().clear(); + assert_eq!(slot.monitor_count().await, 1); + } + + #[tokio::test] + async fn cancelling_connection_drops_bound_resources_and_credentials() { + let display = Arc::new(StdMutex::new(BoundDisplayState { + display: Some(Box::new(PendingDisplay)), + ..BoundDisplayState::default() + })); + let input: Arc>>> = + Arc::new(StdMutex::new(Some(Box::new(NoopInput)))); + let credentials = Arc::new(StdMutex::new(Some(Credentials { + username: "alice".to_owned(), + password: "secret".to_owned(), + domain: None, + }))); + let connection = async { + let _binding = BoundConnectionGuard { + display: Arc::clone(&display), + input: Arc::clone(&input), + credentials: Arc::clone(&credentials), + }; + pending::<()>().await; + }; + assert!(tokio::time::timeout(Duration::ZERO, connection).await.is_err()); + assert!(display.lock().unwrap().display.is_none()); + assert!(input.lock().unwrap().is_none()); + assert!(credentials.lock().unwrap().is_none()); + } + + #[test] + fn a_cleared_connection_cannot_restore_an_old_display_lease() { + let display = Arc::new(StdMutex::new(BoundDisplayState { + display: Some(Box::new(PendingDisplay)), + ..BoundDisplayState::default() + })); + let lease = BoundDisplayLease::take(&display).unwrap(); + display.lock().unwrap().clear(); + drop(lease); + assert!(display.lock().unwrap().display.is_none()); + } + + #[tokio::test] + async fn run_connection_releases_the_static_channels() { + struct ResourceFactory(Arc); + impl StaticChannelFactory for ResourceFactory { + fn attach(&self, acceptor: &mut Acceptor) { + acceptor.attach_static_channel(ResourceChannel(Arc::clone(&self.0))); + } + } + + let released = Arc::new(AtomicBool::new(false)); + let mut server = RdpServer::builder() + .with_addr(([127, 0, 0, 1], 0)) + .with_no_security() + .with_no_input() + .with_no_display() + .with_static_channel_factory(Box::new(ResourceFactory(Arc::clone(&released)))) + .build(); + + // A stream that is already at EOF: the connection ends early, which + // is the path an embedder's accept loop sees when a client vanishes. + let (client, server_side) = tokio::io::duplex(64); + drop(client); + let _ = server.run_connection(server_side).await; + + assert!( + released.load(Ordering::Relaxed), + "the channel backends of a finished connection must be released, not held until the next client" + ); + } +} diff --git a/crates/ironrdp-testsuite-core/tests/server/acceptor.rs b/crates/ironrdp-testsuite-core/tests/server/acceptor.rs index e41c342fe8..800b1e6a8e 100644 --- a/crates/ironrdp-testsuite-core/tests/server/acceptor.rs +++ b/crates/ironrdp-testsuite-core/tests/server/acceptor.rs @@ -1,19 +1,39 @@ +use core::cell::RefCell; +use core::future::{Ready, ready}; use std::borrow::Cow; +use std::collections::VecDeque; +use std::io; +use std::rc::Rc; -use ironrdp_acceptor::{Acceptor, MultitransportSecurityRng}; +use ironrdp_acceptor::{ + Acceptor, ConnectionSetupHandler, MultitransportSecurityRng, ReceivedCredentials, accept_credssp_with, + accept_finalize_with, +}; +use ironrdp_async::bytes::BytesMut; +use ironrdp_async::{Framed, FramedRead, FramedWrite, NetworkClient, StreamWrapper}; +use ironrdp_connector::credssp::CredsspSequence; +use ironrdp_connector::sspi::credssp::TsRequest; +use ironrdp_connector::sspi::generator::{GeneratorState, NetworkRequest}; +use ironrdp_connector::{ConnectorError, ConnectorErrorExt as _, ConnectorResult}; use ironrdp_connector::{DesktopSize, Sequence as _, Written, encode_x224_packet}; use ironrdp_core::{WriteBuf, decode, encode_vec}; -use ironrdp_pdu::gcc::{ClientMessageChannelData, MultiTransportChannelData, MultiTransportFlags}; +use ironrdp_pdu::gcc::{ + ClientCoreOptionalData, ClientEarlyCapabilityFlags, ClientMessageChannelData, MultiTransportChannelData, + MultiTransportFlags, +}; use ironrdp_pdu::mcs::{self, ConnectInitial}; use ironrdp_pdu::nego::{self, SecurityProtocol}; -use ironrdp_pdu::rdp::client_info::CompressionType; +use ironrdp_pdu::rdp::ClientInfoPdu; +use ironrdp_pdu::rdp::client_info::{CompressionType, Credentials}; use ironrdp_pdu::rdp::finalization_messages::{ControlAction, ControlPdu, SynchronizePdu}; use ironrdp_pdu::rdp::headers::{ - BasicSecurityHeaderFlags, CompressionFlags, ShareControlHeader, ShareControlPdu, ShareDataHeader, ShareDataPdu, - StreamPriority, + BasicSecurityHeader, BasicSecurityHeaderFlags, CompressionFlags, ShareControlHeader, ShareControlPdu, + ShareDataHeader, ShareDataPdu, StreamPriority, }; use ironrdp_pdu::rdp::multitransport::{MultitransportRequestPdu, MultitransportResponsePdu, RequestedProtocol}; +use ironrdp_pdu::rdp::server_error_info::{ErrorInfo, ProtocolIndependentCode, ServerSetErrorInfoPdu}; use ironrdp_pdu::x224::{X224, X224Data}; +use ironrdp_testsuite_core::client_info::CLIENT_INFO_UNICODE; use ironrdp_testsuite_core::gcc::CLIENT_GCC_WITHOUT_OPTIONAL_FIELDS; use ironrdp_testsuite_core::rdp::{CLIENT_DEMAND_ACTIVE_PDU_BUFFER, CLIENT_INFO_PDU_BUFFER}; @@ -720,3 +740,365 @@ fn multitransport_not_offered_when_client_does_not_reciprocate() { assert!(matches!(written, Written::Nothing)); assert!(acceptor.multitransport_request().is_none()); } + +#[derive(Default)] +struct HandshakeStream { + incoming: VecDeque>, + outgoing: Rc>>>, + credssp_client: Option, +} + +impl StreamWrapper for HandshakeStream { + type InnerStream = Self; + + fn from_inner(stream: Self) -> Self { + stream + } + + fn into_inner(self) -> Self { + self + } + + fn get_inner(&self) -> &Self { + self + } + + fn get_inner_mut(&mut self) -> &mut Self { + self + } +} + +impl FramedRead for HandshakeStream { + type ReadFut<'read> = Ready>; + + fn read<'a>(&'a mut self, buf: &'a mut BytesMut) -> Self::ReadFut<'a> { + let chunk = self.incoming.pop_front().unwrap_or_default(); + buf.extend_from_slice(&chunk); + ready(Ok(chunk.len())) + } +} + +impl FramedWrite for HandshakeStream { + type WriteAllFut<'write> = Ready>; + + fn write_all<'a>(&'a mut self, buf: &'a [u8]) -> Self::WriteAllFut<'a> { + self.outgoing.borrow_mut().push(buf.to_vec()); + if let Some(client) = self.credssp_client.as_mut() + && buf.len() != 4 + { + let request = client.decode_server_message(buf).unwrap().unwrap(); + self.incoming.push_back(client_credssp_message(client, request)); + } + ready(Ok(())) + } +} + +fn client_credssp_message(client: &mut CredsspSequence, request: TsRequest) -> Vec { + let result = { + let mut generator = client.process_ts_request(request); + let GeneratorState::Completed(result) = generator.start() else { + panic!("NTLM should not make network requests"); + }; + result.unwrap() + }; + let mut buf = WriteBuf::new(); + client.handle_process_result(result, &mut buf).unwrap(); + buf.filled().to_vec() +} + +struct NoNetwork; + +impl NetworkClient for NoNetwork { + async fn send(&mut self, _: &NetworkRequest) -> ConnectorResult> { + Err(ConnectorError::general("unexpected network request during NTLM")) + } +} + +#[derive(Default)] +struct SetupHandler { + received: Vec, + reject_credentials: bool, + reject_capabilities: bool, + capability_calls: usize, +} + +#[async_trait::async_trait(?Send)] +impl ConnectionSetupHandler for SetupHandler { + async fn handle_credentials(&mut self, credentials: Option) -> ConnectorResult<()> { + self.received + .push(credentials.expect("handshake should supply credentials")); + if self.reject_credentials { + Err(ConnectorError::general("test credential rejection")) + } else { + Ok(()) + } + } + + async fn prepare_capability_exchange(&mut self, _: DesktopSize) -> ConnectorResult<()> { + self.capability_calls += 1; + if self.reject_capabilities { + Err(ConnectorError::general("test capability rejection")) + } else { + Ok(()) + } + } +} + +fn negotiated_acceptor(protocol: SecurityProtocol) -> Acceptor { + let mut acceptor = Acceptor::new( + protocol, + DesktopSize { + width: 1024, + height: 768, + }, + Vec::new(), + protocol + .intersects(SecurityProtocol::HYBRID | SecurityProtocol::HYBRID_EX) + .then(|| Credentials { + username: "alice".to_owned(), + password: "secret".to_owned(), + domain: None, + }), + ); + acceptor + .step(&encode_connection_request(protocol), None, &mut WriteBuf::new()) + .unwrap(); + acceptor.step(&[], None, &mut WriteBuf::new()).unwrap(); + acceptor.mark_security_upgrade_as_done(); + acceptor +} + +fn post_credssp_messages(error_info: Option) -> VecDeque> { + let mut gcc = CLIENT_GCC_WITHOUT_OPTIONAL_FIELDS.clone(); + gcc.network = None; + gcc.core.optional_data = ClientCoreOptionalData { + post_beta2_color_depth: gcc.core.optional_data.post_beta2_color_depth, + client_product_id: gcc.core.optional_data.client_product_id, + serial_number: gcc.core.optional_data.serial_number, + high_color_depth: gcc.core.optional_data.high_color_depth, + supported_color_depths: gcc.core.optional_data.supported_color_depths, + early_capability_flags: error_info, + ..ClientCoreOptionalData::default() + }; + let mut initial = WriteBuf::new(); + encode_x224_packet(&ConnectInitial::with_gcc_blocks(gcc).unwrap(), &mut initial).unwrap(); + + let info = ClientInfoPdu { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::INFO_PKT, + }, + client_info: CLIENT_INFO_UNICODE.clone(), + }; + [ + initial.filled().to_vec(), + encode_vec(&X224(mcs::ErectDomainPdu { + sub_height: 0, + sub_interval: 0, + })) + .unwrap(), + encode_vec(&X224(mcs::AttachUserRequest)).unwrap(), + encode_vec(&X224(mcs::ChannelJoinRequest { + initiator_id: 1002, + channel_id: 1002, + })) + .unwrap(), + encode_vec(&X224(mcs::ChannelJoinRequest { + initiator_id: 1002, + channel_id: 1003, + })) + .unwrap(), + encode_vec(&X224(mcs::SendDataRequest { + initiator_id: 1002, + channel_id: 1003, + user_data: encode_vec(&info).unwrap().into(), + })) + .unwrap(), + ] + .into() +} + +fn assert_access_denied(bytes: &[u8]) { + let data = decode::>>(bytes).unwrap().0; + assert_eq!(data.initiator_id, 1002); + assert_eq!(data.channel_id, 1003); + assert_eq!(data.user_data.len(), 22); + let header = decode::(data.user_data.as_ref()).unwrap(); + assert_eq!(header.pdu_source, 0); + let ShareControlPdu::Data(data) = header.share_control_pdu else { + panic!("expected a Share Data PDU"); + }; + assert!(data.compression_flags.is_empty()); + assert_eq!( + data.share_data_pdu, + ShareDataPdu::ServerSetErrorInfo(ServerSetErrorInfoPdu(ErrorInfo::ProtocolIndependentCode( + ProtocolIndependentCode::ServerInsufficientPrivileges, + ))) + ); +} + +#[rstest::rstest] +#[case(None)] +#[case(Some(ClientEarlyCapabilityFlags::empty()))] +#[case(Some(ClientEarlyCapabilityFlags::SUPPORT_ERR_INFO_PDU))] +#[tokio::test] +async fn tls_hook_rejection_respects_error_info_support( + #[case] error_info: Option, + #[values(false, true)] reject_credentials: bool, +) { + let mut acceptor = negotiated_acceptor(SecurityProtocol::SSL); + let mut handler = SetupHandler { + reject_credentials, + reject_capabilities: !reject_credentials, + ..SetupHandler::default() + }; + let framed = Framed::::new(HandshakeStream { + incoming: post_credssp_messages(error_info), + ..HandshakeStream::default() + }); + let outgoing = Rc::clone(&framed.get_inner().0.outgoing); + let result = accept_finalize_with(framed, &mut acceptor, &mut handler).await; + let error = result.err().expect("hook rejection should fail the handshake"); + assert!(error.to_string().contains(if reject_credentials { + "test credential rejection" + } else { + "test capability rejection" + })); + assert_eq!(handler.received.len(), 1); + assert_eq!( + handler.received[0].origin, + ironrdp_acceptor::CredentialOrigin::ClientInfo + ); + assert_eq!(handler.capability_calls, usize::from(!reject_credentials)); + + let outgoing = outgoing.borrow(); + let supports_error_info = + error_info.is_some_and(|flags| flags.contains(ClientEarlyCapabilityFlags::SUPPORT_ERR_INFO_PDU)); + assert_eq!( + outgoing.len(), + 4 + usize::from(!reject_credentials) + usize::from(supports_error_info) + ); + assert_eq!( + decode::>(&outgoing[3]) + .unwrap() + .0 + .channel_id, + 1003 + ); + let mut denied = WriteBuf::new(); + let written = acceptor.encode_access_denied(&mut denied).unwrap(); + if supports_error_info { + assert_eq!(written, denied.filled_len()); + assert_access_denied(denied.filled()); + assert_eq!(outgoing.last().unwrap(), denied.filled()); + } else { + assert_eq!(written, 0); + assert!(denied.filled().is_empty()); + } +} + +#[rstest::rstest] +#[case(SecurityProtocol::HYBRID, None)] +#[case(SecurityProtocol::HYBRID, Some(ClientEarlyCapabilityFlags::SUPPORT_ERR_INFO_PDU))] +#[case(SecurityProtocol::HYBRID_EX, None)] +#[tokio::test] +async fn credssp_authorization_uses_selected_protocol( + #[case] protocol: SecurityProtocol, + #[case] error_info: Option, + #[values("alice@corp.example.com", "CORP\\alice", "alice")] username: &str, + #[values(false, true)] reject_credentials: bool, +) { + let mut acceptor = negotiated_acceptor(protocol); + let public_key = vec![0x42; 32]; + let (mut client, request) = CredsspSequence::init( + ironrdp_connector::Credentials::UsernamePassword { + username: username.to_owned(), + password: "secret".to_owned(), + }, + None, + protocol, + "server.example.com".to_owned().into(), + public_key.clone(), + None, + ) + .unwrap(); + let first_message = client_credssp_message(&mut client, request); + let mut framed = Framed::::new(HandshakeStream { + incoming: [first_message].into(), + credssp_client: Some(client), + ..HandshakeStream::default() + }); + let outgoing = Rc::clone(&framed.get_inner().0.outgoing); + let mut handler = SetupHandler { + reject_credentials, + reject_capabilities: !reject_credentials, + ..SetupHandler::default() + }; + let result = accept_credssp_with( + &mut framed, + &mut acceptor, + &mut NoNetwork, + "client.example.com".to_owned().into(), + public_key, + None, + &mut handler, + ) + .await; + + if protocol == SecurityProtocol::HYBRID_EX && reject_credentials { + assert!(result.unwrap_err().to_string().contains("test credential rejection")); + assert_eq!(outgoing.borrow().last().unwrap(), &5u32.to_le_bytes()); + } else { + result.unwrap(); + if protocol == SecurityProtocol::HYBRID { + assert!(handler.received.is_empty(), "plain HYBRID must defer authorization"); + } else { + assert_eq!(handler.received.len(), 1); + assert_eq!(outgoing.borrow().last().unwrap(), &0u32.to_le_bytes()); + } + let credssp_write_count = outgoing.borrow().len(); + let (stream, _) = framed.get_inner_mut(); + stream.credssp_client = None; + stream.incoming = post_credssp_messages(error_info); + let error = accept_finalize_with(framed, &mut acceptor, &mut handler) + .await + .err() + .expect("hook rejection should fail the handshake"); + assert!(error.to_string().contains(if reject_credentials { + "test credential rejection" + } else { + "test capability rejection" + })); + let outgoing = outgoing.borrow(); + let handshake_writes = &outgoing[credssp_write_count..]; + let supports_error_info = + error_info.is_some_and(|flags| flags.contains(ClientEarlyCapabilityFlags::SUPPORT_ERR_INFO_PDU)); + assert_eq!( + handshake_writes.len(), + 4 + usize::from(!reject_credentials) + usize::from(supports_error_info) + ); + assert_eq!( + decode::>(&handshake_writes[3]) + .unwrap() + .0 + .channel_id, + 1003 + ); + if supports_error_info { + assert_access_denied(handshake_writes.last().unwrap()); + } + } + + assert_eq!(handler.received.len(), 1); + assert_eq!( + handler.received[0].origin, + ironrdp_acceptor::CredentialOrigin::CredSspDelegated + ); + let (expected_username, expected_domain) = match username { + "CORP\\alice" => ("alice", Some("CORP")), + _ => (username, None), + }; + assert_eq!(handler.received[0].credentials.username, expected_username); + assert_eq!(handler.received[0].credentials.domain.as_deref(), expected_domain); + assert_eq!(handler.received[0].credentials.password, "secret"); + assert_eq!(handler.capability_calls, usize::from(!reject_credentials)); +} diff --git a/crates/ironrdp-testsuite-core/tests/server/connection_policy.rs b/crates/ironrdp-testsuite-core/tests/server/connection_policy.rs index 32c1ebbbc6..673e268c33 100644 --- a/crates/ironrdp-testsuite-core/tests/server/connection_policy.rs +++ b/crates/ironrdp-testsuite-core/tests/server/connection_policy.rs @@ -45,6 +45,14 @@ fn build(policy: ConnectionPolicy) -> RdpServer { .build() } +#[test] +fn accept_loop_future_stays_below_clippy_large_future_limit() { + let mut server = build(ConnectionPolicy::Queue); + let future = server.run(); + let size = size_of_val(&future); + assert!(size < 16 * 1024, "accept-loop future is {size} bytes"); +} + /// With `Reject`, a second connection arriving while a session runs is closed /// immediately: the read returns EOF rather than hanging. #[tokio::test] diff --git a/crates/ironrdp-testsuite-core/tests/server/credential_validator.rs b/crates/ironrdp-testsuite-core/tests/server/credential_validator.rs index 4b4dd280a3..c6c1226a9d 100644 --- a/crates/ironrdp-testsuite-core/tests/server/credential_validator.rs +++ b/crates/ironrdp-testsuite-core/tests/server/credential_validator.rs @@ -2,7 +2,9 @@ use core::fmt; use std::sync::Arc; use async_trait::async_trait; -use ironrdp_server::{CredentialDecision, CredentialValidationError, CredentialValidator, Credentials}; +use ironrdp_server::{ + CredentialDecision, CredentialOrigin, CredentialValidationError, CredentialValidator, Credentials, +}; fn fixed_creds() -> Credentials { Credentials { @@ -45,6 +47,26 @@ impl CredentialValidator for AlwaysBackendError { } } +struct CredSspOnly; +#[async_trait] +impl CredentialValidator for CredSspOnly { + async fn validate(&self, _: &Credentials) -> Result { + Ok(CredentialDecision::Reject) + } + + async fn validate_received( + &self, + _: &Credentials, + origin: CredentialOrigin, + ) -> Result { + Ok(if origin == CredentialOrigin::CredSspDelegated { + CredentialDecision::Accept + } else { + CredentialDecision::Reject + }) + } +} + #[tokio::test] async fn validator_accept_returns_accept() { let v = AlwaysAccept; @@ -72,3 +94,20 @@ async fn validator_can_be_held_behind_arc_dyn() { let v: Arc = Arc::new(AlwaysAccept); assert_eq!(v.validate(&fixed_creds()).await.unwrap(), CredentialDecision::Accept); } + +#[tokio::test] +async fn validator_can_override_origin_aware_policy() { + let v = CredSspOnly; + assert_eq!( + v.validate_received(&fixed_creds(), CredentialOrigin::ClientInfo) + .await + .unwrap(), + CredentialDecision::Reject + ); + assert_eq!( + v.validate_received(&fixed_creds(), CredentialOrigin::CredSspDelegated) + .await + .unwrap(), + CredentialDecision::Accept + ); +}