security: harden landlock ffi
ober
91a0c8522c5b3307cb075aefcf6830bc04e0935b
--- a/docs/ffi-audit.md +++ b/docs/ffi-audit.md @@ -41,9 +41,9 @@ Latest summary from `tools/ffi-audit-report.ss`: (c-file-count 5) (no-mangle-exports 196) (unsafe-sites 429) - (unsafe-sites-with-nearby-safety-comment 189) - (unsafe-sites-without-nearby-safety-comment 240) - (unsafe-comment-review-sites 240) + (unsafe-sites-with-nearby-safety-comment 197) + (unsafe-sites-without-nearby-safety-comment 232) + (unsafe-comment-review-sites 232) (export-review-sites 196)) (vendor (jsqlite (path "vendor/jsqlite") (status accepted-risk-cve-gated))) @@ -120,9 +120,12 @@ buffer copies, and returns normal FFI errors for poisoned server-registry locks. `postgres_native.rs` now funnels SQL and connection-string inputs through a shared checked UTF-8 helper, rejects null/nonzero and oversized FFI buffers, documents result-slot and output-buffer writes, and permits zero-sized -output buffers without manufacturing null slices. The generated inventory now -reports 189 annotated native unsafe sites and 240 remaining unsafe review -sites. +output buffers without manufacturing null slices. `landlock.rs` now validates +path and TCP port inputs before issuing syscalls, rejects interior-NUL path +truncation, documents Landlock syscall-attribute and fd-close invariants, and +has Linux-only regression tests for syscall-free input rejection. The generated +inventory now reports 197 annotated native unsafe sites and 232 remaining +unsafe review sites. Remaining work before closing K3-P1-01: --- a/docs/kimi3-security-recommmendations.md +++ b/docs/kimi3-security-recommmendations.md @@ -665,9 +665,12 @@ not started." locks. `postgres_native.rs` now funnels SQL and connection-string inputs through a shared checked UTF-8 helper, rejects null/nonzero and oversized FFI buffers, documents result-slot and output-buffer writes, and permits - zero-sized output buffers without manufacturing null slices. The generated - report now shows 189 annotated native unsafe sites and 240 remaining unsafe - review sites. + zero-sized output buffers without manufacturing null slices. `landlock.rs` + now validates path and TCP port inputs before issuing syscalls, rejects + interior-NUL path truncation, documents Landlock syscall-attribute and + fd-close invariants, and has Linux-only regression tests for syscall-free + input rejection. The generated report now shows 197 annotated native unsafe + sites and 232 remaining unsafe review sites. Remaining work: continue unsafe invariant comments across the rest of `jerboa-native-rs`. --- a/docs/reviews/2026-07-27-native-export-review.sexp +++ b/docs/reviews/2026-07-27-native-export-review.sexp @@ -77,11 +77,11 @@ (export (symbol "jerboa_integrity_hash_self") (file "jerboa-native-rs/src/integrity.rs") (line 68) (scheme-callers 1 (caller (file "lib/std/os/integrity.ss") (line 53)))) (export (symbol "jerboa_integrity_sign_verify") (file "jerboa-native-rs/src/integrity.rs") (line 141) (scheme-callers 1 (caller (file "lib/std/os/integrity.ss") (line 57)))) (export (symbol "jerboa_integrity_verify_hash") (file "jerboa-native-rs/src/integrity.rs") (line 101) (scheme-callers 1 (caller (file "lib/std/os/integrity.ss") (line 55)))) - (export (symbol "jerboa_landlock_abi_version") (file "jerboa-native-rs/src/landlock.rs") (line 65) (scheme-callers 6 (caller (file "lib/std/os/landlock-native.ss") (line 78)) (caller (file "lib/std/os/landlock.ss") (line 12)) (caller (file "lib/std/os/landlock.ss") (line 56)) (caller (file "lib/std/os/landlock.ss") (line 57)) (caller (file "lib/std/os/limits/sandbox.ss") (line 455)) (caller (file "lib/std/os/sandbox.ss") (line 75)))) - (export (symbol "jerboa_landlock_add_net_rule") (file "jerboa-native-rs/src/landlock.rs") (line 201) (scheme-callers 1 (caller (file "lib/std/os/landlock-native.ss") (line 86)))) - (export (symbol "jerboa_landlock_add_path_rule") (file "jerboa-native-rs/src/landlock.rs") (line 120) (scheme-callers 1 (caller (file "lib/std/os/landlock-native.ss") (line 83)))) - (export (symbol "jerboa_landlock_create_ruleset") (file "jerboa-native-rs/src/landlock.rs") (line 91) (scheme-callers 1 (caller (file "lib/std/os/landlock-native.ss") (line 80)))) - (export (symbol "jerboa_landlock_enforce") (file "jerboa-native-rs/src/landlock.rs") (line 234) (scheme-callers 1 (caller (file "lib/std/os/landlock-native.ss") (line 89)))) + (export (symbol "jerboa_landlock_abi_version") (file "jerboa-native-rs/src/landlock.rs") (line 68) (scheme-callers 6 (caller (file "lib/std/os/landlock-native.ss") (line 78)) (caller (file "lib/std/os/landlock.ss") (line 12)) (caller (file "lib/std/os/landlock.ss") (line 56)) (caller (file "lib/std/os/landlock.ss") (line 57)) (caller (file "lib/std/os/limits/sandbox.ss") (line 455)) (caller (file "lib/std/os/sandbox.ss") (line 75)))) + (export (symbol "jerboa_landlock_add_net_rule") (file "jerboa-native-rs/src/landlock.rs") (line 235) (scheme-callers 1 (caller (file "lib/std/os/landlock-native.ss") (line 86)))) + (export (symbol "jerboa_landlock_add_path_rule") (file "jerboa-native-rs/src/landlock.rs") (line 129) (scheme-callers 1 (caller (file "lib/std/os/landlock-native.ss") (line 83)))) + (export (symbol "jerboa_landlock_create_ruleset") (file "jerboa-native-rs/src/landlock.rs") (line 97) (scheme-callers 1 (caller (file "lib/std/os/landlock-native.ss") (line 80)))) + (export (symbol "jerboa_landlock_enforce") (file "jerboa-native-rs/src/landlock.rs") (line 277) (scheme-callers 1 (caller (file "lib/std/os/landlock-native.ss") (line 89)))) (export (symbol "jerboa_last_error") (file "jerboa-native-rs/src/panic.rs") (line 9) (scheme-callers 11 (caller (file "lib/std/compress/native-rust.ss") (line 42)) (caller (file "lib/std/crypto/native-rust.ss") (line 48)) (caller (file "lib/std/crypto/x509.ss") (line 24)) (caller (file "lib/std/native.ss") (line 56)) (caller (file "lib/std/net/tls-rustls.ss") (line 167)) (caller (file "lib/std/os/aproc.ss") (line 164)) (caller (file "lib/std/os/integrity.ss") (line 42)) (caller (file "lib/std/os/secure-output.ss") (line 47)) (caller (file "lib/std/pcap.ss") (line 66)) (caller (file "lib/std/regex-native.ss") (line 51)) (caller (file "lib/std/wasm/sandbox.ss") (line 196)))) (export (symbol "jerboa_pbkdf2_derive") (file "jerboa-native-rs/src/crypto.rs") (line 740) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 246)))) (export (symbol "jerboa_pbkdf2_verify") (file "jerboa-native-rs/src/crypto.rs") (line 773) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 261)))) --- a/docs/status.md +++ b/docs/status.md @@ -25,7 +25,7 @@ release artifacts are built as Jerboa multicall binaries with `jerboa`, | Area | Current state | Remaining work | |---|---|---| | Kimi security handoff | [kimi3-security-recommmendations.md](kimi3-security-recommmendations.md) is the backlog. Dated evidence and review manifests live under [reviews/](reviews/). | Keep new security evidence in dated review records and summarize the current release state here. | -| FFI audit phase 5 | [ffi-audit.md](ffi-audit.md) records the scanner output, provisional Scheme binding verdicts, and native Rust export inventory. `make native-export-review-check` gates native export decisions. The worker-launch native path in `aproc.rs`/`seccomp.rs`, crypto FFI buffer path in `crypto.rs`, secure-memory region lifecycle in `secure_mem.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, and PostgreSQL native ABI now have nearby `SAFETY:` comments or equivalent checked FFI invariants, with the generated unannotated unsafe-site count at 240. | Continue adding `SAFETY:` invariant comments near the remaining Rust unsafe sites. | +| FFI audit phase 5 | [ffi-audit.md](ffi-audit.md) records the scanner output, provisional Scheme binding verdicts, and native Rust export inventory. `make native-export-review-check` gates native export decisions. The worker-launch native path in `aproc.rs`/`seccomp.rs`, crypto FFI buffer path in `crypto.rs`, secure-memory region lifecycle in `secure_mem.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, and Landlock native ABI now have nearby `SAFETY:` comments or equivalent checked FFI invariants, with the generated unannotated unsafe-site count at 232. | Continue adding `SAFETY:` invariant comments near the remaining Rust unsafe sites. | | Native Rust exports | The native export review now has 190 exported functions: 183 tracked Scheme references and 7 retained standalone C/binary helpers. The previous 35 no-Scheme-reference removal candidates no longer have C ABI export markers. | Re-run `make native-export-review-check` whenever adding or removing native exports. | | Confined worker | `(std security worker)` provides the facade, audit lifecycle, output caps, deadlines, process-group kill, memory rlimit pre-exec setup, Linux syscall/ptrace seccomp pre-exec setup for requested axes, explicit sandbox-axis refusal, and egress proxy env wiring. | Install native Landlock path/net rules in the worker pre-exec path and keep Linux/macOS/FreeBSD parity tests current. | | Safe surface | Direct scripts default to the safe prelude; raw access requires `--unsafe-prelude` or `(jerboa prelude unsafe)`. | Continue moving risky APIs behind explicit unsafe imports as new modules land. | --- a/jerboa-native-rs/src/landlock.rs +++ b/jerboa-native-rs/src/landlock.rs @@ -1,4 +1,7 @@ use crate::panic::{ffi_wrap, set_last_error}; +use std::ffi::CString; + +const MAX_C_ABI_SLICE_LEN: usize = isize::MAX as usize; // Landlock LSM syscall wrappers via libc // ABI v1-v5 support @@ -64,6 +67,9 @@ struct LandlockNetPortAttr { #[no_mangle] pub extern "C" fn jerboa_landlock_abi_version() -> i32 { ffi_wrap(|| { + // SAFETY: This is the Landlock version-probe ABI: null attr pointer, + // zero size, and LANDLOCK_CREATE_RULESET_VERSION in flags. No user + // memory is dereferenced by the kernel for this form. let version = unsafe { libc::syscall( SYS_LANDLOCK_CREATE_RULESET, @@ -94,6 +100,9 @@ pub extern "C" fn jerboa_landlock_create_ruleset(fs_access_mask: u64, net_access handled_access_fs: fs_access_mask, handled_access_net: net_access_mask, }; + // SAFETY: `attr` is a stack-allocated C-compatible ruleset attribute + // with the exact size passed to the syscall and remains live for the + // duration of the call. let fd = unsafe { libc::syscall( SYS_LANDLOCK_CREATE_RULESET, @@ -125,13 +134,28 @@ pub extern "C" fn jerboa_landlock_add_path_rule( ) -> i32 { ffi_wrap(|| { if path.is_null() { + set_last_error("null landlock path".into()); + return -1; + } + if path_len == 0 || path_len > MAX_C_ABI_SLICE_LEN { + set_last_error("invalid landlock path length".into()); return -1; } + // SAFETY: `path` is non-null and `path_len` is bounded to + // `isize::MAX`; the slice is immediately copied into an owned + // CString before any syscall can retain or outlive it. let path_bytes = unsafe { std::slice::from_raw_parts(path, path_len) }; - let mut path_buf = Vec::with_capacity(path_len + 1); - path_buf.extend_from_slice(path_bytes); - path_buf.push(0); + let path_buf = match CString::new(path_bytes) { + Ok(value) => value, + Err(_) => { + set_last_error("landlock path contains interior NUL".into()); + return -1; + } + }; + // SAFETY: `path_buf` is NUL-terminated and remains live for the + // duration of `open`; flags request an O_PATH descriptor without + // following it into owned Rust file abstractions. let parent_fd = unsafe { libc::open( path_buf.as_ptr() as *const _, @@ -150,7 +174,11 @@ pub extern "C" fn jerboa_landlock_add_path_rule( // directory, mask access_mask down to file-applicable bits // (EXECUTE | WRITE_FILE | READ_FILE | TRUNCATE | IOCTL_DEV). let mut effective_access = access_mask; + // SAFETY: `stat` is zero-initialized before the kernel fills it via + // `fstat`, and `parent_fd` is a live descriptor returned by `open`. let mut st: libc::stat = unsafe { std::mem::zeroed() }; + // SAFETY: `st` points to valid writable storage and `parent_fd` + // remains open across the syscall. if unsafe { libc::fstat(parent_fd, &mut st) } == 0 && (st.st_mode & libc::S_IFMT) != libc::S_IFDIR { @@ -162,6 +190,7 @@ pub extern "C" fn jerboa_landlock_add_path_rule( effective_access &= FILE_BITS; } if effective_access == 0 { + // SAFETY: `parent_fd` is the live descriptor returned by `open`. unsafe { libc::close(parent_fd); } @@ -172,6 +201,9 @@ pub extern "C" fn jerboa_landlock_add_path_rule( allowed_access: effective_access, parent_fd, }; + // SAFETY: `attr` is C-compatible and live for the syscall, and its + // `parent_fd` field names the open O_PATH descriptor. Landlock copies + // the rule data during the syscall. let rc = unsafe { libc::syscall( SYS_LANDLOCK_ADD_RULE, @@ -181,6 +213,8 @@ pub extern "C" fn jerboa_landlock_add_path_rule( 0u32, ) }; + // SAFETY: `parent_fd` is no longer needed after Landlock has copied + // the rule into the ruleset. unsafe { libc::close(parent_fd); } @@ -204,10 +238,19 @@ pub extern "C" fn jerboa_landlock_add_net_rule( access_mask: u64, ) -> i32 { ffi_wrap(|| { + if port > u16::MAX as u64 { + set_last_error("landlock TCP port out of range".into()); + return -1; + } + if access_mask == 0 { + return 0; + } let attr = LandlockNetPortAttr { allowed_access: access_mask, port, }; + // SAFETY: `attr` is C-compatible and live for the syscall. Landlock + // copies the port-rule data into the ruleset during the call. let rc = unsafe { libc::syscall( SYS_LANDLOCK_ADD_RULE, @@ -234,6 +277,8 @@ pub extern "C" fn jerboa_landlock_add_net_rule( pub extern "C" fn jerboa_landlock_enforce(ruleset_fd: i32) -> i32 { ffi_wrap(|| { // prctl(PR_SET_NO_NEW_PRIVS, 1) is required before restrict_self + // SAFETY: `prctl` receives only immediate integer arguments for + // PR_SET_NO_NEW_PRIVS and does not dereference user memory. let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; if rc < 0 { set_last_error(format!( @@ -243,6 +288,8 @@ pub extern "C" fn jerboa_landlock_enforce(ruleset_fd: i32) -> i32 { return -1; } + // SAFETY: `ruleset_fd` is supplied by the caller and validated by the + // kernel; no Rust references are shared across the syscall. let rc = unsafe { libc::syscall(SYS_LANDLOCK_RESTRICT_SELF, ruleset_fd, 0u32) }; if rc < 0 { set_last_error(format!( @@ -253,9 +300,36 @@ pub extern "C" fn jerboa_landlock_enforce(ruleset_fd: i32) -> i32 { } // Close the ruleset fd — no longer needed + // SAFETY: after successful restrict_self, the ruleset fd is no longer + // needed by this process; closing releases the descriptor. unsafe { libc::close(ruleset_fd); } 0 }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_rule_rejects_null_empty_and_interior_nul() { + assert_eq!(jerboa_landlock_add_path_rule(0, std::ptr::null(), 1, 1), -1); + let empty = b""; + assert_eq!( + jerboa_landlock_add_path_rule(0, empty.as_ptr(), empty.len(), 1), + -1 + ); + let interior_nul = b"/tmp\0shadow"; + assert_eq!( + jerboa_landlock_add_path_rule(0, interior_nul.as_ptr(), interior_nul.len(), 1), + -1 + ); + } + + #[test] + fn net_rule_rejects_invalid_port_before_syscall() { + assert_eq!(jerboa_landlock_add_net_rule(0, u16::MAX as u64 + 1, 1), -1); + } +}