From f1d7a14b8db0b35d25f360a3fcced0a167ab5bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Sun, 29 Mar 2026 16:50:59 +0200 Subject: [PATCH] Ensure root filesystem is always read-only inside sandbox Whitelist mode's implicit bwrap root was a writable tmpfs, letting the sandboxed process create files and directories anywhere not covered by an explicit ro mount. This was not an issue in blacklist mode due to --ro-bind / / covering that case. This patch adds --remount-ro / before any other mount to make the base layer read-only in both modes. --- src/sandbox.rs | 1 + tests/integration.rs | 101 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/src/sandbox.rs b/src/sandbox.rs index e4ab81d..952b92f 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -34,6 +34,7 @@ pub fn build_command(config: &SandboxConfig) -> Result { add_ro_bind(&mut cmd, path)?; } + cmd.args(["--remount-ro", "/"]); cmd.arg("--new-session"); cmd.arg("--die-with-parent"); cmd.arg("--chdir").arg(&config.chdir); diff --git a/tests/integration.rs b/tests/integration.rs index 5c5d122..dc99566 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -420,6 +420,107 @@ fn blacklist_dev_input_hidden() { ); } +#[test] +fn blacklist_root_is_readonly() { + let output = sandbox(&[]) + .args([ + "--", + "bash", + "-c", + "touch /rootfile 2>&1 && echo WRITABLE || echo READONLY; \ + mkdir /newdir 2>&1 && echo MKDIR_OK || echo MKDIR_FAIL", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("READONLY"), + "expected root to be read-only in blacklist mode, got: {stdout}" + ); + assert!( + stdout.contains("MKDIR_FAIL"), + "expected mkdir at root to fail in blacklist mode, got: {stdout}" + ); +} + +#[test] +fn whitelist_root_is_readonly() { + let output = sandbox(&["--whitelist"]) + .args([ + "--", + "bash", + "-c", + "touch /rootfile 2>&1 && echo WRITABLE || echo READONLY; \ + mkdir /newdir 2>&1 && echo MKDIR_OK || echo MKDIR_FAIL", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("READONLY"), + "expected root to be read-only in whitelist mode, got: {stdout}" + ); + assert!( + stdout.contains("MKDIR_FAIL"), + "expected mkdir at root to fail in whitelist mode, got: {stdout}" + ); +} + +#[test] +fn whitelist_mountpoint_parents_are_readonly() { + let output = sandbox(&["--whitelist"]) + .args([ + "--", + "bash", + "-c", + "echo pwned > /home/testfile 2>&1 && echo HOME_WRITABLE || echo HOME_READONLY; \ + touch /etc/newfile 2>&1 && echo ETC_WRITABLE || echo ETC_READONLY; \ + touch /var/newfile 2>&1 && echo VAR_WRITABLE || echo VAR_READONLY", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("HOME_READONLY"), + "expected /home to be read-only in whitelist mode, got: {stdout}" + ); + assert!( + stdout.contains("ETC_READONLY"), + "expected /etc to be read-only in whitelist mode, got: {stdout}" + ); + assert!( + stdout.contains("VAR_READONLY"), + "expected /var to be read-only in whitelist mode, got: {stdout}" + ); +} + +#[test] +fn whitelist_tmp_still_writable() { + let output = sandbox(&["--whitelist"]) + .args([ + "--", + "bash", + "-c", + "touch /tmp/ok 2>&1 && echo TMP_WRITABLE || echo TMP_READONLY; \ + touch /var/tmp/ok 2>&1 && echo VARTMP_WRITABLE || echo VARTMP_READONLY", + ]) + .output() + .expect("agent-sandbox binary failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("TMP_WRITABLE"), + "expected /tmp to remain writable in whitelist mode, got: {stdout}" + ); + assert!( + stdout.contains("VARTMP_WRITABLE"), + "expected /var/tmp to remain writable in whitelist mode, got: {stdout}" + ); +} + #[test] fn rw_missing_path_errors() { let output = sandbox(&["--rw", "/nonexistent/xyz"])