From 0a60eae85a8599a8f0683a506fdd6b6913517909 Mon Sep 17 00:00:00 2001 From: jeremie Date: Fri, 25 Sep 2026 19:52:48 +0000 Subject: [PATCH 1/4] expose enable_standard_rdp_security in the web-client (brought by https://github.com/Devolutions/IronRDP/pull/1580) to the web client --- crates/ironrdp-web/src/session.rs | 124 ++++++++++++++---- web-client/iron-remote-desktop-rdp/README.md | 19 +++ .../iron-remote-desktop-rdp/src/main.ts | 11 ++ 3 files changed, 125 insertions(+), 29 deletions(-) diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index a47bd3d0c0..eb49eb14c8 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -52,6 +52,12 @@ use crate::printer::{JsPrinterStreamCallbacks, WasmPrinter, WasmPrinterBackend, const DEFAULT_WIDTH: u16 = 1280; const DEFAULT_HEIGHT: u16 = 720; +#[derive(Clone, Copy)] +struct SecurityConfig { + enable_credssp: bool, + enable_standard_rdp_security: bool, +} + #[derive(Clone, Default)] pub(crate) struct SessionBuilder(Rc>); @@ -91,6 +97,7 @@ struct SessionBuilderInner { use_display_control: bool, enable_credssp: bool, + enable_standard_rdp_security: bool, enable_server_pointer: bool, legacy_graphics: bool, outbound_message_size_limit: Option, @@ -135,6 +142,7 @@ impl Default for SessionBuilderInner { use_display_control: false, enable_credssp: true, + enable_standard_rdp_security: false, enable_server_pointer: true, legacy_graphics: false, outbound_message_size_limit: None, @@ -255,6 +263,9 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { |kdc_proxy_url: String| { self.0.borrow_mut().kdc_proxy_url = Some(kdc_proxy_url) }; |display_control: bool| { self.0.borrow_mut().use_display_control = display_control }; |enable_credssp: bool| { self.0.borrow_mut().enable_credssp = enable_credssp }; + |enable_standard_rdp_security: bool| { + self.0.borrow_mut().enable_standard_rdp_security = enable_standard_rdp_security; + }; |enable_server_pointer: bool| { self.0.borrow_mut().enable_server_pointer = enable_server_pointer }; |legacy_graphics: bool| { self.0.borrow_mut().legacy_graphics = legacy_graphics }; |outbound_message_size_limit: f64| { @@ -409,10 +420,19 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { legacy_graphics = inner.legacy_graphics; } + let security = SecurityConfig { + enable_credssp: self.0.borrow().enable_credssp, + enable_standard_rdp_security: self.0.borrow().enable_standard_rdp_security, + }; + if pcb.is_some() && vmconnect.is_some() { return Err(anyhow::Error::msg("generic preconnection blob and VMConnect are mutually exclusive").into()); } + if security.enable_standard_rdp_security && vmconnect.is_some() { + return Err(anyhow::Error::msg("standard RDP security and VMConnect are mutually exclusive").into()); + } + info!("Connect to RDP host"); let mut config = build_config( @@ -422,11 +442,9 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { client_name.clone(), desktop_size, legacy_graphics, + security, ); - let enable_credssp = self.0.borrow().enable_credssp; - config.enable_credssp = enable_credssp; - let enable_server_pointer = self.0.borrow().enable_server_pointer; config.enable_server_pointer = enable_server_pointer; @@ -1459,6 +1477,7 @@ fn build_config( client_name: String, desktop_size: DesktopSize, legacy_graphics: bool, + security: SecurityConfig, ) -> connector::Config { // Win7-class servers need 32-bpp lossless bitmaps and no advertised codecs. let bitmap = if legacy_graphics { @@ -1478,10 +1497,9 @@ fn build_config( connector::Config { credentials: Credentials::UsernamePassword { username, password }, domain, - // TODO(#327): expose these options from the WASM module. - enable_tls: true, - enable_credssp: true, - enable_standard_rdp_security: false, + enable_tls: !security.enable_standard_rdp_security, + enable_credssp: security.enable_credssp && !security.enable_standard_rdp_security, + enable_standard_rdp_security: security.enable_standard_rdp_security, keyboard_type: ironrdp::pdu::gcc::KeyboardType::IBM_ENHANCED, keyboard_subtype: 0, keyboard_layout: 0, // the server SHOULD use the default active input locale identifier @@ -1872,34 +1890,50 @@ where } }; - let server_cert = server_cert_chain - .into_iter() - .next() - .context("server cert chain missing from rdcleanpath response")?; + let standard_rdp_security = if let Some(x224_connection_response) = x224_connection_response.as_ref() { + let connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state else { + return Err(anyhow::Error::msg("invalid connector state (wait confirm)").into()); + }; - let cert = x509_cert::Certificate::from_der(server_cert.as_bytes()) - .context("failed to decode x509 certificate sent by proxy")?; + debug_assert!(connector.next_pdu_hint().is_some()); - let server_public_key = cert - .tbs_certificate() - .subject_public_key_info() - .subject_public_key - .as_bytes() - .context("subject public key BIT STRING is not aligned")? - .to_owned(); + buf.clear(); + let written = connector.step(x224_connection_response.as_bytes(), None, &mut buf)?; + debug_assert!(written.is_nothing()); - let upgraded = match x224_connection_response { - Some(x224_connection_response) => { - let connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state else { - return Err(anyhow::Error::msg("invalid connector state (wait confirm)").into()); - }; + let connector::ClientConnectorState::EnhancedSecurityUpgrade { selected_protocol } = &connector.state + else { + return Err(anyhow::Error::msg("invalid connector state (security upgrade)").into()); + }; - debug_assert!(connector.next_pdu_hint().is_some()); + selected_protocol.is_standard_rdp_security() + } else { + false + }; - buf.clear(); - let written = connector.step(x224_connection_response.as_bytes(), None, &mut buf)?; - debug_assert!(written.is_nothing()); + let server_public_key = if standard_rdp_security { + // The proxy certificate is used to bind CredSSP to the TLS server. Standard RDP + // Security with ENCRYPTION_LEVEL_NONE performs neither exchange. + Vec::new() + } else { + let server_cert = server_cert_chain + .into_iter() + .next() + .context("server cert chain missing from rdcleanpath response")?; + + let cert = x509_cert::Certificate::from_der(server_cert.as_bytes()) + .context("failed to decode x509 certificate sent by proxy")?; + + cert.tbs_certificate() + .subject_public_key_info() + .subject_public_key + .as_bytes() + .context("subject public key BIT STRING is not aligned")? + .to_owned() + }; + let upgraded = match x224_connection_response { + Some(_) => { let should_upgrade = ironrdp_futures::skip_connect_begin(connector); ironrdp_futures::mark_as_upgraded(should_upgrade, connector) } @@ -1977,6 +2011,38 @@ mod tests { ); } + #[test] + fn standard_rdp_security_overrides_enhanced_security() { + for (case, enable_credssp, enable_standard_rdp_security, expected_tls, expected_credssp) in [ + ("secure defaults", true, false, true, true), + ("credssp disabled", false, false, true, false), + ("standard RDP security", true, true, false, false), + ] { + let config = build_config( + String::new(), + String::new(), + None, + "ironrdp-web".to_owned(), + DesktopSize { + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + }, + false, + SecurityConfig { + enable_credssp, + enable_standard_rdp_security, + }, + ); + + assert_eq!(config.enable_tls, expected_tls, "{case}"); + assert_eq!(config.enable_credssp, expected_credssp, "{case}"); + assert_eq!( + config.enable_standard_rdp_security, enable_standard_rdp_security, + "{case}" + ); + } + } + #[test] fn test_request_file_contents_parameter_marshalling() { let (tx, mut rx) = create_test_input_channel(); diff --git a/web-client/iron-remote-desktop-rdp/README.md b/web-client/iron-remote-desktop-rdp/README.md index 4a12b908df..6d523a158d 100644 --- a/web-client/iron-remote-desktop-rdp/README.md +++ b/web-client/iron-remote-desktop-rdp/README.md @@ -22,6 +22,25 @@ Otherwise, you can run `npm install` targeting the `dist/` folder directly. Import the `iron-remote-desktop-rdp.umd.cjs` from `node_modules/` folder. +## Standard RDP Security + +`enableStandardRdpSecurity(true)` opts into `PROTOCOL_RDP` with +`ENCRYPTION_LEVEL_NONE` and disables TLS and CredSSP negotiation. This is +intended only for trusted RDCleanPath deployments where the WebSocket and the +proxy-to-server transport already provide the required security. + +```ts +import { ConfigBuilder } from '@devolutions/iron-remote-desktop'; +import { enableStandardRdpSecurity } from '@devolutions/iron-remote-desktop-rdp'; + +const config = new ConfigBuilder() + .withDestination('localhost:3389') + .withProxyAddress('wss://example.test/rdp') + .withAuthToken('token') + .withExtension(enableStandardRdpSecurity(true)) + .build(); +``` + ## Virtual Printer Register `printJobStreamCallbacks` before connecting to enable the browser-side diff --git a/web-client/iron-remote-desktop-rdp/src/main.ts b/web-client/iron-remote-desktop-rdp/src/main.ts index acd28b2e96..ad005cf917 100644 --- a/web-client/iron-remote-desktop-rdp/src/main.ts +++ b/web-client/iron-remote-desktop-rdp/src/main.ts @@ -57,6 +57,17 @@ export function enableCredssp(enable: boolean): Extension { return new Extension('enable_credssp', enable); } +/** + * Allow unencrypted Standard RDP Security over a trusted RDCleanPath transport. + * + * Enabling this disables TLS and CredSSP protocol negotiation. Use it only when + * the WebSocket and proxy-to-server transports already provide the required + * confidentiality and peer authentication. + */ +export function enableStandardRdpSecurity(enable: boolean): Extension { + return new Extension('enable_standard_rdp_security', enable); +} + export function enableServerPointer(enable: boolean): Extension { return new Extension('enable_server_pointer', enable); } From 949ab6694e4a19febadb71a819cf5665aff97188 Mon Sep 17 00:00:00 2001 From: jeremie Date: Fri, 25 Sep 2026 21:43:22 +0000 Subject: [PATCH 2/4] add tests to validate certificate handling (or lack thereof) based on set security --- crates/ironrdp-web/src/session.rs | 138 ++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index eb49eb14c8..5b56ef3199 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -1961,6 +1961,10 @@ fn f64_to_u16_saturating_cast(value: f64) -> u16 { #[cfg(test)] mod tests { + use futures_util::io::Cursor; + use iron_remote_desktop::IronError as _; + use ironrdp_futures::bytes::BytesMut; + use super::*; // Test helpers @@ -1971,6 +1975,58 @@ mod tests { mpsc::unbounded() } + fn rdcleanpath_framed(response: &[u8]) -> ironrdp_futures::LocalFuturesFramed>> { + ironrdp_futures::LocalFuturesFramed::new_with_leftover(Cursor::new(Vec::new()), BytesMut::from(response)) + } + + fn connector_config(enable_standard_rdp_security: bool) -> connector::Config { + build_config( + String::new(), + String::new(), + None, + "ironrdp-web-test".to_owned(), + DesktopSize { + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + }, + false, + SecurityConfig { + enable_credssp: false, + enable_standard_rdp_security, + }, + ) + } + + fn rdcleanpath_response(protocol: ironrdp::pdu::nego::SecurityProtocol) -> Vec { + let x224_response = ironrdp_core::encode_vec(&ironrdp::pdu::x224::X224( + ironrdp::pdu::nego::ConnectionConfirm::Response { + flags: ironrdp::pdu::nego::ResponseFlags::empty(), + protocol, + }, + )) + .expect("encode X.224 connection response"); + + ironrdp_rdcleanpath::RDCleanPathPdu::new_response( + "127.0.0.1".to_owned(), + x224_response, + std::iter::empty::>(), + ) + .expect("build RDCleanPath response") + .to_der() + .expect("encode RDCleanPath response") + } + + fn rdcleanpath_connect_params() -> RDCleanPathConnectParams { + RDCleanPathConnectParams { + server_name: connector::ServerName::from("rdp.example.test"), + destination: "rdp.example.test".to_owned(), + proxy_auth_token: "test-token".to_owned(), + pcb: None, + vmconnect: None, + kerberos_config: None, + } + } + #[test] fn printer_driver_defaults_to_postscript_when_macos_version_is_unknown() { assert_eq!( @@ -2043,6 +2099,88 @@ mod tests { } } + #[test] + fn standard_rdp_security_accepts_empty_rdcleanpath_certificate_chain() { + let response = rdcleanpath_response(ironrdp::pdu::nego::SecurityProtocol::empty()); + let mut framed = rdcleanpath_framed(&response); + let mut connector = ClientConnector::new( + connector_config(true), + core::net::SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 33899)), + ); + let mut network_client = WasmNetworkClient; + + let (upgraded, server_public_key) = connect_rdcleanpath( + &mut framed, + &mut connector, + &mut network_client, + rdcleanpath_connect_params(), + ) + .now_or_never() + .expect("in-memory RDCleanPath I/O should be ready") + .unwrap_or_else(|error| panic!("Standard RDP Security negotiation failed: {}", error.backtrace())); + + assert!(server_public_key.is_empty()); + let request_len = framed.get_inner().0.get_ref().len(); + assert_ne!(request_len, 0, "RDCleanPath request was sent"); + + // The fixture intentionally has no MCS response. Reaching EOF only after a second + // write proves finalization advanced past the no-op security upgrade and sent the + // client's MCS Connect Initial without trying to perform TLS first. + let finalize_result = ironrdp_futures::connect_finalize( + upgraded, + connector, + &mut framed, + &mut network_client, + connector::ServerName::from("rdp.example.test"), + server_public_key, + None, + ) + .now_or_never() + .expect("in-memory finalization I/O should be ready"); + + assert!( + finalize_result.is_err(), + "fixture should end while awaiting the MCS response" + ); + let final_len = framed.get_inner().0.get_ref().len(); + assert!( + final_len > request_len, + "expected finalization to write more data: {final_len} <= {request_len}" + ); + } + + #[test] + fn enhanced_security_rejects_empty_rdcleanpath_certificate_chain() { + let response = rdcleanpath_response(ironrdp::pdu::nego::SecurityProtocol::SSL); + let mut framed = rdcleanpath_framed(&response); + let mut connector = ClientConnector::new( + connector_config(false), + core::net::SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 33899)), + ); + let mut network_client = WasmNetworkClient; + + let error = match connect_rdcleanpath( + &mut framed, + &mut connector, + &mut network_client, + rdcleanpath_connect_params(), + ) + .now_or_never() + .expect("in-memory RDCleanPath I/O should be ready") + { + Ok(_) => panic!("enhanced security accepted a missing proxy certificate"), + Err(error) => error, + }; + + assert!( + error + .backtrace() + .contains("server cert chain missing from rdcleanpath response"), + "unexpected error: {}", + error.backtrace() + ); + } + #[test] fn test_request_file_contents_parameter_marshalling() { let (tx, mut rx) = create_test_input_channel(); From 227160414d392538c4154709c03c4c5e5848ec82 Mon Sep 17 00:00:00 2001 From: jeremie Date: Mon, 28 Sep 2026 13:37:07 +0000 Subject: [PATCH 3/4] fix linter error (std::iter -> core::iter) --- crates/ironrdp-web/src/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index 5b56ef3199..ebdda14179 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -2009,7 +2009,7 @@ mod tests { ironrdp_rdcleanpath::RDCleanPathPdu::new_response( "127.0.0.1".to_owned(), x224_response, - std::iter::empty::>(), + core::iter::empty::>(), ) .expect("build RDCleanPath response") .to_der() From 4cee32c7eb7ece23881c9c311d792e75b29929bf Mon Sep 17 00:00:00 2001 From: jeremie Date: Tue, 6 Oct 2026 14:31:51 +0000 Subject: [PATCH 4/4] cleanup/code compression --- crates/ironrdp-web/src/session.rs | 50 ++++++++++++------------------- 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index ebdda14179..1feb83877e 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -375,6 +375,7 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { printer_driver_name, outbound_message_size_limit, legacy_graphics, + security, ); { @@ -418,13 +419,12 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { printer_driver_name = inner.printer_driver_name.clone(); outbound_message_size_limit = inner.outbound_message_size_limit; legacy_graphics = inner.legacy_graphics; + security = SecurityConfig { + enable_credssp: inner.enable_credssp, + enable_standard_rdp_security: inner.enable_standard_rdp_security, + }; } - let security = SecurityConfig { - enable_credssp: self.0.borrow().enable_credssp, - enable_standard_rdp_security: self.0.borrow().enable_standard_rdp_security, - }; - if pcb.is_some() && vmconnect.is_some() { return Err(anyhow::Error::msg("generic preconnection blob and VMConnect are mutually exclusive").into()); } @@ -1979,7 +1979,7 @@ mod tests { ironrdp_futures::LocalFuturesFramed::new_with_leftover(Cursor::new(Vec::new()), BytesMut::from(response)) } - fn connector_config(enable_standard_rdp_security: bool) -> connector::Config { + fn connector_config(security: SecurityConfig) -> connector::Config { build_config( String::new(), String::new(), @@ -1990,10 +1990,7 @@ mod tests { height: DEFAULT_HEIGHT, }, false, - SecurityConfig { - enable_credssp: false, - enable_standard_rdp_security, - }, + security, ) } @@ -2074,28 +2071,13 @@ mod tests { ("credssp disabled", false, false, true, false), ("standard RDP security", true, true, false, false), ] { - let config = build_config( - String::new(), - String::new(), - None, - "ironrdp-web".to_owned(), - DesktopSize { - width: DEFAULT_WIDTH, - height: DEFAULT_HEIGHT, - }, - false, - SecurityConfig { - enable_credssp, - enable_standard_rdp_security, - }, - ); + let config = connector_config(SecurityConfig { + enable_credssp, + enable_standard_rdp_security, + }); assert_eq!(config.enable_tls, expected_tls, "{case}"); assert_eq!(config.enable_credssp, expected_credssp, "{case}"); - assert_eq!( - config.enable_standard_rdp_security, enable_standard_rdp_security, - "{case}" - ); } } @@ -2104,7 +2086,10 @@ mod tests { let response = rdcleanpath_response(ironrdp::pdu::nego::SecurityProtocol::empty()); let mut framed = rdcleanpath_framed(&response); let mut connector = ClientConnector::new( - connector_config(true), + connector_config(SecurityConfig { + enable_credssp: false, + enable_standard_rdp_security: true, + }), core::net::SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 33899)), ); let mut network_client = WasmNetworkClient; @@ -2154,7 +2139,10 @@ mod tests { let response = rdcleanpath_response(ironrdp::pdu::nego::SecurityProtocol::SSL); let mut framed = rdcleanpath_framed(&response); let mut connector = ClientConnector::new( - connector_config(false), + connector_config(SecurityConfig { + enable_credssp: false, + enable_standard_rdp_security: false, + }), core::net::SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 33899)), ); let mut network_client = WasmNetworkClient;