feat(rdp): printer redirection, saved as PostScript

An optional 'Nexus Terminal' printer (IronRDP's PostScript driver
announce) appears on the remote desktop; each job is written to
'Nexus print <time>.ps' in Downloads, with a toast saying where. Jobs
are capped at 256 MiB and empty jobs leave no file. The shared drive and
the printer now share one RDPDR backend (rdp_devices); the printer
works on every platform, the drive on Linux and macOS.

Tested with a unit test driving create/write/close; xrdp has no printer
redirection, so not exercised against a server.
This commit is contained in:
Andy Dixon
2026-10-12 18:14:31 +01:00
parent b798422735
commit 2fa56e0723
11 changed files with 354 additions and 139 deletions
+1 -1
View File
@@ -148,7 +148,7 @@ Use Nexus Terminal when you need to:
- Scale-to-fit or 1:1, a fixed resolution or one that follows the tab (Display Control). - Scale-to-fit or 1:1, a fixed resolution or one that follows the tab (Display Control).
- Keyboard (scancodes for keys and shortcuts, Unicode for text), mouse and wheel; Ctrl+Alt+Del; text clipboard sharing in both directions. - Keyboard (scancodes for keys and shortcuts, Unicode for text), mouse and wheel; Ctrl+Alt+Del; text clipboard sharing in both directions.
- Password storage modes and domain logon. - Password storage modes and domain logon.
- Remote audio playback, and a shared folder redirected as a drive (Linux and macOS), confined to that folder. - Remote audio playback, a printer whose jobs are saved as PostScript files, and a shared folder redirected as a drive (Linux and macOS), confined to that folder.
### VNC capabilities ### VNC capabilities
+1 -1
View File
@@ -167,7 +167,7 @@ Use SSH tunnelling for VNC whenever possible. If a VNC profile has no tunnel, Ne
- **RDP**: domain (optional), password storage mode, **Certificate Policy** and **Allow servers without NLA**. The username, host and port (3389) are the profile's own. - **RDP**: domain (optional), password storage mode, **Certificate Policy** and **Allow servers without NLA**. The username, host and port (3389) are the profile's own.
- **Display**: **Scale to Fit**, a fixed **Width** and **Height** (0 × 0 fits the tab) and **Resize With Tab**. - **Display**: **Scale to Fit**, a fixed **Width** and **Height** (0 × 0 fits the tab) and **Resize With Tab**.
- **Proxy**: **SOCKS5** or **HTTP CONNECT**, with an optional username and password. - **Proxy**: **SOCKS5** or **HTTP CONNECT**, with an optional username and password.
- **Devices**: **Play Remote Audio**, and on Linux and macOS a **Shared Folder** that appears on the remote desktop as a drive named Nexus. The server can read and change everything in that folder and nothing outside it: paths that try to leave it (`..`, or symlinks pointing elsewhere) are refused. - **Devices**: **Play Remote Audio**; **Redirect Printer**, which adds a *Nexus Terminal* printer on the remote desktop whose jobs are saved as PostScript files in your Downloads folder (up to 256 MiB per job); and on Linux and macOS a **Shared Folder** that appears on the remote desktop as a drive named Nexus. The server can read and change everything in that folder and nothing outside it: paths that try to leave it (`..`, or symlinks pointing elsewhere) are refused.
- **SSH Tunnel**: carry RDP through SSH, as for VNC. Use either a proxy or a tunnel, not both. - **SSH Tunnel**: carry RDP through SSH, as for VNC. Use either a proxy or a tunnel, not both.
Nexus Terminal logs on with Network Level Authentication (CredSSP, NTLM), so your password is checked before a session is set up. Servers without NLA, such as xrdp, need **Allow servers without NLA**; the password is then passed to the server's own logon. Kerberos and smart cards are not supported. Nexus Terminal logs on with Network Level Authentication (CredSSP, NTLM), so your password is checked before a session is set up. Servers without NLA, such as xrdp, need **Allow servers without NLA**; the password is then passed to the server's own logon. Kerberos and smart cards are not supported.
+1
View File
@@ -485,6 +485,7 @@ impl App {
} }
} }
Event::Warning { message, .. } => self.s.error(message), Event::Warning { message, .. } => self.s.error(message),
Event::Info { message, .. } => self.s.success(message),
Event::Irc { session, event } => { Event::Irc { session, event } => {
if let Some(View::Irc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) { if let Some(View::Irc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
v.on_event(event); v.on_event(event);
+1
View File
@@ -702,6 +702,7 @@ impl ProfileEditor {
}); });
section(ui, "Devices", |ui| { section(ui, "Devices", |ui| {
check(ui, "Play Remote Audio", &mut c.audio, ""); check(ui, "Play Remote Audio", &mut c.audio, "");
check(ui, "Redirect Printer", &mut c.redirect_printer, "Adds a \"Nexus Terminal\" printer; jobs are saved as PostScript files in Downloads");
if cfg!(unix) { if cfg!(unix) {
label(ui, "Shared Folder"); label(ui, "Shared Folder");
ui.horizontal(|ui| { ui.horizontal(|ui| {
+1 -2
View File
@@ -40,8 +40,7 @@ impl Callbacks {
pub mod proxy; pub mod proxy;
pub mod raw; pub mod raw;
pub mod rdp; pub mod rdp;
#[cfg(unix)] pub mod rdp_devices;
pub mod rdp_drive;
pub mod serial; pub mod serial;
pub mod telnet; pub mod telnet;
pub mod vnc; pub mod vnc;
+22 -4
View File
@@ -52,6 +52,8 @@ pub struct Config {
pub audio: bool, pub audio: bool,
/// A local folder to share as a drive (Unix only), and the name it appears under. /// 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 shared_folder: Option<(std::path::PathBuf, String)>,
/// Offer a printer; jobs are saved as PostScript in this folder.
pub printer: Option<(std::path::PathBuf, super::rdp_devices::PrintedFn)>,
pub dial: Dial, pub dial: Dial,
pub check_cert: CertCheck, pub check_cert: CertCheck,
pub connect_timeout: Duration, pub connect_timeout: Duration,
@@ -196,10 +198,25 @@ impl Client {
if self.cfg.audio { if self.cfg.audio {
connector.attach_static_channel(ironrdp::rdpsnd::client::Rdpsnd::new(Box::new(ironrdp_rdpsnd_native::cpal::RdpsndBackend::new()))); connector.attach_static_channel(ironrdp::rdpsnd::client::Rdpsnd::new(Box::new(ironrdp_rdpsnd_native::cpal::RdpsndBackend::new())));
} }
#[cfg(unix)] if self.cfg.shared_folder.is_some() || self.cfg.printer.is_some() {
if let Some((folder, name)) = &self.cfg.shared_folder { let mut devices = super::rdp_devices::Devices::default();
let drive = super::rdp_drive::ConfinedDrive::new(folder).map_err(|e| format!("shared folder {}: {e}", folder.display()))?; let mut drives = vec![];
connector.attach_static_channel(ironrdp::rdpdr::Rdpdr::new(Box::new(drive), "Nexus".into()).with_drives(Some(vec![(1, name.clone())]))); #[cfg(unix)]
if let Some((folder, name)) = &self.cfg.shared_folder {
devices = devices.with_drive(folder).map_err(|e| format!("shared folder {}: {e}", folder.display()))?;
drives.push((1, name.clone()));
}
if let Some((dir, on_printed)) = &self.cfg.printer {
devices = devices.with_printer(dir.clone(), on_printed.clone());
}
let mut rdpdr = ironrdp::rdpdr::Rdpdr::new(Box::new(devices), "Nexus".into());
if !drives.is_empty() {
rdpdr = rdpdr.with_drives(Some(drives));
}
if self.cfg.printer.is_some() {
rdpdr = rdpdr.with_printer(2, "Nexus Terminal".into());
}
connector.attach_static_channel(rdpdr);
} }
let (clip_tx, clip_rx) = mpsc::channel(); let (clip_tx, clip_rx) = mpsc::channel();
connector.attach_static_channel(CliprdrClient::new(Box::new(TextClipboard { connector.attach_static_channel(CliprdrClient::new(Box::new(TextClipboard {
@@ -584,6 +601,7 @@ mod tests {
allow_tls_only: true, allow_tls_only: true,
audio: std::env::var("NEXUS_RDP_AUDIO").is_ok(), audio: std::env::var("NEXUS_RDP_AUDIO").is_ok(),
shared_folder: std::env::var("NEXUS_RDP_SHARE").ok().map(|p| (p.into(), "nexus".into())), shared_folder: std::env::var("NEXUS_RDP_SHARE").ok().map(|p| (p.into(), "nexus".into())),
printer: std::env::var("NEXUS_RDP_PRINT").ok().map(|p| (p.into(), Arc::new(|f: std::path::PathBuf| eprintln!("printed {}", f.display())) as _)),
dial: Arc::new(move || Ok(TcpStream::connect((h2.as_str(), port))?)), dial: Arc::new(move || Ok(TcpStream::connect((h2.as_str(), port))?)),
check_cert: Arc::new(move |fp| { check_cert: Arc::new(move |fp| {
*sc.lock() = fp.to_string(); *sc.lock() = fp.to_string();
+307
View File
@@ -0,0 +1,307 @@
//! RDP device redirection: a shared folder as a drive, and a virtual printer whose jobs are saved
//! as PostScript files.
//!
//! The drive is confined to one 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. Drives are Unix only (the native backend has no Windows
//! implementation); the printer works everywhere.
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, DeviceCloseResponse, DeviceControlRequest, DeviceCreateResponse, DeviceIoResponse,
DeviceWriteResponse, FileInformationClass, Information, NtStatus, PrinterIoRequest, ServerDeviceAnnounceResponse, ServerDriveIoRequest,
};
use ironrdp::rdpdr::pdu::esc::{ScardCall, ScardIoCtlCode};
use ironrdp::svc::SvcMessage;
#[cfg(unix)]
use ironrdp_rdpdr_native::backend::NixRdpdrBackend;
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
/// A print job may not grow past this (a hostile server could otherwise fill the disk).
const MAX_PRINT_JOB: u64 = 256 << 20;
/// Called with the file a finished print job was saved to.
pub type PrintedFn = std::sync::Arc<dyn Fn(PathBuf) + Send + Sync>;
#[derive(Default)]
pub struct Devices {
#[cfg(unix)]
drive: Option<ConfinedDrive>,
printer: Option<Spool>,
}
impl std::fmt::Debug for Devices {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Devices")
}
}
impl_as_any!(Devices);
impl Devices {
#[cfg(unix)]
pub fn with_drive(mut self, folder: &Path) -> std::io::Result<Devices> {
self.drive = Some(ConfinedDrive::new(folder)?);
Ok(self)
}
/// Print jobs go to `dir` as `Nexus print <time>.ps`.
pub fn with_printer(mut self, dir: PathBuf, on_printed: PrintedFn) -> Devices {
self.printer = Some(Spool { dir, on_printed, jobs: HashMap::new(), next_id: 1 });
self
}
}
struct Spool {
dir: PathBuf,
on_printed: PrintedFn,
jobs: HashMap<u32, (std::fs::File, PathBuf, u64)>,
next_id: u32,
}
impl Spool {
fn handle(&mut self, req: PrinterIoRequest) -> PduResult<Vec<SvcMessage>> {
let pdu = match req {
PrinterIoRequest::Create(r) => {
let name = format!("Nexus print {}.ps", chrono::Local::now().format("%Y-%m-%d %H.%M.%S"));
let path = self.dir.join(name);
match std::fs::File::create(&path) {
Ok(f) => {
let id = self.next_id;
self.next_id += 1;
self.jobs.insert(id, (f, path, 0));
RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse {
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::SUCCESS),
file_id: id,
information: Information::FILE_OPENED,
})
}
Err(e) => {
tracing::warn!(error = %e, dir = %self.dir.display(), "couldn't create a print file");
RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse {
device_io_reply: DeviceIoResponse::new(r.device_io_request, NtStatus::ACCESS_DENIED),
file_id: 0,
information: Information::empty(),
})
}
}
}
PrinterIoRequest::Write(r) => {
let n = r.write_data.len() as u64;
let ok = match self.jobs.get_mut(&r.device_io_request.file_id) {
Some((f, _, written)) if *written + n <= MAX_PRINT_JOB => {
*written += n;
f.write_all(&r.write_data).is_ok()
}
_ => false,
};
RdpdrPdu::DeviceWriteResponse(DeviceWriteResponse {
device_io_reply: DeviceIoResponse::new(r.device_io_request, if ok { NtStatus::SUCCESS } else { NtStatus::UNSUCCESSFUL }),
length: if ok { n as u32 } else { 0 },
})
}
PrinterIoRequest::Close(r) => {
if let Some((f, path, written)) = self.jobs.remove(&r.device_io_request.file_id) {
drop(f);
if written == 0 {
let _ = std::fs::remove_file(&path);
} else {
(self.on_printed)(path);
}
}
RdpdrPdu::DeviceCloseResponse(DeviceCloseResponse { device_io_response: DeviceIoResponse::new(r.device_io_request, NtStatus::SUCCESS) })
}
};
Ok(vec![SvcMessage::from(pdu)])
}
}
impl RdpdrBackend for Devices {
fn handle_server_device_announce_response(&mut self, _: ServerDeviceAnnounceResponse) -> PduResult<()> {
Ok(())
}
fn handle_scard_call(&mut self, _: DeviceControlRequest<ScardIoCtlCode>, _: ScardCall) -> PduResult<()> {
Ok(())
}
fn handle_drive_io_request(&mut self, req: ServerDriveIoRequest) -> PduResult<Vec<SvcMessage>> {
#[cfg(unix)]
if let Some(d) = &mut self.drive {
return d.handle(req);
}
ConfinedDrive::refuse(req)
}
fn handle_printer_io_request(&mut self, req: PrinterIoRequest) -> PduResult<Vec<SvcMessage>> {
match &mut self.printer {
Some(p) => p.handle(req),
None => Ok(vec![SvcMessage::from(RdpdrPdu::DeviceCloseResponse(DeviceCloseResponse {
device_io_response: DeviceIoResponse::new(req.into_device_io_request(), NtStatus::NOT_SUPPORTED),
}))]),
}
}
}
#[derive(Debug)]
struct ConfinedDrive {
#[cfg(unix)]
inner: NixRdpdrBackend,
#[cfg_attr(not(unix), allow(dead_code))]
base: PathBuf,
}
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 {
#[cfg(unix)]
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 ConfinedDrive {
#[cfg(unix)]
fn handle(&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 print_jobs_are_saved_and_capped() {
use ironrdp::rdpdr::pdu::efs::{
CreateDisposition, CreateOptions, DesiredAccess, DeviceCloseRequest, DeviceCreateRequest, DeviceIoRequest, DeviceWriteRequest, FileAttributes,
MajorFunction, SharedAccess,
};
let dir = std::env::temp_dir().join(format!("nexus-print-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let printed: std::sync::Arc<parking_lot::Mutex<Vec<PathBuf>>> = Default::default();
let p2 = printed.clone();
let mut dev = Devices::default().with_printer(dir.clone(), std::sync::Arc::new(move |f| p2.lock().push(f)));
let io = |file_id, major| DeviceIoRequest { device_id: 2, file_id, completion_id: 1, major_function: major, minor_function: 0.into() };
let create = || {
PrinterIoRequest::Create(DeviceCreateRequest {
device_io_request: io(0, MajorFunction::Create),
desired_access: DesiredAccess::empty(),
allocation_size: 0,
file_attributes: FileAttributes::empty(),
shared_access: SharedAccess::empty(),
create_disposition: CreateDisposition::FILE_OPEN,
create_options: CreateOptions::empty(),
path: String::new(),
})
};
let write = |id, data: &[u8]| {
PrinterIoRequest::Write(DeviceWriteRequest { device_io_request: io(id, MajorFunction::Write), offset: 0, write_data: data.to_vec() })
};
let close = |id| PrinterIoRequest::Close(DeviceCloseRequest { device_io_request: io(id, MajorFunction::Close) });
dev.handle_printer_io_request(create()).unwrap();
dev.handle_printer_io_request(write(1, b"%!PS-Adobe-3.0\n")).unwrap();
dev.handle_printer_io_request(write(1, b"showpage\n")).unwrap();
dev.handle_printer_io_request(close(1)).unwrap();
let files = printed.lock().clone();
assert_eq!(files.len(), 1);
assert_eq!(std::fs::read_to_string(&files[0]).unwrap(), "%!PS-Adobe-3.0\nshowpage\n");
// An empty job leaves no file behind; writes past the cap are refused.
dev.handle_printer_io_request(create()).unwrap();
dev.handle_printer_io_request(close(2)).unwrap();
dev.handle_printer_io_request(create()).unwrap();
dev.printer.as_mut().unwrap().jobs.get_mut(&3).unwrap().2 = MAX_PRINT_JOB;
dev.handle_printer_io_request(write(3, b"x")).unwrap();
assert_eq!(dev.printer.as_ref().unwrap().jobs[&3].2, MAX_PRINT_JOB);
assert_eq!(printed.lock().len(), 1);
std::fs::remove_dir_all(dir).unwrap();
}
#[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();
}
}
-131
View File
@@ -1,131 +0,0 @@
//! 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();
}
}
+2
View File
@@ -413,6 +413,8 @@ pub struct RdpConfig {
pub audio: bool, pub audio: bool,
/// Local folder shared with the remote desktop as a drive (empty: none). Linux and macOS. /// Local folder shared with the remote desktop as a drive (empty: none). Linux and macOS.
pub shared_folder: String, pub shared_folder: String,
/// Offer a printer; print jobs are saved as PostScript files in Downloads.
pub redirect_printer: bool,
/// Trust policy for the server's TLS certificate (pinned like an SSH host key). /// Trust policy for the server's TLS certificate (pinned like an SSH host key).
#[serde(deserialize_with = "lenient")] #[serde(deserialize_with = "lenient")]
pub host_key_policy: HostKeyPolicy, pub host_key_policy: HostKeyPolicy,
+5
View File
@@ -65,6 +65,11 @@ pub enum Event {
session: String, session: String,
error: String, error: String,
}, },
/// Something the user might like to know (e.g. where a print job was saved).
Info {
session: String,
message: String,
},
/// Something the user should know about that didn't stop the session (e.g. a credential that /// Something the user should know about that didn't stop the session (e.g. a credential that
/// couldn't be saved). /// couldn't be saved).
Warning { Warning {
+13
View File
@@ -356,6 +356,12 @@ impl Manager {
allow_tls_only: rc.allow_tls_only, allow_tls_only: rc.allow_tls_only,
audio: rc.audio, audio: rc.audio,
shared_folder: (!rc.shared_folder.trim().is_empty()).then(|| (rc.shared_folder.trim().into(), "Nexus".into())), shared_folder: (!rc.shared_folder.trim().is_empty()).then(|| (rc.shared_folder.trim().into(), "Nexus".into())),
printer: rc.redirect_printer.then(|| {
let (me, sid) = (self.clone(), id.to_string());
let on_printed: clients::rdp_devices::PrintedFn =
Arc::new(move |f: PathBuf| me.emit(Event::Info { session: sid.clone(), message: format!("Print job saved to {}", f.display()) }));
(downloads_dir(), on_printed)
}),
dial, dial,
check_cert, check_cert,
connect_timeout: timeout, connect_timeout: timeout,
@@ -418,3 +424,10 @@ pub(super) fn connect_vnc_client(
Err(e) => Err(e), Err(e) => Err(e),
} }
} }
/// Where print jobs and similar go: ~/Downloads when it exists, else the home folder.
fn downloads_dir() -> PathBuf {
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
let d = home.join("Downloads");
if d.is_dir() { d } else { home }
}