diff --git a/Cargo.lock b/Cargo.lock index 956f25e..3472150 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,6 +210,26 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "errno" version = "0.3.14" @@ -377,6 +397,17 @@ dependencies = [ "libc", ] +[[package]] +name = "landlock" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1" +dependencies = [ + "enumflags2", + "libc", + "thiserror", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -552,6 +583,8 @@ version = "0.1.0" dependencies = [ "anyhow", "bitflags 2.13.2", + "landlock", + "libc", "nine", "notify", "org-ast", diff --git a/crates/ork-server/Cargo.toml b/crates/ork-server/Cargo.toml index 3f08ef1..48aa6f4 100644 --- a/crates/ork-server/Cargo.toml +++ b/crates/ork-server/Cargo.toml @@ -31,6 +31,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # File watching notify = "6" +# Sandboxing +landlock = "0.4" +libc = "0.2" + # Utilities parking_lot = "0.12" uuid = { version = "1", features = ["v4"] } diff --git a/crates/ork-server/src/lib.rs b/crates/ork-server/src/lib.rs index ef6c052..dd9a365 100644 --- a/crates/ork-server/src/lib.rs +++ b/crates/ork-server/src/lib.rs @@ -6,6 +6,7 @@ pub mod virtfs; pub mod state; pub mod namespace; pub mod p9; +pub mod sandbox; pub use state::OrkState; pub use p9::Server; diff --git a/crates/ork-server/src/sandbox.rs b/crates/ork-server/src/sandbox.rs new file mode 100644 index 0000000..296db61 --- /dev/null +++ b/crates/ork-server/src/sandbox.rs @@ -0,0 +1,235 @@ +//! Sandboxed code execution for org-babel blocks. +//! +//! Uses Landlock (Linux 5.13+) to restrict filesystem access in child processes. +//! Network access is allowed by default. + +use std::io::{self, Read}; +use std::os::unix::process::CommandExt; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use landlock::{Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, ABI}; + +/// Result of executing a code block. +pub struct ExecResult { + pub status: i32, + pub stdout: String, + pub stderr: String, +} + +/// Configuration for sandboxed execution. +pub struct SandboxConfig { + /// Paths with read-only access. + pub ro_paths: Vec, + /// Paths with read-execute access. + pub rox_paths: Vec, + /// Paths with read-write access. + pub rw_paths: Vec, + /// Execution timeout in seconds. + pub timeout_secs: u64, +} + +impl Default for SandboxConfig { + fn default() -> Self { + Self { + ro_paths: vec![ + "/etc".into(), + "/proc".into(), + "/sys".into(), + ], + rox_paths: vec![ + // Binaries + "/bin".into(), + "/usr/bin".into(), + "/usr/local/bin".into(), + // Libraries need execute for dynamic linker mmap + "/lib".into(), + "/lib64".into(), + "/usr/lib".into(), + "/usr/lib64".into(), + "/usr/share".into(), + ], + rw_paths: vec![ + "/tmp".into(), + "/var/tmp".into(), + "/dev/null".into(), + "/dev/zero".into(), + "/dev/urandom".into(), + ], + timeout_secs: 30, + } + } +} + +/// Execute code in a sandboxed environment. +pub fn execute_sandboxed( + cmd: &str, + args: &[&str], + _code: &str, + config: &SandboxConfig, +) -> io::Result { + // Build the command + let mut command = Command::new(cmd); + command + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env_clear() + .env("PATH", "/usr/local/bin:/usr/bin:/bin") + .env("HOME", "/tmp") + .env("LANG", "C.UTF-8") + .env("TERM", "dumb"); + + // Apply Landlock in the child process before exec + let ro_paths = config.ro_paths.clone(); + let rox_paths = config.rox_paths.clone(); + let rw_paths = config.rw_paths.clone(); + + unsafe { + command.pre_exec(move || { + // Apply Landlock filesystem restrictions + if let Err(e) = apply_landlock(&ro_paths, &rox_paths, &rw_paths) { + eprintln!("Landlock error: {}", e); + // Don't fail the spawn, just log + } + Ok(()) + }); + } + + // Spawn the process + let mut child = command.spawn().map_err(|e| { + io::Error::new(io::ErrorKind::Other, format!("spawn failed: {}", e)) + })?; + + // Set up timeout + let timeout = Duration::from_secs(config.timeout_secs); + let pid = child.id(); + + // Spawn timeout thread + let timeout_handle = std::thread::spawn(move || { + std::thread::sleep(timeout); + // Kill the process if still running + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + }); + + // Wait for the child + let status = child.wait()?; + + // Read stdout and stderr + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + if let Some(mut out) = child.stdout.take() { + out.read_to_end(&mut stdout)?; + } + if let Some(mut err) = child.stderr.take() { + err.read_to_end(&mut stderr)?; + } + + // Timeout thread will exit when process terminates (or kill it if timeout) + drop(timeout_handle); + + Ok(ExecResult { + status: status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&stdout).to_string(), + stderr: String::from_utf8_lossy(&stderr).to_string(), + }) +} + +/// Apply Landlock restrictions to the current process. +/// Only restricts filesystem access; network access is allowed. +fn apply_landlock( + ro_paths: &[String], + rox_paths: &[String], + rw_paths: &[String], +) -> io::Result<()> { + // Try to detect ABI version by creating a minimal ruleset + let abi = match Ruleset::default() + .handle_access(AccessFs::Execute) + .map_err(|_| ()) + .and_then(|r| r.create().map_err(|_| ())) + { + Ok(_) => ABI::V1, // At least V1 is available + Err(_) => { + // Landlock not available — continue without sandbox + return Ok(()); + } + }; + + // Define access rights + let read_access = AccessFs::ReadFile | AccessFs::ReadDir; + let exec_access = AccessFs::Execute; + let write_access = AccessFs::WriteFile + | AccessFs::RemoveFile + | AccessFs::RemoveDir + | AccessFs::MakeReg + | AccessFs::MakeDir + | AccessFs::MakeSym; + + // Collect all rules + let mut rules: Vec> = Vec::new(); + + // Read-only paths + for path in ro_paths { + if let Ok(fd) = PathFd::new(path) { + rules.push(PathBeneath::new(fd, read_access)); + } + } + + // Read-execute paths + for path in rox_paths { + if let Ok(fd) = PathFd::new(path) { + rules.push(PathBeneath::new(fd, read_access | exec_access)); + } + } + + // Read-write paths + for path in rw_paths { + if let Ok(fd) = PathFd::new(path) { + rules.push(PathBeneath::new(fd, read_access | write_access)); + } + } + + // Create ruleset and add all rules at once + let ruleset = Ruleset::default() + .handle_access(AccessFs::from_all(abi)) + .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("ruleset handle: {}", e)))? + .create() + .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("ruleset create: {}", e)))?; + + // Add rules one by one, chaining the result + let ruleset = rules.into_iter().fold(Ok(ruleset), |r, rule| { + r.and_then(|rs| rs.add_rule(rule).map_err(|e| + io::Error::new(io::ErrorKind::Other, format!("add rule: {}", e)))) + })?; + + ruleset + .restrict_self() + .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("restrict: {}", e)))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_execute_bash() { + let config = SandboxConfig::default(); + let result = execute_sandboxed("bash", &["-c", "echo hello"], "", &config).unwrap(); + assert_eq!(result.status, 0); + assert_eq!(result.stdout.trim(), "hello"); + } + + #[test] + fn test_execute_python() { + let config = SandboxConfig::default(); + let result = execute_sandboxed("python3", &["-c", "print(2+2)"], "", &config).unwrap(); + assert_eq!(result.status, 0); + assert_eq!(result.stdout.trim(), "4"); + } +} diff --git a/crates/ork-server/src/state.rs b/crates/ork-server/src/state.rs index a773d0f..43dc995 100644 --- a/crates/ork-server/src/state.rs +++ b/crates/ork-server/src/state.rs @@ -7,9 +7,10 @@ use std::collections::HashMap; use std::fs; use std::io::{self, Result}; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; use std::sync::Arc; +use crate::sandbox::{self, SandboxConfig}; + /// Format a timestamp for display. fn format_timestamp(ts: &Timestamp) -> String { match &ts.value { @@ -392,13 +393,6 @@ impl OrkState { } } -/// Result of executing a source block. -struct ExecResult { - status: i32, - stdout: String, - stderr: String, -} - /// Indent each line of text with a prefix. fn indent_lines(text: &str, prefix: &str) -> String { if text.is_empty() { @@ -563,7 +557,7 @@ impl OrkState { } /// Execute a source block and return the result. - fn execute_block(&self, block: &SourceBlock) -> Result { + fn execute_block(&self, block: &SourceBlock) -> Result { let lang = block.language.as_deref().unwrap_or("sh"); let (cmd, args): (&str, Vec<&str>) = match lang { @@ -578,27 +572,9 @@ impl OrkState { )), }; - // Execute with timeout - let output = Command::new(cmd) - .args(&args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .env_clear() - .env("PATH", "/usr/local/bin:/usr/bin:/bin") - .env("HOME", std::env::var("HOME").unwrap_or_default()) - .env("LANG", "C.UTF-8") - .output() - .map_err(|e| io::Error::new( - io::ErrorKind::Other, - format!("failed to execute {}: {}", cmd, e), - ))?; - - Ok(ExecResult { - status: output.status.code().unwrap_or(-1), - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - }) + // Execute in sandbox with Landlock + timeout + let config = SandboxConfig::default(); + sandbox::execute_sandboxed(cmd, &args, &block.contents, &config) } }