From 663803c269942532f010e54916cece1323cf0b3a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20L=C3=B6nnhager?= <david.l@mullvad.net>
Date: Wed, 21 Aug 2024 17:40:11 +0200
Subject: [PATCH] Spawn processes as an unprivileged user by default, including
 connection-checker

---
 test/scripts/ssh-setup.sh             | 19 ++++++++
 test/test-manager/src/vm/provision.rs |  3 +-
 test/test-rpc/src/lib.rs              |  4 ++
 test/test-runner/Cargo.toml           |  2 +-
 test/test-runner/src/main.rs          | 16 +++++--
 test/test-runner/src/util.rs          | 69 +++++++++++++++++++++++++++
 6 files changed, 106 insertions(+), 7 deletions(-)

diff --git a/test/scripts/ssh-setup.sh b/test/scripts/ssh-setup.sh
index 714756f45e46..5ac5dea15efa 100644
--- a/test/scripts/ssh-setup.sh
+++ b/test/scripts/ssh-setup.sh
@@ -9,6 +9,7 @@ RUNNER_DIR="$1"
 APP_PACKAGE="$2"
 PREVIOUS_APP="$3"
 UI_RUNNER="$4"
+UNPRIVILEGED_USER="$5"
 
 # Copy over test runner to correct place
 
@@ -21,6 +22,9 @@ for file in test-runner connection-checker $APP_PACKAGE $PREVIOUS_APP $UI_RUNNER
     cp -f "$SCRIPT_DIR/$file" "$RUNNER_DIR"
 done
 
+# Unprivileged users need execute rights for connection checker
+chmod 551 "${RUNNER_DIR}/connection-checker"
+
 chown -R root "$RUNNER_DIR/"
 
 # Create service
@@ -69,11 +73,18 @@ function setup_macos {
 </plist>
 EOF
 
+    create_test_user_macos
+
     echo "Starting test runner service"
 
     launchctl load -w $RUNNER_PLIST_PATH
 }
 
+function create_test_user_macos {
+    echo "Adding test user account"
+    sysadminctl -addUser "$UNPRIVILEGED_USER" -fullName "$UNPRIVILEGED_USER" -password "$UNPRIVILEGED_USER"
+}
+
 function setup_systemd {
     RUNNER_SERVICE_PATH="/etc/systemd/system/testrunner.service"
 
@@ -94,10 +105,18 @@ EOF
 
     semanage fcontext -a -t bin_t "$RUNNER_DIR/.*" &> /dev/null || true
 
+    create_test_user_linux
+
     systemctl enable testrunner.service
     systemctl start testrunner.service
 }
 
+function create_test_user_linux {
+    echo "Adding test user account"
+    useradd -m "$UNPRIVILEGED_USER"
+    echo "$UNPRIVILEGED_USER:$UNPRIVILEGED_USER" | chpasswd
+}
+
 if [[ "$(uname -s)" == "Darwin" ]]; then
     setup_macos
     exit 0
diff --git a/test/test-manager/src/vm/provision.rs b/test/test-manager/src/vm/provision.rs
index 143f023fa49f..359cf16c33d8 100644
--- a/test/test-manager/src/vm/provision.rs
+++ b/test/test-manager/src/vm/provision.rs
@@ -10,6 +10,7 @@ use std::{
     net::{IpAddr, SocketAddr, TcpStream},
     path::{Path, PathBuf},
 };
