diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 6104e0f66..09ce6acb6 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -3067,10 +3067,30 @@ async fn resolve_from_sandbox_hosts( None } -async fn resolve_socket_addrs( +trait DnsResolver: Sync { + fn resolve<'a>( + &'a self, + host: &'a str, + port: u16, + ) -> impl Future, String>> + Send + 'a; +} + +struct SystemDnsResolver; + +impl DnsResolver for SystemDnsResolver { + async fn resolve(&self, host: &str, port: u16) -> std::result::Result, String> { + let addrs = tokio::net::lookup_host((host, port)) + .await + .map_err(|error| format!("DNS resolution failed for {host}:{port}: {error}"))?; + Ok(addrs.collect()) + } +} + +async fn resolve_socket_addrs_with_resolver( host: &str, port: u16, entrypoint_pid: u32, + resolver: &R, ) -> std::result::Result, String> { if let Some(addrs) = resolve_ip_literal(host, port) { return Ok(addrs); @@ -3081,10 +3101,7 @@ async fn resolve_socket_addrs( } let lookup_host = normalize_host_lookup_key(host); - let addrs: Vec = tokio::net::lookup_host((lookup_host, port)) - .await - .map_err(|e| format!("DNS resolution failed for {lookup_host}:{port}: {e}"))? - .collect(); + let addrs = resolver.resolve(lookup_host, port).await?; if addrs.is_empty() { return Err(format!( @@ -3095,6 +3112,14 @@ async fn resolve_socket_addrs( Ok(addrs) } +async fn resolve_socket_addrs( + host: &str, + port: u16, + entrypoint_pid: u32, +) -> std::result::Result, String> { + resolve_socket_addrs_with_resolver(host, port, entrypoint_pid, &SystemDnsResolver).await +} + fn reject_internal_resolved_addrs( host: &str, addrs: &[SocketAddr], @@ -3256,14 +3281,23 @@ async fn dial_upstream( /// /// Returns the resolved `SocketAddr` list on success. Returns an error string /// if any resolved IP is in an internal range or if DNS resolution fails. +async fn resolve_and_reject_internal_with_resolver( + host: &str, + port: u16, + entrypoint_pid: u32, + resolver: &R, +) -> std::result::Result, String> { + let addrs = resolve_socket_addrs_with_resolver(host, port, entrypoint_pid, resolver).await?; + reject_internal_resolved_addrs(host, &addrs)?; + Ok(addrs) +} + async fn resolve_and_reject_internal( host: &str, port: u16, entrypoint_pid: u32, ) -> std::result::Result, String> { - let addrs = resolve_socket_addrs(host, port, entrypoint_pid).await?; - reject_internal_resolved_addrs(host, &addrs)?; - Ok(addrs) + resolve_and_reject_internal_with_resolver(host, port, entrypoint_pid, &SystemDnsResolver).await } /// Resolve a host:port using sandbox `/etc/hosts` first (when available), then @@ -3274,15 +3308,32 @@ async fn resolve_and_reject_internal( /// Entries can be CIDR notation ("10.0.5.0/24") or exact IPs ("10.0.5.20"). /// /// Returns the resolved `SocketAddr` list on success. +async fn resolve_and_check_allowed_ips_with_resolver( + host: &str, + port: u16, + allowed_ips: &[ipnet::IpNet], + entrypoint_pid: u32, + resolver: &R, +) -> std::result::Result, String> { + let addrs = resolve_socket_addrs_with_resolver(host, port, entrypoint_pid, resolver).await?; + validate_allowed_ips_for_resolved_addrs(host, port, &addrs, allowed_ips)?; + Ok(addrs) +} + async fn resolve_and_check_allowed_ips( host: &str, port: u16, allowed_ips: &[ipnet::IpNet], entrypoint_pid: u32, ) -> std::result::Result, String> { - let addrs = resolve_socket_addrs(host, port, entrypoint_pid).await?; - validate_allowed_ips_for_resolved_addrs(host, port, &addrs, allowed_ips)?; - Ok(addrs) + resolve_and_check_allowed_ips_with_resolver( + host, + port, + allowed_ips, + entrypoint_pid, + &SystemDnsResolver, + ) + .await } /// Resolve a host:port that was explicitly declared by hostname in policy. @@ -5382,6 +5433,27 @@ mod tests { use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; + struct MockDnsResolver { + host: &'static str, + addresses: Vec, + } + + impl DnsResolver for MockDnsResolver { + async fn resolve( + &self, + host: &str, + port: u16, + ) -> std::result::Result, String> { + assert_eq!(host, self.host); + Ok(self + .addresses + .iter() + .copied() + .map(|address| SocketAddr::new(address, port)) + .collect()) + } + } + struct BlockingForwardMiddleware { entered: Arc, release: Arc, @@ -8540,9 +8612,15 @@ network_policies: #[tokio::test] async fn test_resolve_check_allowed_ips_rejects_outside_allowlist() { - // 8.8.8.8 resolves to a public IP which is NOT in 10.0.0.0/8 + // 8.8.8.8 is a public IP which is NOT in 10.0.0.0/8. + let resolver = MockDnsResolver { + host: "dns.google", + addresses: vec!["8.8.8.8".parse().unwrap()], + }; let nets = parse_allowed_ips(&["10.0.0.0/8".to_string()]).unwrap(); - let result = resolve_and_check_allowed_ips("dns.google", 443, &nets, 0).await; + let result = + resolve_and_check_allowed_ips_with_resolver("dns.google", 443, &nets, 0, &resolver) + .await; assert!(result.is_err()); let err = result.unwrap_err(); assert!( @@ -9872,7 +9950,12 @@ network_policies: async fn test_forward_public_ip_allowed_without_allowed_ips() { // Public IPs (e.g. dns.google -> 8.8.8.8) should pass through // resolve_and_reject_internal without needing allowed_ips. - let result = resolve_and_reject_internal("dns.google", 80, 0).await; + let resolver = MockDnsResolver { + host: "dns.google", + addresses: vec!["8.8.8.8".parse().unwrap()], + }; + let result = + resolve_and_reject_internal_with_resolver("dns.google", 80, 0, &resolver).await; assert!( result.is_ok(), "Public IP should be allowed without allowed_ips: {result:?}" diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index 659fe3dc0..9311d73b6 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -3123,6 +3123,9 @@ mod tests { #[test] #[allow(unsafe_code)] fn effective_identity_validation_honors_landlock_denial() { + if !sandbox::linux::landlock_available() { + return; + } let dir = tempfile::tempdir_in("/tmp").unwrap(); let root = dir.path().canonicalize().unwrap().join("project"); std::fs::create_dir(&root).unwrap(); @@ -3137,13 +3140,12 @@ mod tests { policy.landlock = LandlockPolicy { compatibility: openshell_core::policy::LandlockCompatibility::HardRequirement, }; - let Ok(prepared) = sandbox::linux::prepare_current_user(&policy, None) else { - return; - }; + let prepared = sandbox::linux::prepare_current_user(&policy, None) + .expect("Landlock rules should prepare when the capability probe succeeds"); match unsafe { fork() }.expect("fork should succeed") { ForkResult::Child => { - let denied = sandbox::linux::enforce(prepared).is_ok() + let denied = sandbox::linux::enforce_landlock(prepared).is_ok() && validate_oci_workspace_as_effective_identity(&root).is_err(); unsafe { libc::_exit(i32::from(!denied)) }; } diff --git a/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs b/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs index 107a50e37..4f7f067eb 100644 --- a/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs +++ b/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs @@ -57,6 +57,30 @@ pub fn enforce(prepared: PreparedSandbox) -> Result<()> { Ok(()) } +#[cfg(test)] +pub(crate) fn enforce_landlock(prepared: PreparedSandbox) -> Result<()> { + if let Some(ruleset) = prepared.landlock { + landlock::enforce(ruleset)?; + } + Ok(()) +} + +#[cfg(test)] +pub(crate) fn landlock_available() -> bool { + match landlock::probe_availability() { + landlock::LandlockAvailability::Available { .. } => true, + unavailable => { + eprintln!("skipping test: Landlock is {unavailable}"); + false + } + } +} + +#[cfg(test)] +pub(crate) fn seccomp_filter_installation_available() -> bool { + seccomp::filter_installation_available() +} + /// Apply the supervisor seccomp prelude after privileged bootstrap completes. pub fn apply_supervisor_prelude() -> Result<()> { seccomp::apply_supervisor_prelude() diff --git a/crates/openshell-supervisor-process/src/sandbox/linux/seccomp.rs b/crates/openshell-supervisor-process/src/sandbox/linux/seccomp.rs index a9c67af95..368529da7 100644 --- a/crates/openshell-supervisor-process/src/sandbox/linux/seccomp.rs +++ b/crates/openshell-supervisor-process/src/sandbox/linux/seccomp.rs @@ -361,6 +361,54 @@ fn add_masked_arg_rule( Ok(()) } +#[cfg(test)] +#[allow(unsafe_code)] +pub(super) fn filter_installation_available() -> bool { + const UNAVAILABLE_EXIT_CODE: libc::c_int = 77; + + let mut rules = BTreeMap::new(); + rules.insert(libc::SYS_getpid, Vec::new()); + let filter = compile_filter(rules, SeccompAction::Errno(libc::EPERM as u32)) + .expect("seccomp capability probe filter should compile"); + let pid = unsafe { libc::fork() }; + assert!(pid >= 0, "fork failed while probing seccomp support"); + + if pid == 0 { + unsafe { + if libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 { + libc::_exit(1); + } + match apply_filter(&filter) { + Ok(()) => libc::_exit(0), + Err(seccompiler::Error::Seccomp(error)) + if error.raw_os_error() == Some(libc::EPERM) => + { + libc::_exit(UNAVAILABLE_EXIT_CODE); + } + Err(error) => { + let message = format!("unexpected seccomp capability probe error: {error}\n"); + libc::write(2, message.as_ptr().cast(), message.len()); + libc::_exit(1); + } + } + } + } + + let mut status: libc::c_int = 0; + unsafe { libc::waitpid(pid, &raw mut status, 0) }; + assert!( + libc::WIFEXITED(status), + "seccomp capability probe child failed" + ); + let exit_code = libc::WEXITSTATUS(status); + if exit_code == UNAVAILABLE_EXIT_CODE { + eprintln!("skipping test: seccomp filter installation is blocked by the environment"); + return false; + } + assert_eq!(exit_code, 0, "seccomp capability probe failed unexpectedly"); + true +} + #[cfg(test)] // libc/syscall FFI requires unsafe; these tests fork children and exercise // blocked syscalls, so unsafe blocks/calls are pervasive. @@ -659,6 +707,9 @@ mod tests { #[test] fn behavioral_supervisor_prelude_mount_blocked() { + if !filter_installation_available() { + return; + } let pid = unsafe { libc::fork() }; assert!(pid >= 0, "fork failed"); if pid == 0 { @@ -699,6 +750,9 @@ mod tests { #[test] fn behavioral_clone3_returns_enosys() { + if !filter_installation_available() { + return; + } // clone3 uses a separate filter that returns ENOSYS (not EPERM) so // glibc falls back to clone. let main_filter = build_filter(true).unwrap(); @@ -730,6 +784,9 @@ mod tests { #[test] fn behavioral_third_filter_install_blocked_after_startup() { + if !filter_installation_available() { + return; + } let main_filter = build_filter(true).unwrap(); let clone3_filter = build_clone3_filter().unwrap(); let third_filter = build_clone3_filter().unwrap(); diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index 07302da95..63fc6071c 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -1612,7 +1612,19 @@ mod tests { // With the old single-pty_master design, a window_change_request // for channel N would resize whatever PTY was stored last — // potentially belonging to a different channel. - let pty_a = openpty(None, None).expect("openpty a"); + let pty_a = match openpty(None, None) { + Ok(pty) => pty, + Err( + nix::errno::Errno::EACCES + | nix::errno::Errno::ENOENT + | nix::errno::Errno::ENODEV + | nix::errno::Errno::ENXIO, + ) => { + eprintln!("skipping test: PTY allocation is unavailable"); + return; + } + Err(error) => panic!("openpty a: {error}"), + }; let pty_b = openpty(None, None).expect("openpty b"); let master_a = std::fs::File::from(pty_a.master); let master_b = std::fs::File::from(pty_b.master); @@ -1847,6 +1859,11 @@ mod tests { FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, SandboxPolicy, }; + #[cfg(target_os = "linux")] + if !sandbox::linux::seccomp_filter_installation_available() { + return; + } + // No user/group configured and not running as root → drop_privileges is // a no-op, so spawn succeeds regardless of the effective UID. let policy = SandboxPolicy {