Skip to content

Commit 4b0b687

Browse files
committed
fix(sandbox): warn when capability path is on a 9P filesystem
Landlock grants for paths on 9P mounts (WSL2 Windows host paths under /mnt/c, /mnt/d, QEMU virtfs) are accepted by the kernel but silently have no enforcement effect, or are incompletely enforced depending on the kernel version. This leaves the sandbox in an undefined state: paths the user granted may remain blocked (default-deny fires) or unrestricted (grant is a no-op), with no indication why. Emit a warning before adding a Landlock rule for any path on a 9P filesystem so users know their sandbox has a hole on that path and are directed to move their working directory to a native Linux filesystem. Detection uses statfs(2) and checks f_type against V9FS_MAGIC (0x01021997). The syscall is skipped entirely for paths not under /mnt to avoid any overhead on the common case. Warnings are deduplicated per path so a profile with many capabilities under the same mount emits one warning, not one per capability. Signed-off-by: Aleksy Siek <aleksy@alwaysfurther.ai>
1 parent 0153757 commit 4b0b687

1 file changed

Lines changed: 128 additions & 0 deletions

File tree

crates/nono/src/sandbox/linux.rs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,59 @@ fn can_use_seccomp_network_block_fallback(caps: &CapabilitySet) -> bool {
385385
)
386386
}
387387

388+
/// Linux `statfs` magic number for the 9P filesystem (`v9fs`).
389+
///
390+
/// Covers all 9P-backed mounts: WSL2 Windows host paths (`/mnt/c`, `/mnt/d`),
391+
/// QEMU virtfs, and other Plan 9 mounts. The 9P driver does not implement the
392+
/// LSM inode hooks that Landlock relies on, so `PathBeneath` rules for these
393+
/// paths are accepted by the kernel but silently have no enforcement effect.
394+
const V9FS_MAGIC: libc::c_long = 0x0102_1997;
395+
396+
/// Return `true` if `f_type` (from `statfs::f_type`) identifies a filesystem
397+
/// that does not support Landlock enforcement.
398+
#[inline]
399+
fn fs_type_unsupported(f_type: libc::c_long) -> bool {
400+
f_type == V9FS_MAGIC
401+
}
402+
403+
/// Return `true` if `path` sits on a filesystem that does not support Landlock.
404+
///
405+
/// Currently detects 9P mounts (`V9FS_MAGIC`), which includes WSL2 Windows
406+
/// host paths and QEMU virtfs. Uses `statfs(2)` on the path itself; falls back
407+
/// to `false` on any error so a detection failure never blocks sandbox startup.
408+
///
409+
/// Skips the syscall entirely for paths that cannot be on a 9P mount
410+
/// (anything not under `/mnt`), avoiding overhead on the common case.
411+
///
412+
/// Returns the device ID (`st_dev`) of the mount when unsupported, so the
413+
/// caller can deduplicate warnings per mount rather than per path. Returns
414+
/// `None` for supported filesystems or on any `statfs` error.
415+
fn unsupported_filesystem_dev(path: &Path) -> Option<u64> {
416+
if !path.starts_with("/mnt") {
417+
return None;
418+
}
419+
use std::ffi::CString;
420+
use std::mem::MaybeUninit;
421+
let Ok(cpath) = CString::new(path.as_os_str().as_encoded_bytes()) else {
422+
return None;
423+
};
424+
// SAFETY: `buf` is a valid out-pointer for `statfs`; we initialise it
425+
// through the syscall before reading any field.
426+
unsafe {
427+
let mut buf: MaybeUninit<libc::statfs> = MaybeUninit::uninit();
428+
if libc::statfs(cpath.as_ptr(), buf.as_mut_ptr()) != 0 {
429+
return None;
430+
}
431+
let stat = buf.assume_init();
432+
if fs_type_unsupported(stat.f_type) {
433+
// fsid_t.__val is private; transmute the 8-byte struct to u64 for dedup.
434+
Some(std::mem::transmute::<libc::fsid_t, u64>(stat.f_fsid))
435+
} else {
436+
None
437+
}
438+
}
439+
}
440+
388441
/// Check if a path is a character or block device file.
389442
///
390443
/// Used to selectively grant `IoctlDev` only for actual device files
@@ -684,6 +737,10 @@ pub fn apply_with_abi(caps: &CapabilitySet, abi: &DetectedAbi) -> Result<Seccomp
684737
// are not distinguishable on this Linux path until the seccomp AF_UNIX
685738
// allowlist work enforces UnixSocketCapability::covers().
686739
let ioctl_dev_available = AccessFs::from_all(target_abi).contains(AccessFs::IoctlDev);
740+
// Track device IDs of mounts already warned about to emit one warning
741+
// per mount, not one per capability path.
742+
let mut warned_unsupported_devs: std::collections::HashSet<u64> =
743+
std::collections::HashSet::new();
687744

