feat(rdp): remote audio and a shared folder redirected as a drive

- Audio: the RDPSND channel with IronRDP's cpal playback backend (PCM;
  Opus left out to avoid another native library). On by default for new
  RDP profiles. Building now needs the ALSA headers on Linux
  (libasound2-dev), added to CI, release and fuzz jobs and the build docs.
- Shared folder (Linux/macOS): one local folder appears on the remote
  desktop as a drive. IronRDP's native backend joins server paths onto
  the share unchecked, so it is wrapped in ConfinedDrive: create, list
  and rename paths with '..' components, NUL bytes, or that resolve
  through a symlink outside the share are refused (ACCESS_DENIED);
  tests cover the escapes.

Verified against xrdp (with FUSE): the remote read the shared file, and
writes, mkdir and rename landed in the local folder. Not verified:
audio playback (the test container has no sound module), and xrdp lists
the drive as 'ignored' and can't list its directories (an IronRDP/xrdp
naming and listing mismatch, fixed upstream for the name).
This commit is contained in:
Andy Dixon
2026-10-12 18:07:49 +01:00
parent 6476ccff28
commit b798422735
15 changed files with 478 additions and 8 deletions
+2
View File
@@ -40,6 +40,8 @@ impl Callbacks {
pub mod proxy;
pub mod raw;
pub mod rdp;
#[cfg(unix)]
pub mod rdp_drive;
pub mod serial;
pub mod telnet;
pub mod vnc;
+15 -1
View File
@@ -48,6 +48,10 @@ pub struct Config {
pub height: u16,
/// Also allow plain TLS (graphical logon) for servers without NLA, such as xrdp.
pub allow_tls_only: bool,
/// Play the remote desktop's sound locally.
pub audio: bool,
/// A local folder to share as a drive (Unix only), and the name it appears under.
pub shared_folder: Option<(std::path::PathBuf, String)>,
pub dial: Dial,
pub check_cert: CertCheck,
pub connect_timeout: Duration,
@@ -165,7 +169,7 @@ impl Client {
request_data: None,
// Also hands the credentials to TLS-only servers (xrdp), which would otherwise show their own login screen.
autologon: !c.password.is_empty(),
enable_audio_playback: false,
enable_audio_playback: c.audio,
// ponytail: no bulk compression (the bitmap codecs already compress); enabling it means
// carrying the decompressor across reactivations.
compression_type: None,
@@ -189,6 +193,14 @@ impl Client {
let mut framed = Framed::new(tcp);
let drdynvc = DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new())));
let mut connector = ClientConnector::new(self.connector_config(), local).with_static_channel(drdynvc);
if self.cfg.audio {
connector.attach_static_channel(ironrdp::rdpsnd::client::Rdpsnd::new(Box::new(ironrdp_rdpsnd_native::cpal::RdpsndBackend::new())));
}
#[cfg(unix)]
if let Some((folder, name)) = &self.cfg.shared_folder {
let drive = super::rdp_drive::ConfinedDrive::new(folder).map_err(|e| format!("shared folder {}: {e}", folder.display()))?;
connector.attach_static_channel(ironrdp::rdpdr::Rdpdr::new(Box::new(drive), "Nexus".into()).with_drives(Some(vec![(1, name.clone())])));
}
let (clip_tx, clip_rx) = mpsc::channel();
connector.attach_static_channel(CliprdrClient::new(Box::new(TextClipboard {
tx: clip_tx,
@@ -570,6 +582,8 @@ mod tests {
width: 1024,
height: 768,
allow_tls_only: true,
audio: std::env::var("NEXUS_RDP_AUDIO").is_ok(),
shared_folder: std::env::var("NEXUS_RDP_SHARE").ok().map(|p| (p.into(), "nexus".into())),
dial: Arc::new(move || Ok(TcpStream::connect((h2.as_str(), port))?)),
check_cert: Arc::new(move |fp| {
*sc.lock() = fp.to_string();
+131
View File
@@ -0,0 +1,131 @@
//! Drive redirection for RDP, confined to one shared folder. IronRDP's native backend joins the
//! server's paths onto the share without checking them, so a hostile server could reach
//! `\..\..\.ssh\id_ed25519`. Every path the server sends is checked here first: no `..`
//! components, and after resolving symlinks it must stay inside the share. Unix only (the native
//! backend has no Windows implementation).
use ironrdp::core::impl_as_any;
use ironrdp::pdu::PduResult;
use ironrdp::rdpdr::RdpdrBackend;
use ironrdp::rdpdr::pdu::RdpdrPdu;
use ironrdp::rdpdr::pdu::efs::{
ClientDriveQueryDirectoryResponse, ClientDriveSetInformationResponse, DeviceControlRequest, DeviceCreateResponse, DeviceIoResponse, FileInformationClass,
Information, NtStatus, ServerDeviceAnnounceResponse, ServerDriveIoRequest,
};
use ironrdp::rdpdr::pdu::esc::{ScardCall, ScardIoCtlCode};
use ironrdp::svc::SvcMessage;
use ironrdp_rdpdr_native::backend::NixRdpdrBackend;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct ConfinedDrive {
inner: NixRdpdrBackend,
base: PathBuf,
}
impl_as_any!(ConfinedDrive);
impl ConfinedDrive {
pub fn new(folder: &Path) -> std::io::Result<ConfinedDrive> {
let base = folder.canonicalize()?;
if !base.is_dir() {
return Err(std::io::Error::other(format!("{} is not a folder", base.display())));
}
Ok(ConfinedDrive { inner: NixRdpdrBackend::new(base.to_string_lossy().trim_end_matches('/').to_string()), base })
}
/// Whether a server path (`\dir\file`, Windows separators) stays inside the share.
fn allowed(&self, server_path: &str) -> bool {
let rel = server_path.replace('\\', "/");
if rel.contains('\0') {
return false;
}
let mut p = self.base.clone();
for c in rel.split('/').filter(|c| !c.is_empty() && *c != ".") {
if c == ".." {
return false;
}
p.push(c);
}
// The deepest part that exists must resolve (through any symlinks) inside the share; what
// doesn't exist yet can't be a link.
let mut probe = p.as_path();
loop {
if probe.symlink_metadata().is_ok() {
return probe.canonicalize().is_ok_and(|real| real.starts_with(&self.base));
}
match probe.parent() {
Some(parent) if parent.starts_with(&self.base) => probe = parent,
_ => return false,
}
}
}
fn refuse(req: ServerDriveIoRequest) -> PduResult<Vec<SvcMessage>> {
tracing::warn!("RDP server asked for a path outside the shared folder; refused");
let pdu = match req {
ServerDriveIoRequest::ServerCreateDriveRequest(r) => RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse {
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::ACCESS_DENIED),
file_id: 0,
information: Information::empty(),
}),
ServerDriveIoRequest::ServerDriveQueryDirectoryRequest(r) => RdpdrPdu::ClientDriveQueryDirectoryResponse(ClientDriveQueryDirectoryResponse {
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::NO_MORE_FILES),
buffer: None,
}),
ServerDriveIoRequest::ServerDriveSetInformationRequest(r) => RdpdrPdu::ClientDriveSetInformationResponse(
ClientDriveSetInformationResponse::new(&r, NtStatus::ACCESS_DENIED).map_err(|e| ironrdp::pdu::encode_err!(e))?,
),
_ => return Ok(vec![]),
};
Ok(vec![SvcMessage::from(pdu)])
}
}
impl RdpdrBackend for ConfinedDrive {
fn handle_server_device_announce_response(&mut self, pdu: ServerDeviceAnnounceResponse) -> PduResult<()> {
self.inner.handle_server_device_announce_response(pdu)
}
fn handle_scard_call(&mut self, req: DeviceControlRequest<ScardIoCtlCode>, call: ScardCall) -> PduResult<()> {
self.inner.handle_scard_call(req, call)
}
fn handle_drive_io_request(&mut self, req: ServerDriveIoRequest) -> PduResult<Vec<SvcMessage>> {
let ok = match &req {
ServerDriveIoRequest::ServerCreateDriveRequest(r) => self.allowed(&r.path),
ServerDriveIoRequest::ServerDriveQueryDirectoryRequest(r) => r.initial_query == 0 || self.allowed(r.path.trim_end_matches('*')),
ServerDriveIoRequest::ServerDriveSetInformationRequest(r) => match &r.set_buffer {
FileInformationClass::Rename(info) => self.allowed(&info.file_name),
_ => true,
},
// Everything else names a file by an id that a checked create handed out.
_ => true,
};
if ok { self.inner.handle_drive_io_request(req) } else { Self::refuse(req) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn server_paths_stay_inside_the_share() {
let root = std::env::temp_dir().join(format!("nexus-drive-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let share = root.join("share");
std::fs::create_dir_all(share.join("sub")).unwrap();
std::fs::write(root.join("secret"), "x").unwrap();
std::os::unix::fs::symlink(&root, share.join("escape")).unwrap();
std::os::unix::fs::symlink(share.join("sub"), share.join("inside")).unwrap();
let d = ConfinedDrive::new(&share).unwrap();
for ok in ["\\", "\\sub", "\\sub\\new.txt", "\\new\\deeper\\file", "\\inside\\x", "\\.\\sub"] {
assert!(d.allowed(ok), "{ok}");
}
for bad in ["\\..\\secret", "\\sub\\..\\..\\secret", "\\escape\\secret", "\\escape\\new", "\\a\0b"] {
assert!(!d.allowed(bad), "{bad:?}");
}
std::fs::remove_dir_all(root).unwrap();
}
}
+5
View File
@@ -409,6 +409,10 @@ pub struct RdpConfig {
/// Allow plain TLS logon for servers without Network Level Authentication (e.g. xrdp).
#[serde(rename = "allowTLSOnly")]
pub allow_tls_only: bool,
/// Play remote audio locally.
pub audio: bool,
/// Local folder shared with the remote desktop as a drive (empty: none). Linux and macOS.
pub shared_folder: String,
/// Trust policy for the server's TLS certificate (pinned like an SSH host key).
#[serde(deserialize_with = "lenient")]
pub host_key_policy: HostKeyPolicy,
@@ -433,6 +437,7 @@ impl RdpConfig {
RdpConfig {
scale_to_fit: true,
dynamic_resolution: true,
audio: true,
proxy_type: "none".into(),
ssh_port: DEFAULT_SSH_PORT.into(),
ssh_auth_method: "publickey".into(),
+2
View File
@@ -354,6 +354,8 @@ impl Manager {
width,
height,
allow_tls_only: rc.allow_tls_only,
audio: rc.audio,
shared_folder: (!rc.shared_folder.trim().is_empty()).then(|| (rc.shared_folder.trim().into(), "Nexus".into())),
dial,
check_cert,
connect_timeout: timeout,
+4
View File
@@ -334,6 +334,10 @@ pub fn validate_rdp_config(cfg: &RdpConfig) -> Result<(), ValidationErrors> {
}
t => errs.add("rdp.proxyType", format!("unsupported proxy type {t:?}")),
}
let share = cfg.shared_folder.trim();
if !share.is_empty() && !std::path::Path::new(share).is_dir() {
errs.add("rdp.sharedFolder", "the shared folder must be an existing folder");
}
if cfg.ssh_tunnel {
if !matches!(cfg.proxy_type.as_str(), "" | "none") {
errs.add("rdp.proxyType", "use either a proxy or an SSH tunnel, not both");