Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@ struct RunArgs {
allow_degraded: Vec<String>,

/// Disable the named protection entirely (no rule emitted, no error on missing ABI).
/// Repeatable. Accepts the same values as --allow-degraded.
/// Repeatable. Accepts the same values as --allow-degraded, except fs-refer:
/// the kernel denies REFER by default even when unhandled, so disabling it
/// only tightens the sandbox and is rejected.
#[arg(long = "disable", value_name = "PROTECTION")]
disable: Vec<String>,

Expand Down
10 changes: 9 additions & 1 deletion crates/sandlock-core/src/landlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,15 @@ fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError
// The PT_INTERP patching in handle_chroot_exec ensures the kernel loads
// the image's ELF interpreter via an injected fd, not a host path.
let chroot_root = policy.chroot.as_deref();
let fs_write_mask = write_access(abi);
// Intersect the per-path write mask with the resolved handled set so
// every installed rule is a subset of `handled_access_fs` by
// construction. `compute_fs_mask` drops the REFER/TRUNCATE/IOCTL_DEV
// bit for any FS protection that is Disabled or Degraded; without this
// intersection the writable-path rule would still request the dropped
// bit and `landlock_add_rule` would reject it with EINVAL. (The file
// path inside `add_path_rule` further narrows this with `& ACCESS_FILE`,
// which preserves the subset property.)
let fs_write_mask = write_access(abi) & handled_access_fs;
for path in &policy.fs_writable {
let host;
let rule_path = if let Some(root) = chroot_root {
Expand Down
27 changes: 27 additions & 0 deletions crates/sandlock-core/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1967,6 +1967,14 @@ impl SandboxBuilder {
/// it. Intended for workloads that legitimately need the capability
/// the protection blocks (e.g. signalling a sibling process when
/// `SignalScope` would normally prevent it).
///
/// `Protection::FsRefer` cannot be disabled: Landlock denies REFER
/// (cross-directory rename/link) by default in every ruleset even when
/// it is not handled, so disabling it only tightens the sandbox rather
/// than loosening it. `build()` (and `build_unchecked()`) return
/// `SandboxError::Invalid` if `disable(Protection::FsRefer)` was called.
/// Use [`allow_degraded`](Self::allow_degraded) if you want REFER
/// enforced only where the kernel supports it.
pub fn disable(mut self, protection: Protection) -> Self {
self.protection_policy.set(protection, ProtectionState::Disabled);
self
Expand Down Expand Up @@ -2228,6 +2236,25 @@ impl SandboxBuilder {
pub fn build_unchecked(self) -> Result<Sandbox, SandboxError> {
validate_syscall_names(&self.extra_deny_syscalls)?;

// Reject disable(FsRefer): the kernel denies REFER (cross-directory
// rename/link) by default in every ruleset even when REFER is not
// handled. Controlled cross-directory rename within writable areas
// works precisely *because* REFER is handled and granted on writable
// paths (the Strict and Degradable states do this). Disabling REFER
// un-handles it, which can only make rename stricter, never looser,
// so it cannot do what disable() promises and is a footgun. Degrading
// (allow_degraded) REFER is still meaningful and remains allowed.
if self.protection_policy.state(Protection::FsRefer) == ProtectionState::Disabled {
return Err(SandboxError::Invalid(
"disable(Protection::FsRefer) is not permitted: Landlock denies \
REFER (cross-directory rename/link) by default even when it is \
not handled, so disabling it only tightens the sandbox, never \
loosens it. Remove the disable() call (use allow_degraded() if \
you wanted REFER enforced only where the kernel supports it)."
.into(),
));
}

// Validate: max_cpu must be 1-100
if let Some(cpu) = self.max_cpu {
if cpu == 0 || cpu > 100 {
Expand Down
107 changes: 107 additions & 0 deletions crates/sandlock-core/tests/integration/test_protection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,3 +353,110 @@ fn protection_policy_survives_bincode_round_trip() {
not reset to strict_all() on load"
);
}

// ----------------------------------------------------------------------
// Regression: disable() of an FS protection must not cause confine to
// fail with EINVAL when the sandbox has a writable path.
//
// `compute_fs_mask` drops the disabled bit (REFER/TRUNCATE/IOCTL_DEV)
// from `handled_access_fs`, but the per-path write mask is derived from
// `write_access(abi)` alone. If the two are not intersected, the
// writable-path rule still requests the dropped bit, which is no longer
// a subset of the ruleset's handled accesses, and `landlock_add_rule`
// rejects it with EINVAL, breaking every real sandbox (one that has at
// least one writable path) under `disable(FsRefer/FsTruncate/FsIoctlDev)`.
//
// This must run a real `confine_filesystem` against the host kernel, so
// it forks: confinement is irreversible. NO_NEW_PRIVS is set in the
// child so `restrict_self` succeeds; the child then exits 0 only if
// every `add_path_rule` (including the writable-path rule) was accepted.
// ----------------------------------------------------------------------

/// Run `confine_filesystem(sandbox)` in a forked child and return true
/// iff it succeeds end-to-end (child exits 0). Exit codes: 0 = Ok,
/// 1 = confine_filesystem returned Err (the EINVAL bug), 2 = NO_NEW_PRIVS
/// prctl failed (harness problem, not the code under test).
fn confine_filesystem_succeeds_in_child(sandbox: &sandlock_core::Sandbox) -> i32 {
let pid = unsafe { libc::fork() };
assert!(pid >= 0, "fork failed");
if pid == 0 {
if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
unsafe { libc::_exit(2) };
}
let code = match sandlock_core::landlock::confine_filesystem(sandbox) {
Ok(()) => 0,
Err(_) => 1,
};
unsafe { libc::_exit(code) };
}
let mut status: i32 = 0;
unsafe { libc::waitpid(pid, &mut status, 0) };
assert!(libc::WIFEXITED(status), "child did not exit normally");
libc::WEXITSTATUS(status)
}

#[test]
fn disable_fs_protection_with_writable_path_does_not_einval() {
// Needs a real v6+ host: the default policy keeps the v6 scope
// protections Strict, so confine_filesystem would otherwise abort
// with ProtectionUnavailable before reaching the FS rules.
if sandlock_core::landlock_abi_version().unwrap_or(0) < 6 {
eprintln!("Skipping: Landlock ABI v6 required");
return;
}

// A writable path is the trigger: without one, no write rule is
// installed and the handled-vs-rule inconsistency never surfaces.
// (FsRefer is excluded: disabling it is rejected at build time; see
// `disable_fsrefer_is_rejected_at_build` below.)
for p in [Protection::FsTruncate, Protection::FsIoctlDev] {
let sandbox = sandlock_core::Sandbox::builder()
.disable(p)
.fs_write("/tmp")
.build()
.expect("build sandbox");

assert_eq!(
confine_filesystem_succeeds_in_child(&sandbox),
0,
"disable({:?}) + fs_write(\"/tmp\") must confine cleanly, \
not fail with EINVAL when installing the writable-path rule",
p
);
}
}

// ----------------------------------------------------------------------
// disable(FsRefer) is a footgun: Landlock denies REFER by default even
// when unhandled, so disabling it can only tighten the sandbox, never
// loosen it (contrary to what `disable()` promises). It is rejected at
// build time. `allow_degraded(FsRefer)` stays meaningful and is allowed.
// ----------------------------------------------------------------------

#[test]
fn disable_fsrefer_is_rejected_at_build() {
let err = sandlock_core::Sandbox::builder()
.disable(Protection::FsRefer)
.build()
.expect_err("disable(FsRefer) must be rejected at build");
let msg = err.to_string();
assert!(
msg.contains("FsRefer"),
"rejection message should name FsRefer, got: {msg}"
);

// The unchecked path must reject it too.
assert!(
sandlock_core::Sandbox::builder()
.disable(Protection::FsRefer)
.build_unchecked()
.is_err(),
"build_unchecked must also reject disable(FsRefer)"
);

// allow_degraded(FsRefer) is still meaningful and must build cleanly.
sandlock_core::Sandbox::builder()
.allow_degraded(Protection::FsRefer)
.build()
.expect("allow_degraded(FsRefer) must remain allowed");
}
Loading