688745
for cap in caps.fs_capabilities() {
689746
let result = access_to_landlock(cap.access, target_abi);
@@ -715,6 +772,18 @@ pub fn apply_with_abi(caps: &CapabilitySet, abi: &DetectedAbi) -> Result<Seccomp
715772
);
716773
}
717774

775+
if let Some(dev) = unsupported_filesystem_dev(&cap.resolved)
776+
&& warned_unsupported_devs.insert(dev)
777+
{
778+
warn!(
779+
"Path '{}' is on a 9P filesystem (e.g. WSL2 Windows host mount, QEMU virtfs). \
780+
Landlock enforcement on 9P paths is unreliable — grants may be silently ignored \
781+
or incompletely enforced, causing unexpected access denials. \
782+
Move your working directory to a native Linux filesystem to use nono safely.",
783+
cap.resolved.display()
784+
);
785+
}
786+
718787
debug!(
719788
"Adding rule: {} with access {:?}",
720789
cap.resolved.display(),
@@ -4162,4 +4231,63 @@ mod tests {
41624231
);
41634232
}
41644233
}
4234+
4235+
// fs_type_unsupported: pure logic, runs on any CI platform
4236+
4237+
#[test]
4238+
fn test_fs_type_unsupported_v9fs_magic() {
4239+
assert!(
4240+
fs_type_unsupported(V9FS_MAGIC),
4241+
"V9FS_MAGIC must be unsupported"
4242+
);
4243+
}
4244+
4245+
#[test]
4246+
fn test_fs_type_unsupported_known_supported_types() {
4247+
const EXT4_MAGIC: libc::c_long = 0xEF53;
4248+
const TMPFS_MAGIC: libc::c_long = 0x0102_1994;
4249+
const PROC_MAGIC: libc::c_long = 0x9FA0;
4250+
assert!(!fs_type_unsupported(EXT4_MAGIC));
4251+
assert!(!fs_type_unsupported(TMPFS_MAGIC));
4252+
assert!(!fs_type_unsupported(PROC_MAGIC));
4253+
assert!(!fs_type_unsupported(0));
4254+
}
4255+
4256+
// unsupported_filesystem_dev: exercises the statfs syscall path
4257+
4258+
#[test]
4259+
fn test_unsupported_filesystem_dev_native_paths() {
4260+
// /tmp and /proc are native Linux filesystems, never 9P.
4261+
assert!(
4262+
unsupported_filesystem_dev(std::path::Path::new("/tmp")).is_none(),
4263+
"/tmp should be on a supported filesystem"
4264+
);
4265+
assert!(
4266+
unsupported_filesystem_dev(std::path::Path::new("/proc")).is_none(),
4267+
"/proc should be on a supported filesystem"
4268+
);
4269+
}
4270+
4271+
#[test]
4272+
fn test_unsupported_filesystem_dev_nonexistent_path() {
4273+
// statfs fails on a nonexistent path — must return None, not panic.
4274+
assert!(
4275+
unsupported_filesystem_dev(std::path::Path::new("/nonexistent-nono-test-path-xyz"))
4276+
.is_none(),
4277+
"nonexistent path should return None, not panic"
4278+
);
4279+
}
4280+
4281+
#[test]
4282+
fn test_unsupported_filesystem_dev_wsl2_mount() {
4283+
// On a real WSL2 system /mnt/c is a 9P mount and must be detected.
4284+
// Skipped on native Linux where /mnt/c doesn't exist.
4285+
let mnt_c = std::path::Path::new("/mnt/c");
4286+
if mnt_c.exists() {
4287+
assert!(
4288+
unsupported_filesystem_dev(mnt_c).is_some(),
4289+
"/mnt/c exists but was not detected as a 9P filesystem"
4290+
);
4291+
}
4292+
}
41654293
}

0 commit comments

Comments
 (0)