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}