Compare commits
11 Commits
7cee9938bb
...
6490a8655c
| Author | SHA1 | Date | |
|---|---|---|---|
| 6490a8655c | |||
| 4e0c3632db | |||
| 3e31a94939 | |||
| 2fbe250ab8 | |||
| a24767a0ad | |||
| 87b11a7959 | |||
| ea0ac7ce15 | |||
| 618922b2a7 | |||
| 6d44e40964 | |||
| 77ab15f41f | |||
| 40368d415d |
@@ -635,6 +635,7 @@ message PermissionInfo {
|
||||
Restart = 5;
|
||||
Recording = 6;
|
||||
BlockInput = 7;
|
||||
PrivacyMode = 8;
|
||||
}
|
||||
|
||||
Permission permission = 1;
|
||||
|
||||
@@ -44,6 +44,7 @@ message ControlPermissions {
|
||||
recording = 9;
|
||||
block_input = 10;
|
||||
remote_modify = 11;
|
||||
privacy_mode = 12;
|
||||
}
|
||||
uint64 permissions = 1;
|
||||
}
|
||||
|
||||
+80
-3
@@ -164,6 +164,29 @@ pub const RELAY_PORT: i32 = 21117;
|
||||
pub const WS_RENDEZVOUS_PORT: i32 = 21118;
|
||||
pub const WS_RELAY_PORT: i32 = 21119;
|
||||
|
||||
#[inline]
|
||||
pub fn is_service_ipc_postfix(postfix: &str) -> bool {
|
||||
// `_service` is a protected cross-user IPC channel used by the root service.
|
||||
//
|
||||
// On Linux Wayland, input injection is implemented via uinput in the root service process.
|
||||
// The user `--server` process must be able to connect to these uinput IPC channels, so they
|
||||
// must share the same IPC parent directory as `_service`.
|
||||
postfix == "_service" || postfix.starts_with("_uinput_")
|
||||
}
|
||||
|
||||
// Keep Linux/macOS IPC parent directory rules in one place to avoid drift between
|
||||
// `ipc_path()` and Linux-only `ipc_path_for_uid()`.
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[inline]
|
||||
fn ipc_parent_dir_for_uid(uid: u32, postfix: &str) -> String {
|
||||
let app_name = APP_NAME.read().unwrap().clone();
|
||||
if is_service_ipc_postfix(postfix) {
|
||||
format!("/tmp/{app_name}-service")
|
||||
} else {
|
||||
format!("/tmp/{app_name}-{uid}")
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! serde_field_string {
|
||||
($default_func:ident, $de_func:ident, $default_expr:expr) => {
|
||||
fn $default_func() -> String {
|
||||
@@ -817,19 +840,43 @@ impl Config {
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
#[cfg(target_os = "android")]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
#[cfg(target_os = "android")]
|
||||
let mut path: PathBuf =
|
||||
format!("{}/{}", *APP_DIR.read().unwrap(), *APP_NAME.read().unwrap()).into();
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
let mut path: PathBuf = {
|
||||
let uid = unsafe { libc::geteuid() as u32 };
|
||||
ipc_parent_dir_for_uid(uid, postfix).into()
|
||||
};
|
||||
#[cfg(not(any(target_os = "android", target_os = "linux", target_os = "macos")))]
|
||||
let mut path: PathBuf = format!("/tmp/{}", *APP_NAME.read().unwrap()).into();
|
||||
fs::create_dir(&path).ok();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o0777)).ok();
|
||||
// Android stores IPC sockets under app-controlled directories. Create the IPC parent
|
||||
// dir and enforce the expected mode here. On other Unix platforms, `ipc_path()` is
|
||||
// intentionally side-effect free (no mkdir/chmod); callers should enforce directory and
|
||||
// socket permissions at the IPC server boundary.
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
fs::create_dir_all(&path).ok();
|
||||
let path_mode = if is_service_ipc_postfix(postfix) {
|
||||
0o0711
|
||||
} else {
|
||||
0o0700
|
||||
};
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(path_mode)).ok();
|
||||
}
|
||||
path.push(format!("ipc{postfix}"));
|
||||
path.to_str().unwrap_or("").to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn ipc_path_for_uid(uid: u32, postfix: &str) -> String {
|
||||
let parent = ipc_parent_dir_for_uid(uid, postfix);
|
||||
format!("{parent}/ipc{postfix}")
|
||||
}
|
||||
|
||||
pub fn icon_path() -> PathBuf {
|
||||
let mut path = Self::path("icons");
|
||||
if fs::create_dir_all(&path).is_err() {
|
||||
@@ -2804,6 +2851,8 @@ pub mod keys {
|
||||
pub const OPTION_ENABLE_REMOTE_RESTART: &str = "enable-remote-restart";
|
||||
pub const OPTION_ENABLE_RECORD_SESSION: &str = "enable-record-session";
|
||||
pub const OPTION_ENABLE_BLOCK_INPUT: &str = "enable-block-input";
|
||||
pub const OPTION_ENABLE_PRIVACY_MODE: &str = "enable-privacy-mode";
|
||||
pub const OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW: &str = "enable-perm-change-in-accept-window";
|
||||
pub const OPTION_ALLOW_REMOTE_CONFIG_MODIFICATION: &str = "allow-remote-config-modification";
|
||||
pub const OPTION_ALLOW_NUMERNIC_ONE_TIME_PASSWORD: &str = "allow-numeric-one-time-password";
|
||||
pub const OPTION_ENABLE_LAN_DISCOVERY: &str = "enable-lan-discovery";
|
||||
@@ -2888,6 +2937,8 @@ pub mod keys {
|
||||
pub const OPTION_HIDE_TRAY: &str = "hide-tray";
|
||||
pub const OPTION_ONE_WAY_CLIPBOARD_REDIRECTION: &str = "one-way-clipboard-redirection";
|
||||
pub const OPTION_ALLOW_LOGON_SCREEN_PASSWORD: &str = "allow-logon-screen-password";
|
||||
pub const OPTION_ALLOW_DEEP_LINK_PASSWORD: &str = "allow-deep-link-password";
|
||||
pub const OPTION_ALLOW_DEEP_LINK_SERVER_SETTINGS: &str = "allow-deep-link-server-settings";
|
||||
pub const OPTION_ONE_WAY_FILE_TRANSFER: &str = "one-way-file-transfer";
|
||||
pub const OPTION_ALLOW_HTTPS_21114: &str = "allow-https-21114";
|
||||
pub const OPTION_USE_RAW_TCP_FOR_API: &str = "use-raw-tcp-for-api";
|
||||
@@ -3030,6 +3081,7 @@ pub mod keys {
|
||||
OPTION_ENABLE_REMOTE_RESTART,
|
||||
OPTION_ENABLE_RECORD_SESSION,
|
||||
OPTION_ENABLE_BLOCK_INPUT,
|
||||
OPTION_ENABLE_PRIVACY_MODE,
|
||||
OPTION_ALLOW_REMOTE_CONFIG_MODIFICATION,
|
||||
OPTION_ALLOW_NUMERNIC_ONE_TIME_PASSWORD,
|
||||
OPTION_ENABLE_LAN_DISCOVERY,
|
||||
@@ -3095,6 +3147,8 @@ pub mod keys {
|
||||
OPTION_HIDE_TRAY,
|
||||
OPTION_ONE_WAY_CLIPBOARD_REDIRECTION,
|
||||
OPTION_ALLOW_LOGON_SCREEN_PASSWORD,
|
||||
OPTION_ALLOW_DEEP_LINK_PASSWORD,
|
||||
OPTION_ALLOW_DEEP_LINK_SERVER_SETTINGS,
|
||||
OPTION_ONE_WAY_FILE_TRANSFER,
|
||||
OPTION_ALLOW_HTTPS_21114,
|
||||
OPTION_ALLOW_HOSTNAME_AS_ID,
|
||||
@@ -3106,6 +3160,7 @@ pub mod keys {
|
||||
OPTION_DISABLE_CHANGE_ID,
|
||||
OPTION_DISABLE_UNLOCK_PIN,
|
||||
OPTION_USE_RAW_TCP_FOR_API,
|
||||
OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3466,4 +3521,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn test_uinput_ipc_path_is_shared_across_uids() {
|
||||
const ROOT_UID: u32 = 0;
|
||||
const USER_UID: u32 = 1000;
|
||||
|
||||
let path_root = Config::ipc_path_for_uid(ROOT_UID, "_uinput_keyboard");
|
||||
let path_user = Config::ipc_path_for_uid(USER_UID, "_uinput_keyboard");
|
||||
assert_eq!(path_root, path_user);
|
||||
|
||||
let app_name = APP_NAME.read().unwrap().clone();
|
||||
assert!(
|
||||
path_root.starts_with(&format!("/tmp/{app_name}-service/")),
|
||||
"unexpected uinput ipc path: {}",
|
||||
path_root
|
||||
);
|
||||
|
||||
let non_service_root = Config::ipc_path_for_uid(ROOT_UID, "");
|
||||
let non_service_user = Config::ipc_path_for_uid(USER_UID, "");
|
||||
assert_ne!(non_service_root, non_service_user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,7 +464,7 @@ pub fn validate_file_name_no_traversal(name: &str) -> ResultType<()> {
|
||||
let has_traversal = name
|
||||
.split(|c: char| c == '/' || (cfg!(windows) && c == '\\'))
|
||||
.filter(|s| !s.is_empty())
|
||||
.any(|component| component == "..");
|
||||
.any(|s| s == "..");
|
||||
if has_traversal {
|
||||
bail!("path traversal detected in file name");
|
||||
}
|
||||
@@ -502,6 +502,17 @@ fn validate_transfer_file_names(files: &[FileEntry]) -> ResultType<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn validate_fs_path_argument(path: &str, arg_name: &str) -> ResultType<()> {
|
||||
if path.is_empty() {
|
||||
bail!("{arg_name} cannot be empty");
|
||||
}
|
||||
if path.bytes().any(|b| b == 0) {
|
||||
bail!("{arg_name} contains null bytes");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_no_symlink_components(base: &PathBuf, name: &str) -> ResultType<()> {
|
||||
if name.is_empty() {
|
||||
return Ok(());
|
||||
@@ -766,6 +777,10 @@ impl TransferJob {
|
||||
(p.to_string_lossy().to_string(), None)
|
||||
} else {
|
||||
let path = join_validated_path(p, &entry.name)?;
|
||||
// NOTE: We intentionally keep path-based validation + regular file open here.
|
||||
// This still has a known TOCTOU window for symlink races, but avoids a large
|
||||
// cross-platform rewrite for now.
|
||||
// Revisit with descriptor/handle-based no-follow open in future hardening.
|
||||
if let Some(pp) = path.parent() {
|
||||
std::fs::create_dir_all(pp).ok();
|
||||
}
|
||||
@@ -1098,6 +1113,8 @@ impl TransferJob {
|
||||
|
||||
let mut f = if Path::new(&download_path).exists() && Path::new(&digest_path).exists() {
|
||||
// If both download and digest files exist, seek (writer) to the offset
|
||||
// NOTE: same as write path: best-effort symlink validation happened earlier,
|
||||
// but this reopen remains TOCTOU-prone by design for now.
|
||||
match OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
@@ -1382,18 +1399,25 @@ pub fn remove_all_empty_dir(path: &Path) -> ResultType<()> {
|
||||
|
||||
#[inline]
|
||||
pub fn remove_file(file: &str) -> ResultType<()> {
|
||||
validate_fs_path_argument(file, "file path")?;
|
||||
std::fs::remove_file(get_path(file))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn create_dir(dir: &str) -> ResultType<()> {
|
||||
validate_fs_path_argument(dir, "directory path")?;
|
||||
std::fs::create_dir_all(get_path(dir))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn rename_file(path: &str, new_name: &str) -> ResultType<()> {
|
||||
validate_fs_path_argument(path, "path")?;
|
||||
if new_name.is_empty() {
|
||||
bail!("new file name cannot be empty");
|
||||
}
|
||||
validate_file_name_no_traversal(new_name)?;
|
||||
let path = std::path::Path::new(&path);
|
||||
if path.exists() {
|
||||
let dir = path
|
||||
@@ -1604,6 +1628,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(windows, ignore = "requires symlink privilege to create test symlink")]
|
||||
fn path_traversal_e2e_write_rejects_symlink_escape() {
|
||||
let tmp_root = TestTempDir::new("rustdesk_e2e_symlink");
|
||||
let downloads = tmp_root.join("downloads");
|
||||
@@ -1616,20 +1641,12 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
if let Err(err) = symlink(&outside, &symlink_path) {
|
||||
eprintln!("Skipping symlink test: failed to create symlink: {err}");
|
||||
return;
|
||||
}
|
||||
symlink(&outside, &symlink_path).expect("create symlink for test");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::symlink_dir;
|
||||
if let Err(err) = symlink_dir(&outside, &symlink_path) {
|
||||
eprintln!(
|
||||
"Skipping symlink test: failed to create directory symlink (requires privileges): {err}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
symlink_dir(&outside, &symlink_path).expect("create directory symlink for test");
|
||||
}
|
||||
|
||||
let err = new_write_job(3, downloads, "link/escape.txt")
|
||||
@@ -1693,6 +1710,80 @@ mod tests {
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_file_rejects_empty_path() {
|
||||
let err = remove_file("").expect_err("empty file path must be rejected");
|
||||
assert_err_contains(err, "cannot be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_file_rejects_null_byte_path() {
|
||||
let err = remove_file("bad\0path").expect_err("null byte path must be rejected");
|
||||
assert_err_contains(err, "null bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dir_rejects_empty_path() {
|
||||
let err = create_dir("").expect_err("empty directory path must be rejected");
|
||||
assert_err_contains(err, "cannot be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_dir_rejects_null_byte_path() {
|
||||
let err = create_dir("bad\0path").expect_err("null byte path must be rejected");
|
||||
assert_err_contains(err, "null bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_file_rejects_invalid_new_name() {
|
||||
let tmp_root = TestTempDir::new("rustdesk_rename_invalid");
|
||||
let src = tmp_root.join("source.txt");
|
||||
std::fs::create_dir_all(&tmp_root.path).expect("create temp dir");
|
||||
std::fs::write(&src, b"content").expect("create source file");
|
||||
|
||||
let src_str = src.to_string_lossy().to_string();
|
||||
|
||||
let err_empty =
|
||||
rename_file(&src_str, "").expect_err("empty new file name must be rejected");
|
||||
assert_err_contains(err_empty, "cannot be empty");
|
||||
|
||||
let err_traversal = rename_file(&src_str, "../escape.txt")
|
||||
.expect_err("traversal new file name must be rejected");
|
||||
assert_err_contains(err_traversal, "path traversal");
|
||||
|
||||
let err_null = rename_file(&src_str, "bad\0name.txt")
|
||||
.expect_err("null byte in new file name must be rejected");
|
||||
assert_err_contains(err_null, "null bytes");
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let err_abs = rename_file(&src_str, "C:\\Windows\\Temp\\payload.txt")
|
||||
.expect_err("absolute new file name must be rejected");
|
||||
assert_err_contains(err_abs, "absolute path");
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let err_abs = rename_file(&src_str, "/tmp/payload.txt")
|
||||
.expect_err("absolute new file name must be rejected");
|
||||
assert_err_contains(err_abs, "absolute path");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_file_accepts_valid_new_name() {
|
||||
let tmp_root = TestTempDir::new("rustdesk_rename_ok");
|
||||
let src = tmp_root.join("rename_src.txt");
|
||||
let dst = tmp_root.join("renamed.txt");
|
||||
std::fs::create_dir_all(&tmp_root.path).expect("create temp dir");
|
||||
std::fs::write(&src, b"content").expect("create source file");
|
||||
|
||||
let src_str = src.to_string_lossy().to_string();
|
||||
rename_file(&src_str, "renamed.txt").expect("rename should succeed");
|
||||
|
||||
assert!(!src.exists());
|
||||
assert!(dst.exists());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn set_files_rejects_windows_drive_absolute_path() {
|
||||
@@ -1703,43 +1794,13 @@ mod tests {
|
||||
assert_err_contains(err, "absolute path");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn set_files_rejects_symlink_path_component() {
|
||||
let tmp_root = TestTempDir::new("rustdesk_set_files_symlink");
|
||||
let downloads = tmp_root.join("downloads");
|
||||
let outside = tmp_root.join("outside");
|
||||
std::fs::create_dir_all(&downloads).expect("create downloads dir");
|
||||
std::fs::create_dir_all(&outside).expect("create outside dir");
|
||||
|
||||
let symlink_path = downloads.join("link");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
if symlink(&outside, &symlink_path).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::symlink_dir;
|
||||
if symlink_dir(&outside, &symlink_path).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut job = TransferJob::new_write(
|
||||
107,
|
||||
JobType::Generic,
|
||||
"/fake/remote".to_string(),
|
||||
DataSource::FilePath(downloads),
|
||||
0,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
fn set_files_rejects_windows_verbatim_drive_absolute_path() {
|
||||
let mut job = new_validation_job(1061);
|
||||
let err = job
|
||||
.set_files(vec![new_file_entry("link/escape.txt")])
|
||||
.expect_err("symlink component must be rejected");
|
||||
assert_err_contains(err, "symlink");
|
||||
.set_files(vec![new_file_entry(r"\\?\C:\Windows\Temp\x.txt")])
|
||||
.expect_err("verbatim drive absolute path must be rejected");
|
||||
assert_err_contains(err, "absolute path");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user