+use test_rpc::UNPRIVILEGED_USER;
 
 /// Returns the directory in the test runner where the test-runner binary is installed.
 pub async fn provision(
@@ -156,7 +157,7 @@ fn blocking_ssh(
 
     // Run the setup script in the test runner
     let cmd = format!(
-        r#"sudo {} {remote_dir} "{app_package_path}" "{app_package_to_upgrade_from_path}" "{gui_package_path}""#,
+        r#"sudo {} {remote_dir} "{app_package_path}" "{app_package_to_upgrade_from_path}" "{gui_package_path}" "{UNPRIVILEGED_USER}""#,
         bootstrap_script_dest.display(),
     );
     log::debug!("Running setup script on remote, cmd: {cmd}");
diff --git a/test/test-rpc/src/lib.rs b/test/test-rpc/src/lib.rs
index cc263b845e1e..1ec8fb73216a 100644
--- a/test/test-rpc/src/lib.rs
+++ b/test/test-rpc/src/lib.rs
@@ -13,6 +13,10 @@ pub mod net;
 pub mod package;
 pub mod transport;
 
+/// Unprivileged user. This is used for things like spawning processes.
+/// This is also used as the password for the same user, as is common practice.
+pub const UNPRIVILEGED_USER: &str = "mole";
+
 #[derive(thiserror::Error, Debug, Serialize, Deserialize, PartialEq, Eq)]
 pub enum Error {
     #[error("Test runner RPC failed")]
diff --git a/test/test-runner/Cargo.toml b/test/test-runner/Cargo.toml
index b16a8c23b2b3..7206cb394a8d 100644
--- a/test/test-runner/Cargo.toml
+++ b/test/test-runner/Cargo.toml
@@ -58,7 +58,7 @@ features = ["codec"]
 default-features = false
 
 [target.'cfg(unix)'.dependencies]
-nix = { workspace = true }
+nix = { workspace = true, features = ["user"] }
 
 [target.'cfg(target_os = "linux")'.dependencies]
 rs-release = "0.1.7"
diff --git a/test/test-runner/src/main.rs b/test/test-runner/src/main.rs
index 59fc83672d77..79bc17b4ef50 100644
--- a/test/test-runner/src/main.rs
+++ b/test/test-runner/src/main.rs
@@ -17,7 +17,7 @@ use test_rpc::{
     net::SockHandleId,
     package::Package,
     transport::GrpcForwarder,
-    AppTrace, Service, SpawnOpts,
+    AppTrace, Service, SpawnOpts, UNPRIVILEGED_USER,
 };
 use tokio::{
     io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
@@ -386,11 +386,17 @@ impl Service for TestServer {
         }
 
         cmd.stderr(Stdio::piped());
+        cmd.kill_on_drop(true);
 
-        let mut child = cmd.kill_on_drop(true).spawn().map_err(|error| {
-            log::error!("Failed to spawn {}: {error}", opts.path);
-            test_rpc::Error::Syscall
-        })?;
+        let mut child = util::as_unprivileged_user(UNPRIVILEGED_USER, || cmd.spawn())
+            .map_err(|error| {
+                log::error!("Failed to drop privileges: {error}");
+                test_rpc::Error::Syscall
+            })?
+            .map_err(|error| {
+                log::error!("Failed to spawn {}: {error}", opts.path);
+                test_rpc::Error::Syscall
+            })?;
 
         let pid = child
             .id()
diff --git a/test/test-runner/src/util.rs b/test/test-runner/src/util.rs
index 03a334321412..84cc048b49c6 100644
--- a/test/test-runner/src/util.rs
+++ b/test/test-runner/src/util.rs
@@ -21,3 +21,72 @@ impl<F: FnOnce() + Send> OnDrop<F> {
         }
     }
 }
+
+#[derive(thiserror::Error, Debug)]
+#[error(transparent)]
+pub struct Error {
+    inner: InnerError,
+}
+
+#[cfg(unix)]
+#[derive(thiserror::Error, Debug)]
+enum InnerError {
+    #[error("Failed to get the specified user")]
+    GetUser(#[source] nix::Error),
+    #[error("The specified user was not found")]
+    MissingUser,
+    #[error("Failed to set uid")]
+    SetUid(#[source] nix::Error),
+    #[error("Failed to set gid")]
+    SetGid(#[source] nix::Error),
+}
+
+#[cfg(target_os = "windows")]
+#[derive(thiserror::Error, Debug)]
+enum InnerError {}
+
+impl From<InnerError> for Error {
+    fn from(inner: InnerError) -> Self {
+        Self { inner }
+    }
+}
+
+#[cfg(target_os = "windows")]
+pub fn as_unprivileged_user<T>(unpriv_user: &str, func: impl FnOnce() -> T) -> Result<T, Error> {
+    // NOTE: no-op
+    let _ = unpriv_user;
+    Ok(func())
+}
+
+#[cfg(unix)]
+pub fn as_unprivileged_user<T>(unpriv_user: &str, func: impl FnOnce() -> T) -> Result<T, Error> {
+    let original_uid = nix::unistd::getuid();
+    let original_gid = nix::unistd::getgid();
+
+    let user = nix::unistd::User::from_name(unpriv_user)
+        .map_err(InnerError::GetUser)?
+        .ok_or(InnerError::MissingUser)?;
+    let uid = user.uid;
+    let gid = user.gid;
+
+    nix::unistd::setegid(gid).map_err(InnerError::SetGid)?;
+    let restore_gid = OnDrop::new(|| {
+        if let Err(error) = nix::unistd::setegid(original_gid) {
+            log::error!("Failed to restore gid: {error}");
+        }
+    });
+
+    nix::unistd::seteuid(uid).map_err(InnerError::SetUid)?;
+    let restore_uid = OnDrop::new(|| {
+        if let Err(error) = nix::unistd::seteuid(original_uid) {
+            log::error!("Failed to restore uid: {error}");
+        }
+    });
+
+    let result = Ok(func());
+
+    drop(restore_uid);
+    drop(restore_gid);
+
+    result
+}