diff --git a/README.md b/README.md index e458a902..ed379c8f 100644 --- a/README.md +++ b/README.md @@ -757,7 +757,9 @@ comma-separated list of single ports or inclusive `lo-hi` ranges (e.g. allows binding any port, including an ephemeral `bind(0)`; it cannot be mixed with port lists (repeating the bare wildcard is fine). Landlock enforces the allowlist (TCP only; the wildcard simply leaves Landlock's `BIND_TCP` hook -unhandled); `--port-remap` adds on-behalf virtualization for binding. +unhandled). When network supervision is active (`--net-allow`, `--net-deny`, +`--http-allow`, `--port-remap`, a policy function) `bind()` runs on the +supervisor's on-behalf path, which enforces the same allowlist. `--net-deny-bind ` is the inverse: default-allow binding, deny the listed TCP ports (same port syntax, mutually exclusive with `--net-allow-bind`). Because Landlock is allowlist-only, a deny-bind relaxes diff --git a/crates/sandlock-cli/src/learn.rs b/crates/sandlock-cli/src/learn.rs index 268ec5d5..7742991d 100644 --- a/crates/sandlock-cli/src/learn.rs +++ b/crates/sandlock-cli/src/learn.rs @@ -600,6 +600,9 @@ pub async fn run(args: LearnArgs) -> Result<()> { // A scheme-less "*" covers TCP and UDP; ICMP always needs its own rule. .net_allow("*") .net_allow("icmp://*") + // Observation must see every bind the program attempts, so nothing + // may refuse one before it is recorded. + .net_allow_bind("*") .max_memory(sandlock_core::sandbox::ByteSize(1 << 43)) // 8 TiB .policy_fn(move |event, _ctx| observer_cb.on_event(event)); diff --git a/crates/sandlock-core/src/port_remap.rs b/crates/sandlock-core/src/port_remap.rs index 212de355..28d73fab 100644 --- a/crates/sandlock-core/src/port_remap.rs +++ b/crates/sandlock-core/src/port_remap.rs @@ -169,20 +169,21 @@ pub(crate) async fn handle_bind( return NotifAction::Errno(libc::EACCES); } - // Non-IP family or ephemeral (port == 0): bind verbatim — nothing to - // track or remap. extract_port returns None for non-IP families and - // for truncated buffers; in both cases the kernel will validate. - let virtual_port = match extract_port(&bytes) { - Some(p) if p != 0 => p, - _ => return bind_verbatim(&dup_fd, &bytes, addr_len), - }; - - // --net-deny-bind: reject binding a denied TCP port. Only TCP is gated - // (mirroring --net-allow-bind); UDP/other binds are unaffected. The - // SO_PROTOCOL probe is skipped entirely when the denylist is empty. - let denied = { - let ns = network.lock().await; - !ns.bind_deny_ports.is_empty() && ns.bind_deny_ports.contains(&virtual_port) + // Non-IP family or truncated buffer: extract_port returns None and the + // kernel validates the bind. + let ip_port = extract_port(&bytes); + + // TCP bind allowlist / denylist. Only TCP is gated (Landlock's BIND_TCP + // is TCP-only); UDP/other binds are unaffected. A port-0 bind is refused + // under an allowlist because Landlock refuses it too: only `'*'` can + // express "any ephemeral port". + let denied = match ip_port { + Some(port) => { + let ns = network.lock().await; + ns.bind_allow_ports.as_ref().is_some_and(|allow| !allow.contains(&port)) + || ns.bind_deny_ports.contains(&port) + } + None => false, }; if denied && crate::network::query_socket_protocol(dup_fd.as_raw_fd()) @@ -191,6 +192,12 @@ pub(crate) async fn handle_bind( return NotifAction::Errno(libc::EACCES); } + // Ephemeral (port == 0): bind verbatim, nothing to track or remap. + let virtual_port = match ip_port { + Some(p) if p != 0 => p, + _ => return bind_verbatim(&dup_fd, &bytes, addr_len), + }; + // Pick a first-attempt port: cached real port if known, else the // virtual port itself. The cached real port keeps repeat binds of // the same virtual port consistent across the sandbox; the virtual diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index a1d19946..628199b3 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -422,9 +422,10 @@ pub struct Sandbox { /// Mutually exclusive with `net_allow`. pub net_deny: Vec, /// `--net-allow-bind`: TCP ports the sandbox may bind (default-deny - /// allowlist, Landlock-enforced; `All` leaves Landlock's `BIND_TCP` - /// hook unhandled so any port may be bound). Mutually exclusive with - /// `net_deny_bind`. + /// allowlist, enforced by Landlock on the direct path and by the + /// on-behalf `bind()` handler under network supervision; `All` leaves + /// Landlock's `BIND_TCP` hook unhandled so any port may be bound). + /// Mutually exclusive with `net_deny_bind`. pub net_allow_bind: BindPorts, /// `--net-deny-bind`: TCP ports the sandbox may NOT bind (default-allow /// denylist, enforced on the on-behalf `bind()` path). Mutually @@ -2135,6 +2136,15 @@ impl Sandbox { net_state.http_acl_ports = self.http_ports.iter().copied().collect(); net_state.http_acl_orig_dest = self.rt().http_acl_handle.as_ref().map(|h| h.orig_dest.clone()); net_state.bind_deny_ports = self.net_deny_bind.iter().copied().collect(); + let net_tcp_active = self.active_protections()? + .into_iter() + .any(|(p, s)| p == Protection::NetTcp && s == ProtectionStatus::Active); + net_state.bind_allow_ports = match &self.net_allow_bind { + BindPorts::Ports(ports) if net_tcp_active && self.net_deny_bind.is_empty() => { + Some(ports.iter().copied().collect()) + } + _ => None, + }; if let Some(cb) = self.rt_mut().on_bind.take() { net_state.port_map.on_bind = Some(cb); } diff --git a/crates/sandlock-core/src/seccomp/state.rs b/crates/sandlock-core/src/seccomp/state.rs index 14217d46..6f33901c 100644 --- a/crates/sandlock-core/src/seccomp/state.rs +++ b/crates/sandlock-core/src/seccomp/state.rs @@ -468,6 +468,12 @@ pub struct NetworkState { /// denylist). The on-behalf `bind()` handler rejects a TCP bind to any /// port in this set with `EACCES`; empty = no bind denylist. pub bind_deny_ports: HashSet, + /// `--net-allow-bind`: TCP ports the sandbox may bind. The on-behalf + /// `bind()` runs in the supervisor, outside the child's Landlock domain, + /// so the kernel rules never see it and this set is the only enforcer + /// there. `None` = unrestricted (`'*'` or NetTcp inactive); `Some(empty)` + /// = the default deny-all. + pub bind_allow_ports: Option>, /// Per-PID network overrides from policy_fn (IP-only via the legacy /// `restrict_network(ips)` API; any port is permitted to listed IPs). pub pid_ip_overrides: std::sync::Arc>>>, @@ -487,6 +493,7 @@ impl NetworkState { icmp_policy: crate::seccomp::notif::NetworkPolicy::Unrestricted, port_map: crate::port_remap::PortMap::new(), bind_deny_ports: HashSet::new(), + bind_allow_ports: None, pid_ip_overrides: std::sync::Arc::new(std::sync::RwLock::new(HashMap::new())), http_acl_addr: None, http_acl_ports: HashSet::new(), diff --git a/crates/sandlock-core/tests/integration/test_network.rs b/crates/sandlock-core/tests/integration/test_network.rs index 18a1ce3d..f55f47b8 100644 --- a/crates/sandlock-core/tests/integration/test_network.rs +++ b/crates/sandlock-core/tests/integration/test_network.rs @@ -1276,3 +1276,88 @@ async fn test_sendmmsg_blocking_entry_defers_and_delivers_under_net_policy() { ); assert_eq!(got, N, "peer must receive all {N} bytes of the deferred batch entry"); } + +/// Any network supervision (`--net-allow`, `--port-remap`, ...) moves +/// `bind()` onto the on-behalf path, where the supervisor binds outside the +/// child's Landlock domain. The bind allowlist must still hold there: +/// listed ports bind, unlisted ports and ephemeral `bind(0)` fail with +/// EACCES, and UDP is untouched. +#[tokio::test] +async fn test_net_allow_bind_enforced_on_behalf() { + fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port() + } + let allowed = free_port(); + let mut other = free_port(); + while other == allowed { + other = free_port(); + } + + let script = format!(concat!( + "import socket, json\n", + "res = {{}}\n", + "def tcp(key, port):\n", + " s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", + " s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n", + " try:\n", + " s.bind(('127.0.0.1', port)); res[key] = 'ok'\n", + " except PermissionError:\n", + " res[key] = 'eacces'\n", + " except OSError as e:\n", + " res[key] = 'err:%d' % e.errno\n", + " s.close()\n", + "tcp('allowed', {allowed})\n", + "tcp('other', {other})\n", + "tcp('ephemeral', 0)\n", + "u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n", + "try:\n", + " u.bind(('127.0.0.1', {other})); res['udp'] = 'ok'\n", + "except OSError as e:\n", + " res['udp'] = 'err:%d' % e.errno\n", + "open('{out}', 'w').write(json.dumps(res))\n", + ), allowed = allowed, other = other, out = "{out}"); + + let supervised = [ + ("net_allow", base_policy().net_allow("udp://*").net_allow("127.0.0.1:1")), + ("net_deny", base_policy().net_deny("10.0.0.0/8")), + ("port_remap", base_policy().net_allow("udp://*").port_remap(true)), + ]; + for (label, builder) in supervised { + let out = temp_file(&format!("allowbind_{label}")); + let policy = builder.net_allow_bind_port(allowed).build().unwrap(); + let script = script.replace("{out}", &out.display().to_string()); + let result = policy.clone() + .run_interactive(&["python3", "-c", &script]).await.unwrap(); + assert!(result.success(), "{label}: exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + assert!(content.contains("\"allowed\": \"ok\""), "{label}: listed port must bind; got: {content}"); + assert!(content.contains("\"other\": \"eacces\""), "{label}: unlisted port must fail with EACCES; got: {content}"); + assert!(content.contains("\"ephemeral\": \"eacces\""), "{label}: bind(0) must fail with EACCES; got: {content}"); + assert!(content.contains("\"udp\": \"ok\""), "{label}: UDP bind must be unaffected; got: {content}"); + } +} + +/// With no allowlist at all, the default is deny-every-TCP-bind. That must +/// hold on the on-behalf path too, not only under Landlock. +#[tokio::test] +async fn test_default_bind_deny_enforced_on_behalf() { + let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + let out = temp_file("defaultbind"); + let policy = base_policy().port_remap(true).build().unwrap(); + let script = format!(concat!( + "import socket\n", + "s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", + "try:\n", + " s.bind(('127.0.0.1', {port})); r = 'ok'\n", + "except PermissionError:\n", + " r = 'eacces'\n", + "open('{out}', 'w').write(r)\n", + ), port = port, out = out.display()); + let result = policy.clone() + .run_interactive(&["python3", "-c", &script]).await.unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + assert_eq!(content, "eacces", "TCP bind with no allowlist must fail under port_remap"); +} diff --git a/python/tests/test_sandbox.py b/python/tests/test_sandbox.py index c9e27754..166790de 100644 --- a/python/tests/test_sandbox.py +++ b/python/tests/test_sandbox.py @@ -581,7 +581,7 @@ def test_two_sandboxes_same_virtual_port(self): "print(s.getsockname()[1]); " "s.close()" ) - policy = _policy(port_remap=True) + policy = _policy(port_remap=True, net_allow_bind=[8080]) r1 = policy.run(["python3", "-c", code]) r2 = policy.run(["python3", "-c", code]) @@ -601,7 +601,7 @@ def test_getsockname_returns_virtual_port(self): "print(name[0], name[1]); " "s.close()" ) - policy = _policy(port_remap=True) + policy = _policy(port_remap=True, net_allow_bind=[4000]) result = policy.run(["python3", "-c", code]) assert result.success @@ -618,7 +618,7 @@ def test_ephemeral_port_not_remapped(self): "print(s.getsockname()[1]); " "s.close()" ) - policy = _policy(port_remap=True) + policy = _policy(port_remap=True, net_allow_bind=["*"]) result = policy.run(["python3", "-c", code]) assert result.success @@ -633,7 +633,7 @@ def test_ipv6_bind_remapped(self): "print(s.getsockname()[1]); " "s.close()" ) - policy = _policy(port_remap=True) + policy = _policy(port_remap=True, net_allow_bind=[5000]) result = policy.run(["python3", "-c", code]) assert result.success