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:
@@ -36,10 +36,14 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
webpki-roots = "1"
|
||||
ironrdp = { version = "0.17", features = ["connector", "session", "graphics", "pdu", "svc", "dvc", "displaycontrol", "input", "cliprdr"] }
|
||||
ironrdp = { version = "0.17", features = ["connector", "session", "graphics", "pdu", "svc", "dvc", "displaycontrol", "input", "cliprdr", "rdpsnd", "rdpdr"] }
|
||||
ironrdp-rdpsnd-native = { version = "0.7", default-features = false }
|
||||
ironrdp-blocking = "0.10"
|
||||
x509-cert = { version = "0.2", default-features = false, features = ["std"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
ironrdp-rdpdr-native = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
tiny_http = "0.12"
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user