Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ __pycache__/
*.xcuserstate
editable.marker
*.DS_Store
/.vscode/
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ sysinfo = "0.39.6"
[target.'cfg(target_os = "linux")'.dependencies]
tun = { workspace = true, features = ["async"] }
tempfile = "3.20.0"

sysinfo = "0.39.6"
libc = "0.2"

[dev-dependencies]
env_logger = "0.11"
Expand All @@ -119,3 +121,6 @@ opt-level = 3

[features]
tracing = ["console-subscriber"]
# Enables tests that require running as root (uid 0) and eBPF-capable kernel.
# Usage: CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER="sudo -E" cargo test --features root-tests
root-tests = []
81 changes: 81 additions & 0 deletions mitmproxy-linux/src/main2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,4 +313,85 @@
async fn bpf_load() {
load_bpf(0).unwrap();
}

/// Regression test for: mitmproxy started as root (e.g. privileged
/// Kubernetes container) must NOT require `sudo`.
///
/// The test simulates a container image that has no `sudo` binary by
/// running with a PATH that excludes any directory containing `sudo`.
/// It then invokes the redirector binary directly (as root would) and
/// verifies it starts — proving that the fix in `start_redirector` works:
/// when `uid == 0`, the binary is launched directly without `sudo`.
///
/// Requires: feature `root-tests` + runner must be root (uid 0).
/// In CI this is achieved with:
/// CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER="sudo -E" cargo test --features root-tests
#[cfg_attr(not(feature = "root-tests"), ignore)]
#[tokio::test]
async fn root_starts_redirector_without_sudo() {
// Gate: this test only makes sense when running as root.
let uid = unsafe { libc::getuid() };
assert_eq!(uid, 0, "this test must run as root (uid=0), got uid={uid}");

// Simulate a container image without sudo: remove every directory
// that contains a `sudo` binary from PATH.
let path_without_sudo: String = std::env::var("PATH")
.unwrap_or_default()
.split(':')
.filter(|dir| !std::path::Path::new(dir).join("sudo").exists())
.collect::<Vec<_>>()
.join(":");
// Safety: single-threaded at this point in the test, env mutation is OK.
unsafe { std::env::set_var("PATH", &path_without_sudo) };

// Sanity-check: sudo must not be reachable anymore.
let sudo_found = std::process::Command::new("which")
.arg("sudo")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
assert!(
!sudo_found,
"sudo is still reachable via PATH={path_without_sudo:?}; pre-condition not met"
);

// The redirector binary is produced by `cargo build` in the same
// workspace. CARGO_BIN_EXE_mitmproxy-linux-redirector is injected by
// cargo when compiling tests that live in the same workspace as the
// binary target.
let redirector_exe = std::path::PathBuf::from(
env!("CARGO_BIN_EXE_mitmproxy-linux-redirector"),

Check failure on line 363 in mitmproxy-linux/src/main2.rs

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, stable, --exclude mitmproxy-linux-ebpf)

environment variable `CARGO_BIN_EXE_mitmproxy-linux-redirector` not defined at compile time

Check failure on line 363 in mitmproxy-linux/src/main2.rs

View workflow job for this annotation

GitHub Actions / test (ubuntu-22.04, 1.95, --exclude mitmproxy-linux-ebpf)

environment variable `CARGO_BIN_EXE_mitmproxy-linux-redirector` not defined at compile time
);
assert!(
redirector_exe.exists(),
"redirector binary not found at {}; run `cargo build` first",
redirector_exe.display()
);

// Spawn the redirector directly (as root, no sudo).
// We pass an intentionally non-existent pipe-dir so the process exits
// quickly with an error, but the important thing is that it was
// LAUNCHED — meaning no "Failed to run sudo" error occurred.
let output = std::process::Command::new(&redirector_exe)
.arg("/tmp/mitmproxy-root-test-nonexistent")
.env("PATH", &path_without_sudo)
.output()
.expect("failed to spawn the redirector binary — did it fail because sudo was missing?");

