Skip to content

Commit 01a6ab7

Browse files
committed
feat(dgw): add RDCleanPath credential injection via CredSSP MITM
- Thread credential_store through RDCleanPath API handlers - Add handle_with_credential_injection function for CredSSP flow - Expose helper functions for CredSSP and TLS key extraction - Enable early credential detection in WebSocket RDCleanPath path
1 parent b2f5172 commit 01a6ab7

File tree

5 files changed

+321
-7
lines changed

5 files changed

+321
-7
lines changed

clippy_fixes.patch

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
--- a/devolutions-gateway/src/rd_clean_path.rs
2+
+++ b/devolutions-gateway/src/rd_clean_path.rs
3+
@@ -166,6 +166,7 @@ async fn send_clean_path_response(
4+
Ok(())
5+
}
6+
7+
+#[allow(clippy::too_many_arguments)]
8+
async fn process_cleanpath(
9+
cleanpath_pdu: RDCleanPathPdu,
10+
client_addr: SocketAddr,
11+
@@ -360,7 +361,7 @@ async fn handle_with_credential_injection(
12+
info!("RDCleanPath response sent, now performing CredSSP MITM");
13+
14+
// Get TLS configuration for CredSSP
15+
- let tls_conf = conf.tls.as_ref().context("TLS required for credential injection")?;
16+
+ conf.tls.as_ref().context("TLS required for credential injection")?;
17+
18+
// Get credential mapping
19+
let credential_mapping = _credential_entry.mapping.as_ref().context("no credential mapping")?;
20+
@@ -478,9 +479,10 @@ pub async fn handle(
21+
.as_deref()
22+
.ok_or_else(|| anyhow::anyhow!("missing token in RDCleanPath PDU"))?;
23+
24+
- if let Ok(token_id) = crate::token::extract_jti(token) {
25+
- if let Some(entry) = credential_store.get(token_id) {
26+
- if entry.mapping.is_some() {
27+
+ if let Some(entry) = crate::token::extract_jti(token)
28+
+ .ok()
29+
+ .and_then(|token_id| credential_store.get(token_id))
30+
+ .filter(|entry| entry.mapping.is_some())
31+
+ {
32+
// Credentials found! Switch to RdpProxy for credential injection
33+
info!("Switching to RdpProxy for credential injection (WebSocket)");
34+
35+
@@ -496,9 +498,8 @@ pub async fn handle(
36+
entry,
37+
)
38+
.await;
39+
- }
40+
- }
41+
}
42+
43+
trace!("Processing RDCleanPath");

devolutions-gateway/src/api/rdp.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ pub async fn handler(
2525
subscriber_tx,
2626
recordings,
2727
shutdown_signal,
28+
credential_store,
2829
..
2930
}): State<DgwState>,
3031
ConnectInfo(source_addr): ConnectInfo<SocketAddr>,
@@ -44,6 +45,7 @@ pub async fn handler(
4445
subscriber_tx,
4546
recordings.active_recordings,
4647
source_addr,
48+
credential_store,
4749
)
4850
.instrument(span)
4951
});
@@ -62,6 +64,7 @@ async fn handle_socket(
6264
subscriber_tx: SubscriberSender,
6365
active_recordings: Arc<ActiveRecordings>,
6466
source_addr: SocketAddr,
67+
credential_store: crate::credential::CredentialStoreHandle,
6568
) {
6669
let (stream, close_handle) = crate::ws::handle(
6770
ws,
@@ -78,6 +81,7 @@ async fn handle_socket(
7881
sessions,
7982
subscriber_tx,
8083
&active_recordings,
84+
&credential_store,
8185
)
8286
.await;
8387

devolutions-gateway/src/rd_clean_path.rs

Lines changed: 214 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::net::SocketAddr;
33
use std::sync::Arc;
44

55
use crate::config::Conf;
6+
use crate::credential::CredentialStoreHandle;
67
use crate::proxy::Proxy;
78
use crate::recording::ActiveRecordings;
89
use crate::session::{ConnectionModeDetails, DisconnectInterest, DisconnectedInfo, SessionInfo, SessionMessageSender};
@@ -11,6 +12,7 @@ use crate::target_addr::TargetAddr;
1112
use crate::token::{AssociationTokenClaims, CurrentJrl, TokenCache, TokenError};
1213

1314
use anyhow::Context as _;
15+
use ironrdp_pdu::nego;
1416
use ironrdp_rdcleanpath::RDCleanPathPdu;
1517
use tap::prelude::*;
1618
use thiserror::Error;
@@ -164,6 +166,7 @@ struct CleanPathResult {
164166
x224_rsp: Vec<u8>,
165167
}
166168

169+
#[allow(clippy::too_many_arguments)]
167170
async fn process_cleanpath(
168171
cleanpath_pdu: RDCleanPathPdu,
169172
client_addr: SocketAddr,
@@ -172,6 +175,7 @@ async fn process_cleanpath(
172175
jrl: &CurrentJrl,
173176
active_recordings: &ActiveRecordings,
174177
sessions: &SessionMessageSender,
178+
_credential_store: &CredentialStoreHandle,
175179
) -> Result<CleanPathResult, CleanPathError> {
176180
use crate::utils;
177181

@@ -272,25 +276,231 @@ async fn process_cleanpath(
272276
})
273277
}
274278

279+
/// Handle RDP connection with credential injection via CredSSP MITM
280+
#[allow(clippy::too_many_arguments)]
281+
async fn handle_with_credential_injection(
282+
mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static,
283+
client_addr: SocketAddr,
284+
conf: Arc<Conf>,
285+
token_cache: &TokenCache,
286+
jrl: &CurrentJrl,
287+
sessions: SessionMessageSender,
288+
subscriber_tx: SubscriberSender,
289+
active_recordings: &ActiveRecordings,
290+
cleanpath_pdu: RDCleanPathPdu,
291+
_credential_entry: crate::credential::ArcCredentialEntry,
292+
) -> anyhow::Result<()> {
293+
let token = cleanpath_pdu.proxy_auth.as_ref().context("missing token")?;
294+
295+
// Authorize the token
296+
let claims = authorize(client_addr, token, &conf, token_cache, jrl, active_recordings, None)
297+
.map_err(|e| anyhow::anyhow!("authorization failed: {}", e))?;
298+
299+
let crate::token::ConnectionMode::Fwd { targets: _ } = claims.jet_cm else {
300+
anyhow::bail!("unexpected connection mode");
301+
};
302+
303+
let span = tracing::Span::current();
304+
span.record("session_id", claims.jet_aid.to_string());
305+
306+
info!("Credential injection: performing CredSSP MITM");
307+
308+
// Run normal RDCleanPath flow (this will handle server-side TLS and get certs)
309+
let CleanPathResult {
310+
destination,
311+
server_addr,
312+
server_stream,
313+
x224_rsp,
314+
..
315+
} = process_cleanpath(
316+
cleanpath_pdu,
317+
client_addr,
318+
&conf,
319+
token_cache,
320+
jrl,
321+
active_recordings,
322+
&sessions,
323+
&CredentialStoreHandle::new(), // Dummy, not used in process_cleanpath
324+
)
325+
.await
326+
.map_err(|e| anyhow::anyhow!("RDCleanPath processing failed: {}", e))?;
327+
328+
// Extract server security protocol from X224 response (before x224_rsp is moved)
329+
let x224_confirm: ironrdp_pdu::x224::X224<nego::ConnectionConfirm> =
330+
ironrdp_core::decode(&x224_rsp).context("decode X224 connection confirm")?;
331+
let server_security_protocol = match &x224_confirm.0 {
332+
nego::ConnectionConfirm::Response { protocol, .. } => {
333+
if !protocol.intersects(nego::SecurityProtocol::HYBRID | nego::SecurityProtocol::HYBRID_EX) {
334+
anyhow::bail!(
335+
"server selected security protocol {protocol}, which is not supported for credential injection"
336+
);
337+
}
338+
*protocol
339+
}
340+
nego::ConnectionConfirm::Failure { code } => {
341+
anyhow::bail!("RDP session initiation failed with code {code}");
342+
}
343+
};
344+
345+
// Send RDCleanPath response to client (includes server certs)
346+
let x509_chain = server_stream
347+
.get_ref()
348+
.1
349+
.peer_certificates()
350+
.context("no peer certificate found in TLS transport")?
351+
.iter()
352+
.map(|cert| cert.to_vec());
353+
354+
trace!("Sending RDCleanPath response");
355+
356+
let rdcleanpath_rsp = RDCleanPathPdu::new_response(server_addr.to_string(), x224_rsp, x509_chain)
357+
.map_err(|e| anyhow::anyhow!("couldn't build RDCleanPath response: {e}"))?;
358+
359+
send_clean_path_response(&mut client_stream, &rdcleanpath_rsp).await?;
360+
361+
info!("RDCleanPath response sent, now performing CredSSP MITM");
362+
363+
// Verify TLS is configured
364+
conf.tls.as_ref().context("TLS required for credential injection")?;
365+
366+
// Get credential mapping
367+
let credential_mapping = _credential_entry.mapping.as_ref().context("no credential mapping")?;
368+
369+
// Extract server public key from TLS stream
370+
let server_public_key =
371+
crate::rdp_proxy::extract_tls_server_public_key(&server_stream).context("extract server TLS public key")?;
372+
373+
// Wrap streams in TokioFramed for CredSSP
374+
let mut client_framed = ironrdp_tokio::TokioFramed::new(client_stream);
375+
let mut server_framed = ironrdp_tokio::TokioFramed::new(server_stream);
376+
377+
// Use HYBRID_EX for client (web clients typically use this)
378+
let client_security_protocol = nego::SecurityProtocol::HYBRID_EX;
379+
380+
// Perform CredSSP MITM (in parallel)
381+
// Note: Client expects server's public key (since we sent server certs in RDCleanPath response)
382+
let client_credssp_fut = crate::rdp_proxy::perform_credssp_with_client(
383+
&mut client_framed,
384+
client_addr.ip(),
385+
server_public_key.clone(),
386+
client_security_protocol,
387+
&credential_mapping.proxy,
388+
);
389+
390+
let server_credssp_fut = crate::rdp_proxy::perform_credssp_with_server(
391+
&mut server_framed,
392+
destination.host().to_owned(),
393+
server_public_key,
394+
server_security_protocol,
395+
&credential_mapping.target,
396+
);
397+
398+
let (client_res, server_res) = tokio::join!(client_credssp_fut, server_credssp_fut);
399+
client_res.context("CredSSP with client failed")?;
400+
server_res.context("CredSSP with server failed")?;
401+
402+
info!("CredSSP MITM completed successfully");
403+
404+
// Extract streams and any leftover bytes
405+
let (mut client_stream, client_leftover) = client_framed.into_inner();
406+
let (mut server_stream, server_leftover) = server_framed.into_inner();
407+
408+
// Forward any leftover bytes
409+
if !server_leftover.is_empty() {
410+
client_stream
411+
.write_all(&server_leftover)
412+
.await
413+
.context("write server leftover to client")?;
414+
}
415+
if !client_leftover.is_empty() {
416+
server_stream
417+
.write_all(&client_leftover)
418+
.await
419+
.context("write client leftover to server")?;
420+
}
421+
422+
info!("RDP-TLS forwarding (credential injection)");
423+
424+
// Build SessionInfo for forwarding
425+
let session_info = SessionInfo::builder()
426+
.id(claims.jet_aid)
427+
.application_protocol(claims.jet_ap)
428+
.details(ConnectionModeDetails::Fwd {
429+
destination_host: destination.clone(),
430+
})
431+
.time_to_live(claims.jet_ttl)
432+
.recording_policy(claims.jet_rec)
433+
.filtering_policy(claims.jet_flt)
434+
.build();
435+
436+
let disconnect_interest = DisconnectInterest::from_reconnection_policy(claims.jet_reuse);
437+
438+
// Plain forwarding for now
439+
Proxy::builder()
440+
.conf(conf)
441+
.session_info(session_info)
442+
.address_a(client_addr)
443+
.transport_a(client_stream)
444+
.address_b(server_addr)
445+
.transport_b(server_stream)
446+
.sessions(sessions)
447+
.subscriber_tx(subscriber_tx)
448+
.disconnect_interest(disconnect_interest)
449+
.build()
450+
.select_dissector_and_forward()
451+
.await
452+
.context("proxy failed")
453+
}
454+
275455
#[allow(clippy::too_many_arguments)]
276456
#[instrument("fwd", skip_all, fields(session_id = field::Empty, target = field::Empty))]
277457
pub async fn handle(
278-
mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send,
458+
mut client_stream: impl AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static,
279459
client_addr: SocketAddr,
280460
conf: Arc<Conf>,
281461
token_cache: &TokenCache,
282462
jrl: &CurrentJrl,
283463
sessions: SessionMessageSender,
284464
subscriber_tx: SubscriberSender,
285465
active_recordings: &ActiveRecordings,
466+
credential_store: &CredentialStoreHandle,
286467
) -> anyhow::Result<()> {
287468
// Special handshake of our RDP extension
288469

289470
trace!("Reading RDCleanPath");
290471

291472
let cleanpath_pdu = read_cleanpath_pdu(&mut client_stream)
292473
.await
293-
.context("couldn’t read clean cleanpath PDU")?;
474+
.context("couldn't read clean cleanpath PDU")?;
475+
476+
// Early credential detection: check if we should use RdpProxy instead
477+
let token = cleanpath_pdu
478+
.proxy_auth
479+
.as_deref()
480+
.ok_or_else(|| anyhow::anyhow!("missing token in RDCleanPath PDU"))?;
481+
482+
if let Some(entry) = crate::token::extract_jti(token)
483+
.ok()
484+
.and_then(|token_id| credential_store.get(token_id))
485+
.filter(|entry| entry.mapping.is_some())
486+
{
487+
// Credentials found! Switch to RdpProxy for credential injection
488+
info!("Switching to RdpProxy for credential injection (WebSocket)");
489+
490+
return handle_with_credential_injection(
491+
client_stream,
492+
client_addr,
493+
conf,
494+
token_cache,
495+
jrl,
496+
sessions,
497+
subscriber_tx,
498+
active_recordings,
499+
cleanpath_pdu,
500+
entry,
501+
)
502+
.await;
503+
}
294504

295505
trace!("Processing RDCleanPath");
296506

@@ -308,6 +518,7 @@ pub async fn handle(
308518
jrl,
309519
active_recordings,
310520
&sessions,
521+
credential_store,
311522
)
312523
.await
313524
{
@@ -537,3 +748,4 @@ impl From<&io::Error> for WsaError {
537748
}
538749
}
539750
}
751+

devolutions-gateway/src/rdp_proxy.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ where
327327
}
328328

329329
#[instrument(name = "server_credssp", level = "debug", ret, skip_all)]
330-
async fn perform_credssp_with_server<S>(
330+
pub async fn perform_credssp_with_server<S>(
331331
framed: &mut ironrdp_tokio::Framed<S>,
332332
server_name: String,
333333
server_public_key: Vec<u8>,
@@ -392,7 +392,7 @@ where
392392
}
393393

394394
#[instrument(name = "client_credssp", level = "debug", ret, skip_all)]
395-
async fn perform_credssp_with_client<S>(
395+
pub async fn perform_credssp_with_client<S>(
396396
framed: &mut ironrdp_tokio::Framed<S>,
397397
client_addr: IpAddr,
398398
gateway_public_key: Vec<u8>,
@@ -483,7 +483,7 @@ where
483483
}
484484
}
485485

486-
async fn get_cached_gateway_public_key(
486+
pub async fn get_cached_gateway_public_key(
487487
hostname: String,
488488
acceptor: tokio_rustls::TlsAcceptor,
489489
) -> anyhow::Result<Vec<u8>> {
@@ -533,7 +533,7 @@ async fn retrieve_gateway_public_key(hostname: String, acceptor: tokio_rustls::T
533533
Ok(public_key)
534534
}
535535

536-
fn extract_tls_server_public_key(tls_stream: &impl GetPeerCert) -> anyhow::Result<Vec<u8>> {
536+
pub fn extract_tls_server_public_key(tls_stream: &impl GetPeerCert) -> anyhow::Result<Vec<u8>> {
537537
use x509_cert::der::Decode as _;
538538

539539
let cert = tls_stream.get_peer_certificate().context("certificate is missing")?;
@@ -551,7 +551,7 @@ fn extract_tls_server_public_key(tls_stream: &impl GetPeerCert) -> anyhow::Resul
551551
Ok(server_public_key)
552552
}
553553

554-
trait GetPeerCert {
554+
pub trait GetPeerCert {
555555
fn get_peer_certificate(&self) -> Option<&tokio_rustls::rustls::pki_types::CertificateDer<'static>>;
556556
}
557557

0 commit comments

Comments
 (0)