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:
@@ -485,6 +485,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
Event::Warning { message, .. } => self.s.error(message),
|
||||
Event::Info { message, .. } => self.s.success(message),
|
||||
Event::Irc { session, event } => {
|
||||
if let Some(View::Irc(v)) = self.s.panes.get_mut(&session).map(|p| &mut p.view) {
|
||||
v.on_event(event);
|
||||
|
||||
@@ -702,6 +702,7 @@ impl ProfileEditor {
|
||||
});
|
||||
section(ui, "Devices", |ui| {
|
||||
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) {
|
||||
label(ui, "Shared Folder");
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
@@ -40,8 +40,7 @@ impl Callbacks {
|
||||
pub mod proxy;
|
||||
pub mod raw;
|
||||
pub mod rdp;
|
||||
#[cfg(unix)]
|
||||
pub mod rdp_drive;
|
||||
pub mod rdp_devices;
|
||||
pub mod serial;
|
||||
pub mod telnet;
|
||||
pub mod vnc;
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct Config {
|
||||
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)>,
|
||||
/// 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 check_cert: CertCheck,
|
||||
pub connect_timeout: Duration,
|
||||
@@ -196,10 +198,25 @@ impl Client {
|
||||
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())])));
|
||||
if self.cfg.shared_folder.is_some() || self.cfg.printer.is_some() {
|
||||
let mut devices = super::rdp_devices::Devices::default();
|
||||
let mut drives = vec![];
|
||||
#[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();
|
||||
connector.attach_static_channel(CliprdrClient::new(Box::new(TextClipboard {
|
||||
@@ -584,6 +601,7 @@ mod tests {
|
||||
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())),
|
||||
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))?)),
|
||||
check_cert: Arc::new(move |fp| {
|
||||
*sc.lock() = fp.to_string();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -413,6 +413,8 @@ pub struct RdpConfig {
|
||||
pub audio: bool,
|
||||
/// Local folder shared with the remote desktop as a drive (empty: none). Linux and macOS.
|
||||
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).
|
||||
#[serde(deserialize_with = "lenient")]
|
||||
pub host_key_policy: HostKeyPolicy,
|
||||
|
||||
@@ -65,6 +65,11 @@ pub enum Event {
|
||||
session: 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
|
||||
/// couldn't be saved).
|
||||
Warning {
|
||||
|
||||
@@ -356,6 +356,12 @@ impl Manager {
|
||||
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())),
|
||||
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,
|
||||
check_cert,
|
||||
connect_timeout: timeout,
|
||||
@@ -418,3 +424,10 @@ pub(super) fn connect_vnc_client(
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user