Skip to main content

sandbox_run/platform/
linux.rs

1use std::path::{Path, PathBuf};
2
3use crate::{
4    error::SandboxError,
5    sandbox::{ProtectHome, ProtectSystem, SandboxPolicy},
6};
7use landlock::{
8    Access, AccessFs, AccessNet, BitFlags, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset,
9    RulesetAttr, RulesetCreated, RulesetCreatedAttr, ABI,
10};
11
12// libc's setrlimit takes the resource argument as
13// `__rlimit_resource_t` (a u32 alias) on glibc but as `c_int` on
14// musl — the typedef is glibc-only in the `libc` crate. Alias the
15// per-target type once so the call site doesn't have to know which.
16#[cfg(target_env = "gnu")]
17type RlimitResource = libc::__rlimit_resource_t;
18#[cfg(not(target_env = "gnu"))]
19type RlimitResource = libc::c_int;
20
21/// Apply the sandbox policy using Linux-specific mechanisms.
22pub fn apply_sandbox(policy: &SandboxPolicy) -> Result<(), SandboxError> {
23    let config = &policy.config;
24
25    // 1. NoNewPrivileges first (cannot be undone)
26    if config.no_new_privileges {
27        apply_no_new_privs()?;
28    }
29
30    // 2. Resource limits
31    apply_resource_limits(config)?;
32
33    // 3. Landlock filesystem + network restrictions
34    apply_landlock(policy)?;
35
36    Ok(())
37}
38
39fn apply_no_new_privs() -> Result<(), SandboxError> {
40    let ret = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
41    if ret != 0 {
42        return Err(SandboxError::Io(std::io::Error::last_os_error()));
43    }
44    Ok(())
45}
46
47fn apply_resource_limits(config: &crate::sandbox::Sandbox) -> Result<(), SandboxError> {
48    let limits: [(Option<u64>, RlimitResource); 8] = [
49        (config.limit_nofile, libc::RLIMIT_NOFILE),
50        (config.limit_nproc, libc::RLIMIT_NPROC),
51        (config.limit_core, libc::RLIMIT_CORE),
52        (config.limit_fsize, libc::RLIMIT_FSIZE),
53        (config.limit_data, libc::RLIMIT_DATA),
54        (config.limit_stack, libc::RLIMIT_STACK),
55        (config.limit_cpu, libc::RLIMIT_CPU),
56        (config.limit_memlock, libc::RLIMIT_MEMLOCK),
57    ];
58
59    for (value, resource) in limits {
60        if let Some(limit) = value {
61            let rlim = libc::rlimit {
62                rlim_cur: limit,
63                rlim_max: limit,
64            };
65            let ret = unsafe { libc::setrlimit(resource, &rlim) };
66            if ret != 0 {
67                return Err(SandboxError::Io(std::io::Error::last_os_error()));
68            }
69        }
70    }
71    Ok(())
72}
73
74fn apply_landlock(policy: &SandboxPolicy) -> Result<(), SandboxError> {
75    let config = &policy.config;
76
77    // Check if any restrictions apply
78    let has_fs_restrictions = config.protect_system != ProtectSystem::No
79        || config.protect_home != ProtectHome::No
80        || !config.read_only_paths.is_empty()
81        || !config.read_write_paths.is_empty()
82        || !config.inaccessible_paths.is_empty()
83        || !config.exec_paths.is_empty()
84        || !config.no_exec_paths.is_empty();
85
86    let has_net_restrictions = config.private_network;
87
88    if !has_fs_restrictions && !has_net_restrictions {
89        return Ok(());
90    }
91
92    // Define access rights
93    let read_access = AccessFs::ReadFile | AccessFs::ReadDir;
94    let write_access = AccessFs::WriteFile
95        | AccessFs::RemoveFile
96        | AccessFs::RemoveDir
97        | AccessFs::MakeChar
98        | AccessFs::MakeDir
99        | AccessFs::MakeReg
100        | AccessFs::MakeSock
101        | AccessFs::MakeFifo
102        | AccessFs::MakeBlock
103        | AccessFs::MakeSym
104        | AccessFs::Truncate;
105    let exec_access = AccessFs::Execute;
106    // `Refer` (cross-directory rename/link) is ABI v2+ (kernel ≥5.19).
107    // Landlock denies *all* reparenting by default whenever a ruleset is
108    // in force, unless the ruleset both handles `Refer` and grants it on
109    // the source and destination directories. Without it, every
110    // `rename(2)`/`link(2)` that crosses a directory fails inside a
111    // sandboxed service — even entirely within its own writable tree
112    // (e.g. mariadb `RENAME TABLE` across per-database subdirs, or any
113    // tmp-dir→final-dir atomic-move pattern). Granting it alongside the
114    // write rights fixes that; `BestEffort` compatibility (the
115    // `Ruleset::default()` level) silently drops it on pre-5.19 kernels,
116    // where cross-directory reparenting isn't restricted anyway.
117    let all_fs_access = read_access | write_access | exec_access | AccessFs::Refer;
118
119    // Build ruleset
120    let mut ruleset_builder = Ruleset::default().handle_access(all_fs_access)?;
121
122    // Handle network restrictions if needed (Landlock v4+)
123    if has_net_restrictions {
124        ruleset_builder =
125            ruleset_builder.handle_access(AccessNet::BindTcp | AccessNet::ConnectTcp)?;
126    }
127
128    let mut ruleset = ruleset_builder.create()?;
129
130    // Paths carved entirely out of the base grant. Landlock is
131    // allow-list only — the sole way to make a subtree inaccessible is
132    // to never grant it and instead grant each of its siblings on the
133    // way down (`grant_beneath_excluding`). This is what makes
134    // `inaccessible_paths` and `ProtectHome` actually deny rather than
135    // silently no-op.
136    let mut deny: Vec<PathBuf> = config.inaccessible_paths.clone();
137    if config.protect_home == ProtectHome::Yes {
138        // systemd ProtectHome=yes: the standard home trees are
139        // inaccessible. (`ReadOnly` is a no-op under Strict, where the
140        // whole FS is already read-only; under a writable base it can't
141        // be expressed by an allow-list and stays best-effort.)
142        deny.push(PathBuf::from("/home"));
143        deny.push(PathBuf::from("/root"));
144        deny.push(PathBuf::from("/run/user"));
145    }
146    let deny = canonicalize_existing(&deny);
147
148    // Base access granted across the whole filesystem minus `deny`.
149    let base_access = match config.protect_system {
150        // Strict: read + execute everywhere; writes only through the
151        // explicit read_write_paths carve-ins below.
152        ProtectSystem::Strict => read_access | exec_access,
153        // No / Yes / Full: full access as the base. Yes/Full's intent
154        // (system dirs read-only) can't be expressed by an allow-list
155        // and stays best-effort; the security-relevant controls
156        // (inaccessible_paths, ProtectHome=yes) are enforced via `deny`.
157        ProtectSystem::No | ProtectSystem::Yes | ProtectSystem::Full => all_fs_access,
158    };
159    ruleset = grant_beneath_excluding(ruleset, Path::new("/"), base_access, &deny)?;
160
161    // Carve-ins: explicit grants that re-open access to paths sitting
162    // *under* a denied subtree — e.g. a service's own data/run/log dirs
163    // and the read-only store, which live under $HOME and would
164    // otherwise be hidden by ProtectHome=yes. Landlock unions rule
165    // rights, and nothing broader grants these paths (their parents are
166    // carved out), so these rules define their exact access.
167    for path in &config.read_write_paths {
168        if let Ok(fd) = PathFd::new(path) {
169            ruleset = ruleset.add_rule(PathBeneath::new(fd, all_fs_access))?;
170        }
171    }
172    for path in &config.read_only_paths {
173        if let Ok(fd) = PathFd::new(path) {
174            ruleset = ruleset.add_rule(PathBeneath::new(fd, read_access | exec_access))?;
175        }
176    }
177    for path in &config.exec_paths {
178        if let Ok(fd) = PathFd::new(path) {
179            ruleset = ruleset.add_rule(PathBeneath::new(fd, exec_access))?;
180        }
181    }
182
183    // `no_exec_paths` would need the exec grant carved around them the
184    // same way `deny` carves the read grant; left best-effort for now
185    // (no caller uses it). Network: handling BindTcp/ConnectTcp while
186    // adding no net rules denies all TCP; not handling it allows all.
187
188    // Fail OPEN. `BestEffort` (the default) downgrades to a no-op on
189    // kernels < 5.13 or when Landlock is disabled, and `restrict_self`
190    // then reports `NotEnforced`. By request we proceed unconfined
191    // rather than refusing to launch the service. We can't warn from
192    // here (this runs in the post-fork pre_exec, an async-signal
193    // context), so the daemon probes `landlock_available()` up front
194    // and logs once when services will run without confinement. A
195    // genuine error from `restrict_self` (not mere non-enforcement) is
196    // still propagated.
197    let _status = ruleset.restrict_self()?;
198    Ok(())
199}
200
201/// Grant `access` to everything under `dir` except the subtrees listed
202/// in `deny` (and everything beneath them).
203///
204/// Landlock has no "deny" rule: access is the union of every matching
205/// `PathBeneath` grant, so a broad grant can't be narrowed by a more
206/// specific one. To exclude a subtree we therefore never grant it and
207/// instead grant each sibling along the path to it, recursing only into
208/// the ancestor directories that actually contain a denied path. The
209/// walk touches just those ancestors (e.g. `/` then `/home` for
210/// `/home/<user>`), not the whole filesystem.
211///
212/// `deny` entries must be canonical absolute paths (see
213/// `canonicalize_existing`).
214fn grant_beneath_excluding(
215    mut ruleset: RulesetCreated,
216    dir: &Path,
217    access: BitFlags<AccessFs>,
218    deny: &[PathBuf],
219) -> Result<RulesetCreated, SandboxError> {
220    // If no denied path lives at or under `dir`, grant the whole subtree
221    // in one rule and stop descending.
222    if !deny.iter().any(|d| d.starts_with(dir)) {
223        if let Ok(fd) = PathFd::new(dir) {
224            ruleset = ruleset.add_rule(PathBeneath::new(fd, access))?;
225        }
226        return Ok(ruleset);
227    }
228
229    // Otherwise descend: grant children clear of any denied path,
230    // recurse into children that contain one, and skip exact matches
231    // (the holes themselves). If the directory can't be read we grant
232    // nothing under it — failing toward more confinement, not less.
233    let Ok(entries) = std::fs::read_dir(dir) else {
234        return Ok(ruleset);
235    };
236    for entry in entries.flatten() {
237        // Resolve symlinks so the comparisons against the canonical
238        // `deny` set are accurate (a symlinked `/home` must still match)
239        // and so the grant targets the real path rather than the link.
240        // A child that can't be canonicalized (e.g. a dangling symlink)
241        // is left ungranted — again erring toward confinement.
242        let Ok(child) = std::fs::canonicalize(entry.path()) else {
243            continue;
244        };
245        if deny.contains(&child) {
246            continue;
247        }
248        if deny.iter().any(|d| d.starts_with(&child)) {
249            ruleset = grant_beneath_excluding(ruleset, &child, access, deny)?;
250        } else if let Ok(fd) = PathFd::new(&child) {
251            ruleset = ruleset.add_rule(PathBeneath::new(fd, access))?;
252        }
253    }
254    Ok(ruleset)
255}
256
257/// Canonicalize deny paths and drop any that don't resolve. A path that
258/// doesn't exist has nothing to hide, and canonical paths are required
259/// so the prefix checks in `grant_beneath_excluding` line up with the
260/// canonical entries returned by `read_dir`/`PathFd`.
261fn canonicalize_existing(paths: &[PathBuf]) -> Vec<PathBuf> {
262    paths
263        .iter()
264        .filter_map(|p| std::fs::canonicalize(p).ok())
265        .collect()
266}
267
268/// Whether the running kernel actually enforces Landlock (Linux 5.13+
269/// with Landlock enabled). This is a non-restricting probe: it builds a
270/// throwaway ruleset under `HardRequirement` compatibility — which
271/// errors instead of silently downgrading when the feature is missing —
272/// and never calls `restrict_self`, so the calling process is
273/// unaffected.
274///
275/// Intended for callers that fail open (see `apply_landlock`) but want
276/// to warn an operator that spawned processes will run unconfined.
277#[must_use]
278pub fn landlock_available() -> bool {
279    Ruleset::default()
280        .set_compatibility(CompatLevel::HardRequirement)
281        .handle_access(AccessFs::from_all(ABI::V1))
282        .and_then(|r| r.create())
283        .is_ok()
284}