Add Landlock sandbox for code execution
- sandbox.rs: Landlock filesystem restrictions applied via pre_exec - Library paths (/lib, /usr/lib) need execute permission for dynamic linker - 30s timeout with SIGKILL on expiry - Network access allowed (Landlock only restricts filesystem) - Minimal env: PATH, HOME=/tmp, LANG, TERM
This commit is contained in:
parent
445177bbfa
commit
17f18b044c
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// Paths with read-execute access.
|
||||
pub rox_paths: Vec<String>,
|
||||
/// Paths with read-write access.
|
||||
pub rw_paths: Vec<String>,
|
||||
/// 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<ExecResult> {
|
||||
// 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<PathBeneath<PathFd>> = 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ExecResult> {
|
||||
fn execute_block(&self, block: &SourceBlock) -> Result<sandbox::ExecResult> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue