Skip to main content

evalbox_sandbox/isolation/
lockdown.rs

1//! Security lockdown for sandboxed processes.
2//!
3//! Applies all security restrictions to the child process after `pivot_root`.
4//! The order of operations is critical for security:
5//!
6//! 1. **Landlock** - Filesystem and network access control (ABI 4+)
7//! 2. **Seccomp** - Syscall whitelist filter (BPF)
8//! 3. **Rlimits** - Resource limits (memory, CPU, files, processes)
9//! 4. **Capabilities** - Drop all capabilities, set `NO_NEW_PRIVS`
10//! 5. **Close FDs** - Close all file descriptors except stdin/stdout/stderr
11//!
12//! After lockdown, the process cannot:
13//! - Access files outside allowed paths
14//! - Make network connections (if landlock ABI >= 4)
15//! - Call restricted syscalls (ptrace, mount, reboot, etc.)
16//! - Exceed resource limits
17//! - Gain new privileges
18
19use std::ffi::CString;
20use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
21use std::os::unix::ffi::OsStrExt;
22use std::path::Path;
23
24use evalbox_sys::landlock::{
25    self, fs_access_for_abi, landlock_add_rule_path, landlock_create_ruleset,
26    landlock_restrict_self, net_access_for_abi, LandlockPathBeneathAttr, LandlockRulesetAttr,
27    LANDLOCK_ACCESS_FS_EXECUTE, LANDLOCK_ACCESS_FS_MAKE_DIR, LANDLOCK_ACCESS_FS_MAKE_REG,
28    LANDLOCK_ACCESS_FS_READ_DIR, LANDLOCK_ACCESS_FS_READ_FILE, LANDLOCK_ACCESS_FS_REMOVE_DIR,
29    LANDLOCK_ACCESS_FS_REMOVE_FILE, LANDLOCK_ACCESS_FS_TRUNCATE, LANDLOCK_ACCESS_FS_WRITE_FILE,
30};
31use evalbox_sys::last_errno;
32use evalbox_sys::seccomp::{build_whitelist_filter, seccomp_set_mode_filter, SockFprog, DEFAULT_WHITELIST};
33use rustix::io::Errno;
34use thiserror::Error;
35
36use super::rootfs::apply_rlimits;
37use crate::plan::Plan;
38
39/// Error during security lockdown.
40#[derive(Debug, Error)]
41pub enum LockdownError {
42    #[error("landlock: {0}")]
43    Landlock(Errno),
44
45    #[error("seccomp: {0}")]
46    Seccomp(Errno),
47
48    #[error("rlimit: {0}")]
49    Rlimit(Errno),
50
51    #[error("capability: {0}")]
52    Capability(Errno),
53
54    #[error("close fds: {0}")]
55    CloseFds(Errno),
56}
57
58pub fn lockdown(plan: &Plan, workspace_path: Option<&Path>, extra_readonly_paths: &[&str]) -> Result<(), LockdownError> {
59    apply_landlock(plan, workspace_path, extra_readonly_paths)?;
60    apply_seccomp()?;
61    apply_rlimits(plan).map_err(LockdownError::Rlimit)?;
62    drop_all_caps()?;
63    close_extra_fds()?;
64    Ok(())
65}
66
67fn apply_landlock(plan: &Plan, workspace_path: Option<&Path>, extra_readonly_paths: &[&str]) -> Result<(), LockdownError> {
68    let abi = match landlock::landlock_abi_version() {
69        Ok(v) => v,
70        Err(_) => return Ok(()), // Landlock not available
71    };
72
73    let fs_access = fs_access_for_abi(abi);
74    let net_access = if plan.network_blocked && abi >= 4 { net_access_for_abi(abi) } else { 0 };
75
76    let attr = LandlockRulesetAttr { handled_access_fs: fs_access, handled_access_net: net_access };
77    let ruleset_fd = landlock_create_ruleset(&attr).map_err(LockdownError::Landlock)?;
78
79    let read_access = LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR;
80    let write_access = read_access
81        | LANDLOCK_ACCESS_FS_WRITE_FILE
82        | LANDLOCK_ACCESS_FS_MAKE_REG
83        | LANDLOCK_ACCESS_FS_MAKE_DIR
84        | LANDLOCK_ACCESS_FS_REMOVE_FILE
85        | LANDLOCK_ACCESS_FS_REMOVE_DIR
86        | LANDLOCK_ACCESS_FS_TRUNCATE;
87
88    // Read-only paths from plan.mounts (pre-computed by evalbox, includes system paths)
89    for mount in &plan.mounts {
90        if !mount.writable {
91            let access = if mount.executable { read_access } else { read_access & !LANDLOCK_ACCESS_FS_EXECUTE };
92            add_path_rule(&ruleset_fd, &mount.target, access);
93        }
94    }
95
96    for path in extra_readonly_paths {
97        add_path_rule(&ruleset_fd, path, read_access);
98    }
99
100    // Pre-pivot_root workspace path
101    if let Some(ws_path) = workspace_path {
102        add_path_rule(&ruleset_fd, ws_path, write_access);
103    }
104
105    // Writable paths
106    for path in ["/work", "/tmp", "/home"] {
107        add_path_rule(&ruleset_fd, path, write_access);
108    }
109
110    // Proc (read-only)
111    add_path_rule(&ruleset_fd, "/proc", read_access);
112
113    // Dev (read + write for /dev/null etc.)
114    add_path_rule(&ruleset_fd, "/dev", read_access | LANDLOCK_ACCESS_FS_WRITE_FILE);
115
116    landlock_restrict_self(&ruleset_fd).map_err(LockdownError::Landlock)
117}
118
119/// Add a path rule to the Landlock ruleset.
120///
121/// Errors are logged to stderr but not propagated - the path simply won't be
122/// accessible in the sandbox. This is intentional: missing paths (like /nix/store
123/// on non-NixOS) should not prevent sandbox creation.
124fn add_path_rule(ruleset_fd: &OwnedFd, path: impl AsRef<Path>, access: u64) {
125    let path = path.as_ref();
126    let fd = match open_path(path) {
127        Ok(fd) => fd,
128        Err(_) => return, // Path doesn't exist, skip silently
129    };
130
131    let rule = LandlockPathBeneathAttr { allowed_access: access, parent_fd: fd.as_raw_fd() };
132    if let Err(e) = landlock_add_rule_path(ruleset_fd, &rule) {
133        // Log but don't fail - path won't be accessible in sandbox
134        eprintln!("warning: landlock rule for {path:?} failed: {e}");
135    }
136}
137
138#[inline]
139fn open_path(path: impl AsRef<Path>) -> Result<OwnedFd, Errno> {
140    let path_c = CString::new(path.as_ref().as_os_str().as_bytes()).map_err(|_| Errno::INVAL)?;
141    let fd = unsafe { libc::open(path_c.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) };
142    if fd < 0 { Err(last_errno()) } else { Ok(unsafe { OwnedFd::from_raw_fd(fd) }) }
143}
144
145fn apply_seccomp() -> Result<(), LockdownError> {
146    let filter = build_whitelist_filter(DEFAULT_WHITELIST);
147    let fprog = SockFprog { len: filter.len() as u16, filter: filter.as_ptr() };
148    unsafe { seccomp_set_mode_filter(&fprog) }.map_err(LockdownError::Seccomp)
149}
150
151fn drop_all_caps() -> Result<(), LockdownError> {
152    unsafe {
153        libc::prctl(libc::PR_CAP_AMBIENT, libc::PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0);
154        for cap in 0..64 {
155            libc::prctl(libc::PR_CAPBSET_DROP, cap, 0, 0, 0);
156        }
157    }
158
159    let ret = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
160    if ret != 0 { Err(LockdownError::Capability(last_errno())) } else { Ok(()) }
161}
162
163fn close_extra_fds() -> Result<(), LockdownError> {
164    let mut fds_to_close = Vec::new();
165
166    if let Ok(entries) = std::fs::read_dir("/proc/self/fd") {
167        for entry in entries.flatten() {
168            if let Ok(fd) = entry.file_name().to_string_lossy().parse::<RawFd>() {
169                if fd > 2 {
170                    fds_to_close.push(fd);
171                }
172            }
173        }
174    }
175
176    for fd in fds_to_close {
177        unsafe { libc::close(fd) };
178    }
179
180    Ok(())
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn open_path_valid() {
189        assert!(open_path("/tmp").is_ok());
190    }
191}