// The process may exit with an error (missing pipe dir / no eBPF),
// but must NOT produce the "No such file or directory" error that
// comes from trying to execute a missing `sudo`.
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("No such file or directory"),
"redirector stderr contains 'No such file or directory', \
which suggests sudo was invoked even though we are root.\n\
stderr: {stderr}"
);
assert!(
!stderr.contains("Failed to run sudo"),
"redirector stderr contains 'Failed to run sudo' — the fix is not active.\n\
stderr: {stderr}"
);
}
}
Binary file added rust_out
Binary file not shown.
204 changes: 187 additions & 17 deletions src/packet_sources/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,68 @@ use tokio::net::UnixDatagram;
use tokio::process::Command;
use tokio::time::timeout;

/// Returns `true` if the current process is running as root (uid 0).
#[cfg(unix)]
fn is_root() -> bool {
// SAFETY: getuid() is always safe to call.
unsafe { libc::getuid() == 0 }
}

/// Builds the [`std::process::Command`] used to launch the redirector binary.
///
/// When `already_root` is `true`, the redirector is invoked directly so that
/// environments without `sudo` (e.g. a privileged Kubernetes container) work
/// out of the box. Otherwise `sudo --non-interactive --preserve-env` is
/// prepended to perform privilege escalation.
///
/// Extracted as a pure helper so it can be unit-tested without spawning
/// real processes.
fn build_redirector_command(
executable: &Path,
listener_addr: &Path,
already_root: bool,
) -> Command {
if already_root {
let mut cmd = Command::new(executable);
cmd.arg(listener_addr);
cmd
} else {
let mut cmd = Command::new("sudo");
cmd.arg("--non-interactive")
.arg("--preserve-env")
.arg(executable)
.arg(listener_addr);
cmd
}
}

async fn start_redirector(
executable: &Path,
listener_addr: &Path,
shutdown: shutdown::Receiver,
) -> Result<PathBuf> {
debug!("Elevating privileges...");
// Try to elevate privileges using a dummy sudo invocation.
// The idea here is to block execution and give the user time to enter their password.
// For now, we naively assume that all systems 1) have sudo and 2) timestamp_timeout > 0.
let mut sudo = Command::new("sudo")
.arg("echo")
.arg("-n")
.spawn()
.context("Failed to run sudo.")?;
sudo.stdin.take();
if !sudo.wait().await.is_ok_and(|x| x.success()) {
bail!("Failed to elevate privileges");
let already_root = is_root();

if already_root {
debug!("Already running as root, skipping privilege elevation.");
} else {
debug!("Elevating privileges...");
// Try to elevate privileges using a dummy sudo invocation.
// The idea here is to block execution and give the user time to enter their password.
// For now, we naively assume that all systems 1) have sudo and 2) timestamp_timeout > 0.
let mut sudo = Command::new("sudo")
.arg("echo")
.arg("-n")
.spawn()
.context("Failed to run sudo.")?;
sudo.stdin.take();
if !sudo.wait().await.is_ok_and(|x| x.success()) {
bail!("Failed to elevate privileges");
}
}

debug!("Starting mitmproxy-linux-redirector...");
let mut redirector_process = Command::new("sudo")
.arg("--non-interactive")
.arg("--preserve-env")
.arg(executable)
.arg(listener_addr)
let mut redirector_process = build_redirector_command(executable, listener_addr, already_root)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
Expand Down Expand Up @@ -215,3 +252,136 @@ impl PacketSourceTask for LinuxTask {
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
use std::path::Path;

// -----------------------------------------------------------------------
// is_root()
// -----------------------------------------------------------------------

/// `is_root()` must agree with the raw `getuid()` syscall.
#[test]
fn is_root_matches_getuid() {
let uid = unsafe { libc::getuid() };
assert_eq!(is_root(), uid == 0);
}

/// Running `cargo test` without privileges means we are NOT root.
/// This guards against accidentally shipping a build where `is_root()`
/// is hardcoded to `true`.
#[test]
fn is_root_is_false_when_unprivileged() {
if unsafe { libc::getuid() } == 0 {
// Explicitly skip when the test runner itself is root (e.g. CI
// root-tests run). Use the `root_*` tests below instead.
return;
}
assert!(!is_root(), "expected is_root() == false for non-root user");
}

/// When tests are explicitly run as root (feature `root-tests`), confirm
/// that `is_root()` returns `true`.
#[cfg(feature = "root-tests")]
#[test]
fn is_root_is_true_when_privileged() {
assert!(is_root(), "expected is_root() == true when running as root");
}

// -----------------------------------------------------------------------
// build_redirector_command()
// -----------------------------------------------------------------------

/// Helper: extract the program name from a `tokio::process::Command`.
fn program_of(cmd: &Command) -> String {
// as_std() gives std::process::Command whose Debug format is:
// "program" "arg1" "arg2" ...
let dbg = format!("{:?}", cmd.as_std());
dbg.trim_start_matches('"')
.split('"')
.next()
.unwrap_or("")
.to_string()
}

/// Helper: collect all arguments from a `tokio::process::Command`.
fn args_of(cmd: &Command) -> Vec<String> {
// std::process::Command Debug format: `"prog" "a" "b" ...`
let dbg = format!("{:?}", cmd.as_std());
let mut tokens = dbg.split('"').filter(|s| !s.trim().is_empty());
tokens.next(); // skip program
tokens.map(|s| s.to_string()).collect()
}

/// When already root, the command must start with the redirector executable
/// itself — *not* with `sudo`.
#[test]
fn command_as_root_runs_executable_directly() {
let exe = Path::new("/usr/lib/mitmproxy/mitmproxy-linux-redirector");
let addr = Path::new("/tmp/mitmproxy-test");
let cmd = build_redirector_command(exe, addr, /* already_root = */ true);
let prog = program_of(&cmd);
assert!(
prog.ends_with("mitmproxy-linux-redirector"),
"expected executable as first token, got: {prog:?}"
);
assert!(
!prog.contains("sudo"),
"sudo must NOT appear as the program when already root, got: {prog:?}"
);
}

/// When NOT root, the command must start with `sudo`.
#[test]
fn command_without_root_uses_sudo() {
let exe = Path::new("/usr/lib/mitmproxy/mitmproxy-linux-redirector");
let addr = Path::new("/tmp/mitmproxy-test");
let cmd = build_redirector_command(exe, addr, /* already_root = */ false);
let prog = program_of(&cmd);
assert!(
prog.ends_with("sudo"),
"expected 'sudo' as first token, got: {prog:?}"
);
}

/// When NOT root, the sudo invocation must pass `--non-interactive` and
/// `--preserve-env` so that the redirector inherits the user's environment
/// variables without prompting for a password.
#[test]
fn sudo_command_has_required_flags() {
let exe = Path::new("/usr/lib/mitmproxy/mitmproxy-linux-redirector");
let addr = Path::new("/tmp/mitmproxy-test");
let cmd = build_redirector_command(exe, addr, false);
let args = args_of(&cmd);
assert!(
args.iter().any(|a| a == "--non-interactive"),
"--non-interactive flag missing from sudo invocation; args={args:?}"
);
assert!(
args.iter().any(|a| a == "--preserve-env"),
"--preserve-env flag missing from sudo invocation; args={args:?}"
);
}

/// The listener address must be the last argument in both the root and
/// non-root command variants.
#[test]
fn listener_addr_is_last_argument() {
let exe = Path::new("/usr/lib/mitmproxy/mitmproxy-linux-redirector");
let addr = Path::new("/tmp/mitmproxy-9999");

for already_root in [true, false] {
let cmd = build_redirector_command(exe, addr, already_root);
let args = args_of(&cmd);
let last = args.last().cloned().unwrap_or_default();
assert_eq!(
OsStr::new(&last),
addr.as_os_str(),
"listener_addr must be the last argument (already_root={already_root}); args={args:?}"
);
}
}
}
Loading