From 1d8f2498bfe6208488d3d48622e6ef58b7655d6a Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 3 Jun 2026 15:44:37 -0700 Subject: [PATCH 01/24] net: add IpCidr type for --net-deny matching Signed-off-by: Cong Wang --- crates/sandlock-core/src/network.rs | 113 ++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index 2310895..b0931d3 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -20,6 +20,77 @@ use crate::sys::structs::{SeccompNotif, AF_INET, AF_INET6, ECONNREFUSED}; /// Prevents a sandboxed process from triggering OOM in the supervisor. const MAX_SEND_BUF: usize = 64 << 20; +/// An IPv4 or IPv6 address with a prefix length, used by `--net-deny` +/// to match destination IPs by exact address (`/32`, `/128`) or by range. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct IpCidr { + pub addr: IpAddr, + pub prefix_len: u8, +} + +impl IpCidr { + /// Parse `addr` or `addr/prefix`. A bare address becomes a host route + /// (`/32` for IPv4, `/128` for IPv6). Hostnames are rejected: the + /// address part must parse as a literal IP. + pub fn parse(s: &str) -> Result { + let (addr_str, prefix) = match s.split_once('/') { + Some((a, p)) => { + let len: u8 = p.parse().map_err(|_| { + SandboxError::Invalid(format!( + "--net-deny: invalid prefix length in `{}`", + s + )) + })?; + (a, Some(len)) + } + None => (s, None), + }; + let addr: IpAddr = addr_str.parse().map_err(|_| { + SandboxError::Invalid(format!( + "--net-deny: `{}` is not an IP or CIDR (hostnames are not \ + allowed; use --http-deny for domains)", + s + )) + })?; + let max = match addr { + IpAddr::V4(_) => 32u8, + IpAddr::V6(_) => 128u8, + }; + let prefix_len = prefix.unwrap_or(max); + if prefix_len > max { + return Err(SandboxError::Invalid(format!( + "--net-deny: prefix /{} too large for {} in `{}`", + prefix_len, + if max == 32 { "IPv4" } else { "IPv6" }, + s + ))); + } + Ok(IpCidr { addr, prefix_len }) + } + + /// True iff `ip` falls within this network. Different address + /// families never match. + pub fn contains(&self, ip: IpAddr) -> bool { + match (self.addr, ip) { + (IpAddr::V4(net), IpAddr::V4(ip)) => { + if self.prefix_len == 0 { + return true; + } + let mask = u32::MAX << (32 - self.prefix_len); + (u32::from(net) & mask) == (u32::from(ip) & mask) + } + (IpAddr::V6(net), IpAddr::V6(ip)) => { + if self.prefix_len == 0 { + return true; + } + let mask = u128::MAX << (128 - self.prefix_len); + (u128::from(net) & mask) == (u128::from(ip) & mask) + } + _ => false, + } + } +} + /// L4 protocol that a `NetAllow` rule applies to. /// /// `Tcp` is the default if a rule has no scheme (the bare `host:port` @@ -1660,4 +1731,46 @@ mod tests { ); let _ = std::fs::remove_dir_all(&rootfs); } + + // --- IpCidr tests --- + + #[test] + fn ipcidr_parse_bare_ipv4_is_host_route() { + let c = IpCidr::parse("1.2.3.4").unwrap(); + assert_eq!(c.prefix_len, 32); + assert!(c.contains("1.2.3.4".parse().unwrap())); + assert!(!c.contains("1.2.3.5".parse().unwrap())); + } + + #[test] + fn ipcidr_parse_ipv4_range_contains() { + let c = IpCidr::parse("10.0.0.0/8").unwrap(); + assert!(c.contains("10.3.7.9".parse().unwrap())); + assert!(!c.contains("11.0.0.1".parse().unwrap())); + } + + #[test] + fn ipcidr_parse_ipv6_range_contains() { + let c = IpCidr::parse("fc00::/7").unwrap(); + assert!(c.contains("fd00::1".parse().unwrap())); + assert!(!c.contains("2001:db8::1".parse().unwrap())); + } + + #[test] + fn ipcidr_zero_prefix_matches_all_same_family() { + let c = IpCidr::parse("0.0.0.0/0").unwrap(); + assert!(c.contains("8.8.8.8".parse().unwrap())); + assert!(!c.contains("::1".parse().unwrap())); // family mismatch + } + + #[test] + fn ipcidr_rejects_hostname() { + assert!(IpCidr::parse("example.com").is_err()); + } + + #[test] + fn ipcidr_rejects_oversized_prefix() { + assert!(IpCidr::parse("10.0.0.0/33").is_err()); + assert!(IpCidr::parse("fc00::/129").is_err()); + } } From a7de0709ffd1098a20999151ff45391c6b22ca5e Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 3 Jun 2026 19:07:26 -0700 Subject: [PATCH 02/24] net: parse --net-deny rules with CIDR and private token Signed-off-by: Cong Wang --- crates/sandlock-core/src/network.rs | 249 ++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index b0931d3..52e0b40 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -91,6 +91,183 @@ impl IpCidr { } } +/// What a deny rule targets at the IP layer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DenyTarget { + /// Any destination IP (the `:port` / `*:port` form). + AnyIp, + /// A specific IP or CIDR range. + Cidr(IpCidr), +} + +/// A single `--net-deny` rule. Unlike `NetAllow`, the target is always +/// a literal IP/CIDR (or any-IP) resolved at parse time, never a +/// hostname, so no DNS is involved and matching is rebinding-safe. +#[derive(Clone, Debug, PartialEq)] +pub struct NetDeny { + pub protocol: Protocol, + pub target: DenyTarget, + pub ports: Vec, + /// "Any port" (bare target with no `:port`, or the `*` port token). + pub all_ports: bool, +} + +/// Curated internal/private ranges expanded by the `private` token. +/// Covers loopback, RFC1918, link-local (incl. the cloud metadata +/// endpoint 169.254.169.254), IPv6 loopback, ULA, and IPv6 link-local. +pub const PRIVATE_CIDRS: &[&str] = &[ + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + "::1/128", + "fc00::/7", + "fe80::/10", +]; + +impl NetDeny { + /// Parse a `--net-deny` spec into one or more rules. Returns a `Vec` + /// because the `private` token expands to many rules. Forms: + /// + /// - `private` -- the curated internal-range set, all protocols. + /// - `` / `` -- all ports (TCP default scheme). + /// - `:` / `:` / `:*`. + /// - `[]:` -- bracketed IPv6 with a port. + /// - `:` / `*:` -- any IP, that port. + /// - `tcp://...` / `udp://...` / `icmp://...` schemes (icmp: no port). + /// + /// Hostnames are rejected (use `--http-deny` for domain blocking). + pub fn parse(spec: &str) -> Result, SandboxError> { + if spec == "private" { + let mut out = Vec::new(); + for proto in [Protocol::Tcp, Protocol::Udp, Protocol::Icmp] { + for c in PRIVATE_CIDRS { + out.push(NetDeny { + protocol: proto, + target: DenyTarget::Cidr(IpCidr::parse(c)?), + ports: Vec::new(), + all_ports: true, + }); + } + } + return Ok(out); + } + + let (protocol, rest) = match spec.split_once("://") { + Some((scheme, body)) => { + let proto = Protocol::parse(scheme).ok_or_else(|| { + SandboxError::Invalid(format!( + "--net-deny: unknown scheme `{}://` in `{}` (expected tcp, udp, icmp)", + scheme, spec + )) + })?; + (proto, body) + } + None => (Protocol::Tcp, spec), + }; + + // ICMP carries no port: the whole body is the target. + if protocol == Protocol::Icmp { + return Ok(vec![NetDeny { + protocol, + target: parse_deny_target(rest, spec)?, + ports: Vec::new(), + all_ports: true, + }]); + } + + // 1. Bracketed IPv6 with a port: `[addr]:ports`. + if let Some(stripped) = rest.strip_prefix('[') { + let (inside, port_part) = stripped.rsplit_once("]:").ok_or_else(|| { + SandboxError::Invalid(format!( + "--net-deny: malformed bracketed address in `{}`", + spec + )) + })?; + let (ports, all_ports) = parse_deny_ports(port_part, spec)?; + return Ok(vec![NetDeny { + protocol, + target: DenyTarget::Cidr(IpCidr::parse(inside)?), + ports, + all_ports, + }]); + } + + // 2. Whole body is an IP/CIDR with no port -> all ports. This + // is what makes bare `10.0.0.0/8` and IPv6 `fc00::/7` work. + if let Ok(cidr) = IpCidr::parse(rest) { + return Ok(vec![NetDeny { + protocol, + target: DenyTarget::Cidr(cidr), + ports: Vec::new(), + all_ports: true, + }]); + } + + // 3. `target:ports` where target is an IPv4/CIDR, `*`, or empty. + let (host_part, port_part) = rest.rsplit_once(':').ok_or_else(|| { + SandboxError::Invalid(format!( + "--net-deny: expected an IP, CIDR, or `:port` in `{}`", + spec + )) + })?; + let target = parse_deny_target(host_part, spec)?; + let (ports, all_ports) = parse_deny_ports(port_part, spec)?; + Ok(vec![NetDeny { + protocol, + target, + ports, + all_ports, + }]) + } +} + +/// Parse a deny target: `*` / empty -> any IP, otherwise an IP/CIDR. +/// Hostnames fail here via `IpCidr::parse`. +fn parse_deny_target(s: &str, _full: &str) -> Result { + match s { + "" | "*" => Ok(DenyTarget::AnyIp), + _ => Ok(DenyTarget::Cidr(IpCidr::parse(s)?)), + } +} + +/// Parse the port suffix of a deny spec. `*` means all ports. +fn parse_deny_ports(s: &str, full: &str) -> Result<(Vec, bool), SandboxError> { + let mut ports = Vec::new(); + let mut saw_wildcard = false; + for p in s.split(',') { + let p = p.trim(); + if p == "*" { + saw_wildcard = true; + continue; + } + let n: u16 = p.parse().map_err(|_| { + SandboxError::Invalid(format!("--net-deny: invalid port `{}` in `{}`", p, full)) + })?; + if n == 0 { + return Err(SandboxError::Invalid(format!( + "--net-deny: port 0 is not valid in `{}`", + full + ))); + } + ports.push(n); + } + if saw_wildcard && !ports.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-deny: cannot mix `*` with concrete ports in `{}`", + full + ))); + } + if !saw_wildcard && ports.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-deny: at least one port required in `{}`", + full + ))); + } + Ok((ports, saw_wildcard)) +} + /// L4 protocol that a `NetAllow` rule applies to. /// /// `Tcp` is the default if a rule has no scheme (the bare `host:port` @@ -1773,4 +1950,76 @@ mod tests { assert!(IpCidr::parse("10.0.0.0/33").is_err()); assert!(IpCidr::parse("fc00::/129").is_err()); } + + // --- NetDeny::parse tests --- + + #[test] + fn netdeny_bare_cidr_is_all_ports_tcp() { + let rules = NetDeny::parse("10.0.0.0/8").unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].protocol, Protocol::Tcp); + assert!(matches!(rules[0].target, DenyTarget::Cidr(_))); + assert!(rules[0].all_ports); + } + + #[test] + fn netdeny_bare_ip_is_host_route_all_ports() { + let rules = NetDeny::parse("169.254.169.254").unwrap(); + assert_eq!(rules.len(), 1); + match &rules[0].target { + DenyTarget::Cidr(c) => assert_eq!(c.prefix_len, 32), + _ => panic!("expected cidr"), + } + assert!(rules[0].all_ports); + } + + #[test] + fn netdeny_cidr_with_port() { + let rules = NetDeny::parse("10.0.0.0/8:443").unwrap(); + assert_eq!(rules[0].ports, vec![443]); + assert!(!rules[0].all_ports); + } + + #[test] + fn netdeny_any_ip_port() { + let rules = NetDeny::parse(":25").unwrap(); + assert!(matches!(rules[0].target, DenyTarget::AnyIp)); + assert_eq!(rules[0].ports, vec![25]); + } + + #[test] + fn netdeny_udp_scheme() { + let rules = NetDeny::parse("udp://192.168.0.0/16:53").unwrap(); + assert_eq!(rules[0].protocol, Protocol::Udp); + assert_eq!(rules[0].ports, vec![53]); + } + + #[test] + fn netdeny_ipv6_bracket_port() { + let rules = NetDeny::parse("[::1]:443").unwrap(); + assert_eq!(rules[0].ports, vec![443]); + match &rules[0].target { + DenyTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), + _ => panic!("expected cidr"), + } + } + + #[test] + fn netdeny_rejects_hostname() { + assert!(NetDeny::parse("evil.com:443").is_err()); + assert!(NetDeny::parse("evil.com").is_err()); + } + + #[test] + fn netdeny_private_token_expands_v4_and_v6() { + let rules = NetDeny::parse("private").unwrap(); + // Each curated CIDR is emitted once per protocol (tcp, udp, icmp). + assert_eq!(rules.len(), PRIVATE_CIDRS.len() * 3); + // Metadata endpoint is covered. + let meta: IpAddr = "169.254.169.254".parse().unwrap(); + assert!(rules.iter().any(|r| matches!(&r.target, DenyTarget::Cidr(c) if c.contains(meta)))); + // IPv6 ULA is covered. + let ula: IpAddr = "fd00::1".parse().unwrap(); + assert!(rules.iter().any(|r| matches!(&r.target, DenyTarget::Cidr(c) if c.contains(ula)))); + } } From 7c6dd7474bc587614b5421eb3873e67c49abe6d2 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 3 Jun 2026 19:30:39 -0700 Subject: [PATCH 03/24] net: reject empty icmp --net-deny body; document IPv6 bracket form Signed-off-by: Cong Wang --- crates/sandlock-core/src/network.rs | 41 ++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index 52e0b40..89e9098 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -133,7 +133,9 @@ impl NetDeny { /// - `private` -- the curated internal-range set, all protocols. /// - `` / `` -- all ports (TCP default scheme). /// - `:` / `:` / `:*`. - /// - `[]:` -- bracketed IPv6 with a port. + /// - `[]:` -- bracketed IPv6 with a port (IPv6 + /// combined with a port MUST use this bracket form because a bare + /// `addr:port` string is itself a valid IPv6 address). /// - `:` / `*:` -- any IP, that port. /// - `tcp://...` / `udp://...` / `icmp://...` schemes (icmp: no port). /// @@ -169,9 +171,15 @@ impl NetDeny { // ICMP carries no port: the whole body is the target. if protocol == Protocol::Icmp { + if rest.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-deny: icmp rule needs an IP/CIDR or `*`, got `{}`", + spec + ))); + } return Ok(vec![NetDeny { protocol, - target: parse_deny_target(rest, spec)?, + target: parse_deny_target(rest)?, ports: Vec::new(), all_ports: true, }]); @@ -212,7 +220,7 @@ impl NetDeny { spec )) })?; - let target = parse_deny_target(host_part, spec)?; + let target = parse_deny_target(host_part)?; let (ports, all_ports) = parse_deny_ports(port_part, spec)?; Ok(vec![NetDeny { protocol, @@ -225,7 +233,7 @@ impl NetDeny { /// Parse a deny target: `*` / empty -> any IP, otherwise an IP/CIDR. /// Hostnames fail here via `IpCidr::parse`. -fn parse_deny_target(s: &str, _full: &str) -> Result { +fn parse_deny_target(s: &str) -> Result { match s { "" | "*" => Ok(DenyTarget::AnyIp), _ => Ok(DenyTarget::Cidr(IpCidr::parse(s)?)), @@ -2022,4 +2030,29 @@ mod tests { let ula: IpAddr = "fd00::1".parse().unwrap(); assert!(rules.iter().any(|r| matches!(&r.target, DenyTarget::Cidr(c) if c.contains(ula)))); } + + #[test] + fn netdeny_bare_ipv6_address_all_ports() { + let rules = NetDeny::parse("::1").unwrap(); + assert_eq!(rules.len(), 1); + assert!(rules[0].all_ports); + match &rules[0].target { + DenyTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), + _ => panic!("expected cidr"), + } + } + + #[test] + fn netdeny_bare_ipv6_cidr_all_ports() { + let rules = NetDeny::parse("fc00::/7").unwrap(); + assert_eq!(rules.len(), 1); + assert!(rules[0].all_ports); + let ula: std::net::IpAddr = "fd00::1".parse().unwrap(); + assert!(matches!(&rules[0].target, DenyTarget::Cidr(c) if c.contains(ula))); + } + + #[test] + fn netdeny_empty_icmp_body_is_rejected() { + assert!(NetDeny::parse("icmp://").is_err()); + } } From 23675be281d105b81533b4563778cbb62933ab74 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 3 Jun 2026 19:33:13 -0700 Subject: [PATCH 04/24] seccomp: add NetworkPolicy::DenyList variant Signed-off-by: Cong Wang --- crates/sandlock-core/src/seccomp/notif.rs | 72 +++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index 4d0a41c..248c782 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -248,6 +248,18 @@ pub enum NetworkPolicy { /// `*:port`). any_ip_ports: HashSet, }, + /// Default-allow denylist: a connection is permitted unless the + /// destination IP/port matches a deny rule. From `--net-deny`. + DenyList { + /// (network, denied-ports) rules. `PortAllow::Any` denies every + /// port to the network; `Specific` denies only those ports. + cidrs: Vec<(crate::network::IpCidr, PortAllow)>, + /// Ports denied for any IP (the `:port` form). + any_ip_ports: HashSet, + /// Deny everything (the `:*` / `*:*` form). Rare; here for + /// completeness so the form is not silently a no-op. + deny_all: bool, + }, } impl NetworkPolicy { @@ -265,6 +277,27 @@ impl NetworkPolicy { None => false, } } + NetworkPolicy::DenyList { cidrs, any_ip_ports, deny_all } => { + if *deny_all { + return false; + } + if any_ip_ports.contains(&port) { + return false; + } + for (net, denied) in cidrs { + if net.contains(ip) { + match denied { + PortAllow::Any => return false, + PortAllow::Specific(s) => { + if s.contains(&port) { + return false; + } + } + } + } + } + true + } } } } @@ -1842,4 +1875,43 @@ mod tests { assert!(result.is_ok()); assert_eq!(data, 0x1234567890ABCDEF); } + + #[test] + fn denylist_blocks_matching_cidr_allows_rest() { + use crate::network::IpCidr; + let policy = NetworkPolicy::DenyList { + cidrs: vec![(IpCidr::parse("10.0.0.0/8").unwrap(), PortAllow::Any)], + any_ip_ports: HashSet::new(), + deny_all: false, + }; + assert!(!policy.allows("10.1.2.3".parse().unwrap(), 443)); // denied + assert!(policy.allows("8.8.8.8".parse().unwrap(), 443)); // allowed + } + + #[test] + fn denylist_blocks_any_ip_port() { + let mut ports = HashSet::new(); + ports.insert(25u16); + let policy = NetworkPolicy::DenyList { + cidrs: Vec::new(), + any_ip_ports: ports, + deny_all: false, + }; + assert!(!policy.allows("8.8.8.8".parse().unwrap(), 25)); // denied + assert!(policy.allows("8.8.8.8".parse().unwrap(), 80)); // allowed + } + + #[test] + fn denylist_specific_ports_on_cidr() { + use crate::network::IpCidr; + let mut ports = HashSet::new(); + ports.insert(443u16); + let policy = NetworkPolicy::DenyList { + cidrs: vec![(IpCidr::parse("1.2.3.4/32").unwrap(), PortAllow::Specific(ports))], + any_ip_ports: HashSet::new(), + deny_all: false, + }; + assert!(!policy.allows("1.2.3.4".parse().unwrap(), 443)); // denied + assert!(policy.allows("1.2.3.4".parse().unwrap(), 80)); // allowed + } } From 7de52a6bae8dc9aeedf91bc2a81647821619953e Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 3 Jun 2026 21:58:55 -0700 Subject: [PATCH 05/24] net: resolve --net-deny rules to per-protocol policies Signed-off-by: Cong Wang --- crates/sandlock-core/src/network.rs | 87 +++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index 89e9098..339404f 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -1385,6 +1385,65 @@ pub async fn resolve_net_allow( }) } +/// Per-protocol resolved deny policies, ready for `NetworkState`. +pub struct ResolvedNetDenySet { + pub tcp: crate::seccomp::notif::NetworkPolicy, + pub udp: crate::seccomp::notif::NetworkPolicy, + pub icmp: crate::seccomp::notif::NetworkPolicy, +} + +/// Resolve `--net-deny` rules into per-protocol `DenyList` policies. +/// A protocol with no deny rules stays `Unrestricted` (allow-all). +pub fn resolve_net_deny(rules: &[NetDeny]) -> ResolvedNetDenySet { + use crate::seccomp::notif::{NetworkPolicy, PortAllow}; + + let per_proto = |target: Protocol| -> NetworkPolicy { + let mut cidrs: Vec<(IpCidr, PortAllow)> = Vec::new(); + let mut any_ip_ports: HashSet = HashSet::new(); + let mut deny_all = false; + let mut saw_rule = false; + + for rule in rules.iter().filter(|r| r.protocol == target) { + saw_rule = true; + match &rule.target { + DenyTarget::AnyIp => { + if rule.all_ports || target == Protocol::Icmp { + deny_all = true; + } else { + for &p in &rule.ports { + any_ip_ports.insert(p); + } + } + } + DenyTarget::Cidr(c) => { + let pa = if rule.all_ports || target == Protocol::Icmp { + PortAllow::Any + } else { + PortAllow::Specific(rule.ports.iter().copied().collect()) + }; + cidrs.push((*c, pa)); + } + } + } + + if !saw_rule { + NetworkPolicy::Unrestricted + } else { + NetworkPolicy::DenyList { + cidrs, + any_ip_ports, + deny_all, + } + } + }; + + ResolvedNetDenySet { + tcp: per_proto(Protocol::Tcp), + udp: per_proto(Protocol::Udp), + icmp: per_proto(Protocol::Icmp), + } +} + /// Compose the synthetic `/etc/hosts` served to the sandbox. /// /// - **No chroot**: emit the fixed loopback base @@ -2055,4 +2114,32 @@ mod tests { fn netdeny_empty_icmp_body_is_rejected() { assert!(NetDeny::parse("icmp://").is_err()); } + + // --- resolve_net_deny tests --- + + #[test] + fn resolve_net_deny_groups_per_protocol() { + let rules = NetDeny::parse("10.0.0.0/8").unwrap(); + let set = resolve_net_deny(&rules); + // TCP policy denies 10.x, UDP/ICMP unaffected (still allow-all). + assert!(!set.tcp.allows("10.0.0.1".parse().unwrap(), 443)); + assert!(set.udp.allows("10.0.0.1".parse().unwrap(), 443)); + } + + #[test] + fn resolve_net_deny_private_covers_metadata_all_protocols() { + let rules = NetDeny::parse("private").unwrap(); + let set = resolve_net_deny(&rules); + let meta: std::net::IpAddr = "169.254.169.254".parse().unwrap(); + assert!(!set.tcp.allows(meta, 80)); + assert!(!set.udp.allows(meta, 80)); + } + + #[test] + fn resolve_net_deny_any_ip_port() { + let rules = NetDeny::parse(":25").unwrap(); + let set = resolve_net_deny(&rules); + assert!(!set.tcp.allows("8.8.8.8".parse().unwrap(), 25)); + assert!(set.tcp.allows("8.8.8.8".parse().unwrap(), 80)); + } } From c62392f44a029101a6d943e861a10c95cc6a10d5 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 3 Jun 2026 22:04:29 -0700 Subject: [PATCH 06/24] sandbox: add --net-deny flag, builder method, and exclusivity check Signed-off-by: Cong Wang --- crates/sandlock-core/src/network.rs | 6 +-- crates/sandlock-core/src/sandbox.rs | 70 ++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index 339404f..38d4efa 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -22,7 +22,7 @@ const MAX_SEND_BUF: usize = 64 << 20; /// An IPv4 or IPv6 address with a prefix length, used by `--net-deny` /// to match destination IPs by exact address (`/32`, `/128`) or by range. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct IpCidr { pub addr: IpAddr, pub prefix_len: u8, @@ -92,7 +92,7 @@ impl IpCidr { } /// What a deny rule targets at the IP layer. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum DenyTarget { /// Any destination IP (the `:port` / `*:port` form). AnyIp, @@ -103,7 +103,7 @@ pub enum DenyTarget { /// A single `--net-deny` rule. Unlike `NetAllow`, the target is always /// a literal IP/CIDR (or any-IP) resolved at parse time, never a /// hostname, so no DNS is involved and matching is rebinding-safe. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct NetDeny { pub protocol: Protocol, pub target: DenyTarget, diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 1de5a9b..d899d8d 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -10,7 +10,7 @@ use tokio::task::JoinHandle; use crate::context; use crate::error::SandboxError; pub use crate::http::{http_acl_check, normalize_path, prefix_or_exact_match, HttpRule}; -pub use crate::network::{NetAllow, Protocol}; +pub use crate::network::{NetAllow, NetDeny, Protocol}; use crate::protection::{Protection, ProtectionPolicy, ProtectionState, ProtectionStatus}; /// A byte size value. @@ -252,6 +252,9 @@ pub struct Sandbox { /// remain reachable. HTTP rules with wildcard hosts auto-add /// `(Tcp, None, [80])` instead. pub net_allow: Vec, + /// Parsed `--net-deny` rules (default-allow, IP/CIDR/port denylist). + /// Mutually exclusive with `net_allow`. + pub net_deny: Vec, pub net_bind: Vec, // HTTP ACL pub http_allow: Vec, @@ -375,6 +378,7 @@ impl Clone for Sandbox { extra_allow_syscalls: self.extra_allow_syscalls.clone(), protection_policy: self.protection_policy.clone(), net_allow: self.net_allow.clone(), + net_deny: self.net_deny.clone(), net_bind: self.net_bind.clone(), http_allow: self.http_allow.clone(), http_deny: self.http_deny.clone(), @@ -1805,6 +1809,13 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(long = "net-allow", value_name = "SPEC"))] pub net_allow: Vec, + /// `--net-deny`: default-allow networking, block these IPs/CIDRs/ports. + /// Accepts ``, ``, `:`, `:`, `[]:`, + /// and the `private` token (all internal ranges). Hostnames are rejected; + /// use `--http-deny` for domains. Mutually exclusive with `--net-allow`. + #[cfg_attr(feature = "cli", arg(long = "net-deny", value_name = "SPEC", value_delimiter = ','))] + pub net_deny: Vec, + #[cfg_attr(feature = "cli", arg(long = "net-bind"))] pub net_bind: Vec, @@ -1975,6 +1986,7 @@ impl Clone for SandboxBuilder { extra_deny_syscalls: self.extra_deny_syscalls.clone(), extra_allow_syscalls: self.extra_allow_syscalls.clone(), net_allow: self.net_allow.clone(), + net_deny: self.net_deny.clone(), net_bind: self.net_bind.clone(), http_allow: self.http_allow.clone(), http_deny: self.http_deny.clone(), @@ -2091,6 +2103,12 @@ impl SandboxBuilder { self } + /// Add a `--net-deny` rule. See the field docs for accepted forms. + pub fn net_deny(mut self, spec: impl Into) -> Self { + self.net_deny.push(spec.into()); + self + } + pub fn net_bind_port(mut self, port: u16) -> Self { self.net_bind.push(port); self @@ -2372,6 +2390,22 @@ impl SandboxBuilder { .map(|s| NetAllow::parse(&s)) .collect::>()?; + // Parse --net-deny rules (the `private` token expands here). + // NetDeny::parse returns a Vec per spec, so flatten. + let mut net_deny: Vec = Vec::new(); + for spec in self.net_deny { + net_deny.extend(NetDeny::parse(&spec)?); + } + + // --net-allow and --net-deny are mutually exclusive. Check the + // user-supplied allow count (the original specs), not the post-HTTP + // extension, so a coexisting --http-deny does not false-trigger. + if !net_allow.is_empty() && !net_deny.is_empty() { + return Err(SandboxError::Invalid( + "--net-allow and --net-deny are mutually exclusive".into(), + )); + } + crate::http::extend_net_allow_for_http( &mut net_allow, &http_allow, @@ -2387,6 +2421,7 @@ impl SandboxBuilder { extra_allow_syscalls: self.extra_allow_syscalls, protection_policy: self.protection_policy, net_allow, + net_deny, net_bind: self.net_bind, http_allow, http_deny, @@ -2615,4 +2650,37 @@ mod tests { assert!(!p3.allows_sysv_ipc()); } + #[test] + fn builder_parses_net_deny() { + let policy = Sandbox::builder() + .net_deny("10.0.0.0/8") + .build() + .unwrap(); + assert_eq!(policy.net_deny.len(), 1); + } + + #[test] + fn builder_net_deny_private_expands() { + let policy = Sandbox::builder() + .net_deny("private") + .build() + .unwrap(); + assert!(policy.net_deny.len() > 3); // expanded across CIDRs x protocols + } + + #[test] + fn builder_rejects_net_allow_and_net_deny_together() { + let err = Sandbox::builder() + .net_allow("github.com:443") + .net_deny("10.0.0.0/8") + .build(); + assert!(err.is_err()); + } + + #[test] + fn builder_net_deny_rejects_hostname() { + let err = Sandbox::builder().net_deny("evil.com:443").build(); + assert!(err.is_err()); + } + } From 80c76eb98471cbf41580ff1f7f317dc90d4e3411 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 3 Jun 2026 22:18:01 -0700 Subject: [PATCH 07/24] sandbox: enforce --net-deny via DenyList policies in the supervisor Signed-off-by: Cong Wang --- crates/sandlock-core/src/sandbox.rs | 68 ++++++++++++++++++----------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index d899d8d..8cd2ec8 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -1394,6 +1394,7 @@ impl Sandbox { max_processes: self.max_processes, has_memory_limit: self.max_memory.is_some(), has_net_allowlist: !self.net_allow.is_empty() + || !self.net_deny.is_empty() || self.policy_fn.is_some() || !self.http_allow.is_empty() || !self.http_deny.is_empty(), @@ -1429,33 +1430,40 @@ impl Sandbox { let time_random_state = TimeRandomState::new(time_offset, random_state); let mut net_state = NetworkState::new(); - let no_rules = self.net_allow.is_empty(); - let policy_from = |resolved: &network::ResolvedNetAllow| { - if no_rules || resolved.any_ip_all_ports { - crate::seccomp::notif::NetworkPolicy::Unrestricted - } else { - use crate::seccomp::notif::PortAllow; - let per_ip = resolved - .per_ip - .iter() - .map(|(ip, ports)| { - let allow = if resolved.per_ip_all_ports.contains(ip) { - PortAllow::Any - } else { - PortAllow::Specific(ports.clone()) - }; - (*ip, allow) - }) - .collect(); - crate::seccomp::notif::NetworkPolicy::AllowList { - per_ip, - any_ip_ports: resolved.any_ip_ports.clone(), + if !self.net_deny.is_empty() { + let resolved_deny = network::resolve_net_deny(&self.net_deny); + net_state.tcp_policy = resolved_deny.tcp; + net_state.udp_policy = resolved_deny.udp; + net_state.icmp_policy = resolved_deny.icmp; + } else { + let no_rules = self.net_allow.is_empty(); + let policy_from = |resolved: &network::ResolvedNetAllow| { + if no_rules || resolved.any_ip_all_ports { + crate::seccomp::notif::NetworkPolicy::Unrestricted + } else { + use crate::seccomp::notif::PortAllow; + let per_ip = resolved + .per_ip + .iter() + .map(|(ip, ports)| { + let allow = if resolved.per_ip_all_ports.contains(ip) { + PortAllow::Any + } else { + PortAllow::Specific(ports.clone()) + }; + (*ip, allow) + }) + .collect(); + crate::seccomp::notif::NetworkPolicy::AllowList { + per_ip, + any_ip_ports: resolved.any_ip_ports.clone(), + } } - } - }; - net_state.tcp_policy = policy_from(&resolved_net_allow.tcp); - net_state.udp_policy = policy_from(&resolved_net_allow.udp); - net_state.icmp_policy = policy_from(&resolved_net_allow.icmp); + }; + net_state.tcp_policy = policy_from(&resolved_net_allow.tcp); + net_state.udp_policy = policy_from(&resolved_net_allow.udp); + net_state.icmp_policy = policy_from(&resolved_net_allow.icmp); + } net_state.http_acl_addr = self.rt().http_acl_handle.as_ref().map(|h| h.addr); 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()); @@ -2683,4 +2691,12 @@ mod tests { assert!(err.is_err()); } + #[test] + fn net_deny_resolves_to_denylist_policies() { + let policy = Sandbox::builder().net_deny("10.0.0.0/8").build().unwrap(); + let set = crate::network::resolve_net_deny(&policy.net_deny); + assert!(!set.tcp.allows("10.0.0.5".parse().unwrap(), 443)); + assert!(set.tcp.allows("8.8.8.8".parse().unwrap(), 443)); + } + } From fe8e70df6b7c16e83f7b3a9a8d1becbeb4173316 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 3 Jun 2026 22:24:36 -0700 Subject: [PATCH 08/24] profile: support [network].deny and --net-deny round-trip Signed-off-by: Cong Wang --- crates/sandlock-cli/src/main.rs | 34 +++++++++++++++++++++++++++++ crates/sandlock-core/src/profile.rs | 12 ++++++++++ crates/sandlock-core/src/sandbox.rs | 2 +- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index b053347..d55962d 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -357,6 +357,9 @@ async fn run_command(args: RunArgs) -> Result { }; b = b.net_allow(spec); } + for rule in &base.net_deny { + b = b.net_deny(format_net_deny(rule)); + } for p in &base.net_bind { b = b.net_bind_port(*p); } for rule in &base.http_allow { let s = format!("{} {}{}", rule.method, rule.host, rule.path); @@ -763,6 +766,37 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> Ok(()) } +/// Render a parsed NetDeny rule back into a --net-deny spec string, so a +/// profile loaded via --profile-file round-trips through the builder. +fn format_net_deny(rule: &sandlock_core::sandbox::NetDeny) -> String { + use sandlock_core::sandbox::{DenyTarget, Protocol}; + let target = match &rule.target { + DenyTarget::AnyIp => "*".to_string(), + DenyTarget::Cidr(c) => { + let base = format!("{}/{}", c.addr, c.prefix_len); + // Bracket IPv6 only when a port suffix will follow, because a + // bare addr:port is itself a valid IPv6 address. + if matches!(c.addr, std::net::IpAddr::V6(_)) && !rule.all_ports { + format!("[{}]", base) + } else { + base + } + } + }; + match rule.protocol { + Protocol::Icmp => format!("icmp://{}", target), + proto => { + let scheme = if matches!(proto, Protocol::Udp) { "udp://" } else { "" }; + if rule.all_ports { + format!("{}{}", scheme, target) + } else { + let ports = format_ports(&rule.ports, false); + format!("{}{}:{}", scheme, target, ports) + } + } + } +} + /// Parse an ISO 8601 timestamp (e.g. "2000-01-01T00:00:00Z") into a SystemTime. /// Render a port list back into the `--net-allow` port-suffix form: /// concrete ports become `80,443`; the all-ports wildcard becomes `*`. diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 23b9c91..c18bcea 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -82,6 +82,7 @@ pub struct FilesystemSection { pub struct NetworkSection { pub bind: Vec, pub allow: Vec, + pub deny: Vec, pub port_remap: bool, } @@ -169,6 +170,7 @@ pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), Sandlo // [network] for p in input.network.bind.iter() { b = b.net_bind_port(*p); } for r in input.network.allow.iter() { b = b.net_allow(r.as_str()); } + for r in input.network.deny.iter() { b = b.net_deny(r.as_str()); } if input.network.port_remap { b = b.port_remap(true); } // [http] @@ -509,6 +511,16 @@ mod tests { assert!(msg.contains("time_start"), "got: {msg}"); } + #[test] + fn profile_network_deny_parses() { + let toml = r#" + [network] + deny = ["10.0.0.0/8", "private"] + "#; + let (policy, _spec) = parse_profile(toml).unwrap(); + assert!(policy.net_deny.len() > 1); + } + #[test] fn isolation_key_is_rejected() { let toml = r#" diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 8cd2ec8..cbfd2f9 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -10,7 +10,7 @@ use tokio::task::JoinHandle; use crate::context; use crate::error::SandboxError; pub use crate::http::{http_acl_check, normalize_path, prefix_or_exact_match, HttpRule}; -pub use crate::network::{NetAllow, NetDeny, Protocol}; +pub use crate::network::{DenyTarget, IpCidr, NetAllow, NetDeny, Protocol}; use crate::protection::{Protection, ProtectionPolicy, ProtectionState, ProtectionStatus}; /// A byte size value. From d9ffc7fdb2247d1d266c494601c19396eab17281 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 3 Jun 2026 22:26:33 -0700 Subject: [PATCH 09/24] cli: reject --net-deny under --no-supervisor Signed-off-by: Cong Wang --- crates/sandlock-cli/src/main.rs | 2 ++ crates/sandlock-cli/tests/cli_test.rs | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index d55962d..476034b 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -667,6 +667,7 @@ fn validate_no_supervisor(args: &RunArgs) -> Result<()> { if pb.max_open_files.is_some() { bad.push("--max-open-files"); } if args.timeout.is_some() { bad.push("--timeout"); } if !pb.net_allow.is_empty() { bad.push("--net-allow"); } + if !pb.net_deny.is_empty() { bad.push("--net-deny"); } if !pb.net_bind.is_empty() { bad.push("--net-bind"); } if !pb.http_allow.is_empty() { bad.push("--http-allow"); } if !pb.http_deny.is_empty() { bad.push("--http-deny"); } @@ -725,6 +726,7 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> if !profile.fs_denied.is_empty() { bad.push("[filesystem].deny"); } if !profile.net_allow.is_empty() { bad.push("[network].allow"); } + if !profile.net_deny.is_empty() { bad.push("[network].deny"); } if !profile.net_bind.is_empty() { bad.push("[network].bind"); } if profile.port_remap { bad.push("[network].port_remap"); } if !profile.http_allow.is_empty() { bad.push("[http].allow"); } diff --git a/crates/sandlock-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index 511bb3d..f3828d5 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -162,6 +162,17 @@ fn test_no_supervisor_rejects_fs_deny() { assert!(stderr.contains("--fs-deny"), "stderr: {}", stderr); } +#[test] +fn test_no_supervisor_rejects_net_deny() { + let output = sandlock_bin() + .args(["run", "--no-supervisor", "--net-deny", "10.0.0.0/8", "--", "/bin/true"]) + .output() + .expect("failed to run"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--net-deny"), "stderr: {}", stderr); +} + #[test] fn test_no_supervisor_rejects_incompatible_flags() { let output = sandlock_bin() From eb9a1f48dc06ad4aa5234fc42bbb86b3470ab001 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 3 Jun 2026 22:44:55 -0700 Subject: [PATCH 10/24] net: enforce --net-deny at runtime (relax Landlock connect, trap on-behalf, allow UDP) Signed-off-by: Cong Wang --- crates/sandlock-core/src/context.rs | 21 ++++++++++++++++++++- crates/sandlock-core/src/landlock.rs | 14 ++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/sandlock-core/src/context.rs b/crates/sandlock-core/src/context.rs index 2fa442b..fa1133a 100644 --- a/crates/sandlock-core/src/context.rs +++ b/crates/sandlock-core/src/context.rs @@ -373,6 +373,7 @@ const PORT_REMAP_SYSCALLS: &[i64] = &[ fn needs_network_supervision(policy: &Sandbox) -> bool { !policy.net_allow.is_empty() + || !policy.net_deny.is_empty() || policy.policy_fn.is_some() || !policy.http_allow.is_empty() || !policy.http_deny.is_empty() @@ -584,9 +585,14 @@ pub fn arg_filters(policy: &Sandbox) -> Vec { use crate::sandbox::Protocol; let any_udp_rule = policy.net_allow.iter().any(|r| r.protocol == Protocol::Udp); let any_icmp_rule = policy.net_allow.iter().any(|r| r.protocol == Protocol::Icmp); + // `--net-deny` is default-allow, so UDP and the kernel ping socket + // (both SOCK_DGRAM) must be creatable; without this the sandbox + // could not even do DNS over UDP. Per-destination UDP/ICMP denial + // is still enforced on the sendto on-behalf path via the DenyList. + let net_deny_active = !policy.net_deny.is_empty(); let mut blocked_types: Vec = Vec::new(); blocked_types.push(SOCK_RAW); - if !any_udp_rule && !any_icmp_rule { + if !any_udp_rule && !any_icmp_rule && !net_deny_active { blocked_types.push(SOCK_DGRAM); } @@ -1170,6 +1176,19 @@ mod tests { assert!(nrs.contains(&(libc::SYS_sendmmsg as u32))); } + #[test] + fn test_notif_syscalls_net_deny() { + // --net-deny is default-allow but still needs every connect/sendto + // routed to the on-behalf path so the DenyList can refuse matches. + let policy = Sandbox::builder() + .net_deny("10.0.0.0/8") + .build() + .unwrap(); + let nrs = notif_syscalls(&policy, None); + assert!(nrs.contains(&(libc::SYS_connect as u32))); + assert!(nrs.contains(&(libc::SYS_sendto as u32))); + } + #[test] fn test_notif_syscalls_sandbox_name_enables_hostname_virtualization() { let policy = Sandbox::builder().build().unwrap(); diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index aa756ce..6c3eda7 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -280,6 +280,11 @@ pub fn compute_fs_mask(abi: u32, pol: &ProtectionPolicy) -> u64 { /// covers every port we drop `CONNECT_TCP` from the handled set (the /// on-behalf path is then the sole enforcer). /// +/// `--net-deny` is default-allow: every TCP connect must reach the +/// on-behalf seccomp path (the DenyList enforcer), so Landlock must not +/// gate `CONNECT_TCP` at all. A non-empty `net_deny` therefore forces the +/// wildcard treatment, exactly like an all-ports `--net-allow` rule. +/// /// Returns `(0, false)` when `Protection::NetTcp` is not `Active` /// (either disabled by policy or degraded on a kernel that does not /// provide TCP network hooks). @@ -302,10 +307,11 @@ pub fn compute_net_mask( return (0, false); } use crate::sandbox::Protocol; - let net_wildcard = sandbox - .net_allow - .iter() - .any(|r| r.protocol == Protocol::Tcp && r.all_ports); + let net_wildcard = !sandbox.net_deny.is_empty() + || sandbox + .net_allow + .iter() + .any(|r| r.protocol == Protocol::Tcp && r.all_ports); let mask = if net_wildcard { LANDLOCK_ACCESS_NET_BIND_TCP } else { From 2dec8013e531c6fe67ee67cde68038df5c5c7e26 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Wed, 3 Jun 2026 22:44:55 -0700 Subject: [PATCH 11/24] cli: wire --net-deny override into the builder and test exclusivity Signed-off-by: Cong Wang --- crates/sandlock-cli/src/main.rs | 1 + crates/sandlock-cli/tests/cli_test.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 476034b..f388ec1 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -419,6 +419,7 @@ async fn run_command(args: RunArgs) -> Result { for p in &pb.fs_writable { builder = builder.fs_write(p); } if let Some(n) = pb.max_processes { builder = builder.max_processes(n); } for spec in &pb.net_allow { builder = builder.net_allow(spec); } + for spec in &pb.net_deny { builder = builder.net_deny(spec); } for p in &pb.net_bind { builder = builder.net_bind_port(*p); } if let Some(seed) = pb.random_seed { builder = builder.random_seed(seed); } if pb.clean_env { builder = builder.clean_env(true); } diff --git a/crates/sandlock-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index f3828d5..b91c977 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -173,6 +173,19 @@ fn test_no_supervisor_rejects_net_deny() { assert!(stderr.contains("--net-deny"), "stderr: {}", stderr); } +#[test] +fn test_net_allow_and_net_deny_are_mutually_exclusive() { + // Also guards the CLI wiring: --net-deny must reach build(), otherwise + // the exclusivity check never fires and the flag is silently dropped. + let output = sandlock_bin() + .args(["run", "--net-allow", "github.com:443", "--net-deny", "10.0.0.0/8", "--", "/bin/true"]) + .output() + .expect("failed to run"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("mutually exclusive"), "stderr: {}", stderr); +} + #[test] fn test_no_supervisor_rejects_incompatible_flags() { let output = sandlock_bin() From f1f916303f26c9aa6f7899fdffb6a4f520a83f5a Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Thu, 4 Jun 2026 18:50:44 -0700 Subject: [PATCH 12/24] net: make port optional and drop redundant :* in net rule grammar Signed-off-by: Cong Wang --- README.md | 51 ++++++-- crates/sandlock-cli/src/main.rs | 91 ++++++++++---- crates/sandlock-core/src/network.rs | 113 +++++++++++++++--- crates/sandlock-core/src/sandbox.rs | 10 +- .../tests/integration/test_policy.rs | 5 +- 5 files changed, 211 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 00cd06b..542cc0e 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,12 @@ sandlock run --net-allow api.openai.com:443 -r /usr -r /lib -r /etc -- python3 a sandlock run --net-allow github.com:22,443 --net-allow :8080 \ -r /usr -r /lib -r /etc -- python3 agent.py -# Wildcard port — `host:*` permits every port to the host -sandlock run --net-allow github.com:* -r /usr -r /lib -r /etc -- ssh user@github.com +# Wildcard port (optional): a bare `host` (or `host:*`) permits every port +sandlock run --net-allow github.com -r /usr -r /lib -r /etc -- ssh user@github.com -# Unrestricted outbound — `:*` opens any host and any TCP port. For full -# egress add a UDP wildcard via the `udp://*:*` scheme. -sandlock run --net-allow :* --net-allow udp://*:* \ +# Unrestricted outbound: `*` opens any host and any TCP port (`:*` / `*:*` +# are equivalent). For full egress add a UDP wildcard, `udp://*`. +sandlock run --net-allow '*' --net-allow 'udp://*' \ -r /usr -r /lib -r /etc -- ./client # UDP — scheme prefix gates the protocol and scopes the destination @@ -126,6 +126,11 @@ sandlock run --net-allow udp://1.1.1.1:53 --net-allow :443 \ # Ping — kernel ping socket (SOCK_DGRAM) gated by net.ipv4.ping_group_range sandlock run --net-allow icmp://github.com -r /usr -r /lib -r /etc -- ping github.com +# Denylist: default-allow networking, block specific IPs/CIDRs/ports +# (inverse of --net-allow; mutually exclusive with it). Port is optional. +sandlock run --net-deny 169.254.169.254 --net-deny private \ + -r /usr -r /lib -r /etc -- python3 agent.py + # HTTP-level ACL (method + host + path rules via transparent proxy) # HTTP rules with concrete hosts auto-extend --net-allow with host:80,443 sandlock run \ @@ -580,9 +585,10 @@ Outbound traffic is gated by a single endpoint allowlist that names ``` --net-allow repeatable; no rules = deny all outbound - bare form host:port[,port,...] / :port / *:port / host:* / :* / *:* (TCP) + bare form host[:port[,port,...]] / :port / *:port / host:* / :* / *:* (TCP) + the port is optional: `host` or `*` alone means all ports tcp:// same suffix grammar — explicit TCP - udp:// same suffix grammar — UDP (`udp://*:*` opens any UDP) + udp:// same suffix grammar — UDP (`udp://*` opens any UDP) icmp:// host or `*`, no port — kernel ping socket (SOCK_DGRAM) ``` @@ -606,20 +612,41 @@ and port (port is N/A for ICMP). denies every TCP `connect()`, UDP / ICMP / raw socket creation are denied at the seccomp layer, and there is no on-behalf path active. For unrestricted TCP egress, opt in explicitly with -`--net-allow :*`; for any UDP, add `--net-allow udp://*:*`. +`--net-allow '*'`; for any UDP, add `--net-allow 'udp://*'`. + +**Denylist (`--net-deny`).** The inverse model: networking is +default-allow and the listed targets are blocked. Mutually exclusive +with `--net-allow`. Targets are literal IPs or CIDRs (hostnames are +rejected; use `--http-deny` for domains); the port is optional and +`*` matches any IP. The same scheme grammar applies (`tcp://` default, +`udp://`, `icmp://`), and the `private` token expands to all internal +ranges across every protocol. + +``` +--net-deny 10.0.0.0/8 # all ports on a CIDR (all protocols) +--net-deny 169.254.169.254:80 # one IP, one port +--net-deny 169.254.169.254:80,443 # comma-separated ports in one rule +--net-deny '*' # any IP, all ports (TCP) +--net-deny 'udp://192.168.0.0/16' # any UDP to a CIDR +--net-deny private # all internal ranges +``` + +Repeat the flag for multiple rules. **Resolution.** Concrete hostnames are resolved once at sandbox start and pinned in a synthetic `/etc/hosts` (across all protocols). The synthetic file replaces the real one only when at least one rule has -a concrete host; pure `:port` / `udp://*:*` / `icmp://*` rules leave +a concrete host; pure `:port` / `udp://*` / `icmp://*` rules leave the real `/etc/hosts` and DNS visible. **Wildcards.** Hostnames are matched literally — `--net-allow *.example.com:443` is **not** supported, list each domain you need. The `*` token is allowed as the host (alias for empty: `*:port` ≡ -`:port`) and as the port for TCP/UDP rules (`host:*`, `:*`, `*:*`, -`udp://*:*`). Mixing `*` with concrete ports (`host:80,*`) is -rejected. When any TCP rule uses the all-ports wildcard, Landlock no +`:port`) and as the port for TCP/UDP rules (`host:*`, `:*`, `*:*`). +The port is optional: omitting it means all ports, so `host` ≡ +`host:*` and `*` ≡ `:*` ≡ `*:*` (and `udp://*` ≡ `udp://*:*`). Mixing +`*` with concrete ports (`host:80,*`) is rejected. When any TCP rule +uses the all-ports wildcard, Landlock no longer filters TCP connect at the kernel level (it cannot express "every port" without enumerating 65535 rules); the on-behalf path becomes the sole enforcer, and for `:*` it short-circuits to diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index f388ec1..8aaa33f 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -341,21 +341,7 @@ async fn run_command(args: RunArgs) -> Result { for p in &base.fs_writable { b = b.fs_write(p); } for p in &base.fs_denied { b = b.fs_deny(p); } for rule in &base.net_allow { - let host_part = rule.host.as_deref().unwrap_or("*"); - let spec = match rule.protocol { - sandlock_core::sandbox::Protocol::Tcp => { - let ports = format_ports(&rule.ports, rule.all_ports); - format!("tcp://{}:{}", host_part, ports) - } - sandlock_core::sandbox::Protocol::Udp => { - let ports = format_ports(&rule.ports, rule.all_ports); - format!("udp://{}:{}", host_part, ports) - } - sandlock_core::sandbox::Protocol::Icmp => { - format!("icmp://{}", host_part) - } - }; - b = b.net_allow(spec); + b = b.net_allow(format_net_allow(rule)); } for rule in &base.net_deny { b = b.net_deny(format_net_deny(rule)); @@ -769,6 +755,27 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> Ok(()) } +/// Render a parsed NetAllow rule back into a --net-allow spec string, so a +/// profile loaded via --profile-file round-trips through the builder. Mirrors +/// `format_net_deny`: bare TCP, explicit `udp://`/`icmp://`, and the all-ports +/// case drops the redundant `:*`. +fn format_net_allow(rule: &sandlock_core::sandbox::NetAllow) -> String { + use sandlock_core::sandbox::Protocol; + let host = rule.host.as_deref().unwrap_or("*"); + match rule.protocol { + Protocol::Icmp => format!("icmp://{}", host), + proto => { + let scheme = if matches!(proto, Protocol::Udp) { "udp://" } else { "" }; + if rule.all_ports { + format!("{}{}", scheme, host) + } else { + let ports = format_ports(&rule.ports); + format!("{}{}:{}", scheme, host, ports) + } + } + } +} + /// Render a parsed NetDeny rule back into a --net-deny spec string, so a /// profile loaded via --profile-file round-trips through the builder. fn format_net_deny(rule: &sandlock_core::sandbox::NetDeny) -> String { @@ -793,24 +800,21 @@ fn format_net_deny(rule: &sandlock_core::sandbox::NetDeny) -> String { if rule.all_ports { format!("{}{}", scheme, target) } else { - let ports = format_ports(&rule.ports, false); + let ports = format_ports(&rule.ports); format!("{}{}:{}", scheme, target, ports) } } } } -/// Parse an ISO 8601 timestamp (e.g. "2000-01-01T00:00:00Z") into a SystemTime. -/// Render a port list back into the `--net-allow` port-suffix form: -/// concrete ports become `80,443`; the all-ports wildcard becomes `*`. -fn format_ports(ports: &[u16], all_ports: bool) -> String { - if all_ports { - "*".to_string() - } else { - ports.iter().map(|p| p.to_string()).collect::>().join(",") - } +/// Render a concrete port list into the comma-separated port-suffix form +/// (`80,443`). The all-ports case is handled by the callers, which drop the +/// suffix entirely rather than emitting `:*`. +fn format_ports(ports: &[u16]) -> String { + ports.iter().map(|p| p.to_string()).collect::>().join(",") } +/// Parse an ISO 8601 timestamp (e.g. "2000-01-01T00:00:00Z") into a SystemTime. fn parse_time_start(s: &str) -> Result { let ts: jiff::Timestamp = s.parse() .map_err(|e| anyhow!("invalid --time-start '{}': {}", s, e))?; @@ -825,3 +829,40 @@ fn parse_branch_action(flag: &str, s: &str) -> Result { other => Err(anyhow!("invalid {} value '{}': expected commit | abort | keep", flag, other)), } } + +#[cfg(test)] +mod render_tests { + use super::*; + use sandlock_core::sandbox::NetAllow; + + #[test] + fn render_allow_drops_redundant_all_ports_star() { + let r = NetAllow::parse("udp://*:*").unwrap(); + assert_eq!(format_net_allow(&r), "udp://*"); + } + + #[test] + fn render_allow_any_ip_all_ports_tcp_is_bare_star() { + let r = NetAllow::parse(":*").unwrap(); + assert_eq!(format_net_allow(&r), "*"); + } + + #[test] + fn render_allow_host_ports() { + let r = NetAllow::parse("example.com:443").unwrap(); + assert_eq!(format_net_allow(&r), "example.com:443"); + } + + #[test] + fn render_allow_roundtrips_through_parse() { + for spec in ["example.com:443", "udp://1.1.1.1:53", "icmp://github.com", "*", "udp://*"] { + let r = NetAllow::parse(spec).unwrap(); + let rendered = format_net_allow(&r); + let r2 = NetAllow::parse(&rendered).unwrap(); + assert_eq!(r.host, r2.host, "host mismatch for {spec}"); + assert_eq!(r.ports, r2.ports, "ports mismatch for {spec}"); + assert_eq!(r.all_ports, r2.all_ports, "all_ports mismatch for {spec}"); + assert_eq!(r.protocol, r2.protocol, "protocol mismatch for {spec}"); + } + } +} diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index 38d4efa..03b4b77 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -131,7 +131,8 @@ impl NetDeny { /// because the `private` token expands to many rules. Forms: /// /// - `private` -- the curated internal-range set, all protocols. - /// - `` / `` -- all ports (TCP default scheme). + /// - `` / `` / `*` -- all ports (the port is optional; `*` + /// targets any IP). TCP is the default scheme. /// - `:` / `:` / `:*`. /// - `[]:` -- bracketed IPv6 with a port (IPv6 /// combined with a port MUST use this bracket form because a bare @@ -202,6 +203,15 @@ impl NetDeny { }]); } + // An empty body must not silently mean "deny everything"; require + // an explicit `*` for the any-IP target. + if rest.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-deny: empty rule in `{}` (use `*` for any IP)", + spec + ))); + } + // 2. Whole body is an IP/CIDR with no port -> all ports. This // is what makes bare `10.0.0.0/8` and IPv6 `fc00::/7` work. if let Ok(cidr) = IpCidr::parse(rest) { @@ -213,15 +223,18 @@ impl NetDeny { }]); } - // 3. `target:ports` where target is an IPv4/CIDR, `*`, or empty. - let (host_part, port_part) = rest.rsplit_once(':').ok_or_else(|| { - SandboxError::Invalid(format!( - "--net-deny: expected an IP, CIDR, or `:port` in `{}`", - spec - )) - })?; + // 3. `target[:ports]` where target is an IPv4/CIDR, `*`, or empty. + // The port suffix is optional: a bare `*` (or any target with no + // `:port`) covers all ports, mirroring the bare IP/CIDR form above. + let (host_part, port_part) = match rest.rsplit_once(':') { + Some((h, p)) => (h, Some(p)), + None => (rest, None), + }; let target = parse_deny_target(host_part)?; - let (ports, all_ports) = parse_deny_ports(port_part, spec)?; + let (ports, all_ports) = match port_part { + Some(p) => parse_deny_ports(p, spec)?, + None => (Vec::new(), true), + }; Ok(vec![NetDeny { protocol, target, @@ -344,6 +357,8 @@ impl NetAllow { /// /// - `host:port[,port,...]`, `:port`, `*:port`, `host:*`, `:*`, `*:*` /// — TCP (the default scheme). + /// - `host` / `*` — no port suffix means all ports (the port is + /// optional, `*` matches any host); equivalent to `host:*` / `*:*`. /// - `tcp://...` — explicit TCP, same suffix grammar as the bare form. /// - `udp://...` — UDP, same suffix grammar as the bare form. /// - `icmp://host` or `icmp://*` — ICMP echo (kernel ping socket). @@ -371,16 +386,34 @@ impl NetAllow { return Self::parse_icmp(rest, s); } - let (host_part, port_part) = rest.rsplit_once(':').ok_or_else(|| { - SandboxError::Invalid(format!( - "--net-allow: expected `host:port` or `:port`, got `{}`", + if rest.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-allow: empty rule in `{}` (use `*` for any host)", s - )) - })?; + ))); + } + + // The port suffix is optional: a host (or `*`) with no `:port` + // covers all ports, mirroring the `host:*` form. + let (host_part, port_part) = match rest.rsplit_once(':') { + Some((h, p)) => (h, Some(p)), + None => (rest, None), + }; let host = match host_part { "" | "*" => None, h => Some(h.to_string()), }; + let port_part = match port_part { + Some(p) => p, + None => { + return Ok(NetAllow { + protocol, + host, + ports: Vec::new(), + all_ports: true, + }); + } + }; // Detect the wildcard token. We split on ',' first so a // single `*` is a clean match — `*,80` is rejected explicitly @@ -1590,9 +1623,27 @@ mod tests { } #[test] - fn netallow_parse_rejects_no_colon() { - let err = NetAllow::parse("example.com").unwrap_err(); - assert!(format!("{}", err).contains("expected")); + fn netallow_bare_host_is_all_ports() { + // No port suffix means "all ports" (port optional), symmetric + // with the `host:*` form. + let r = NetAllow::parse("example.com").unwrap(); + assert_eq!(r.host.as_deref(), Some("example.com")); + assert!(r.all_ports); + assert!(r.ports.is_empty()); + } + + #[test] + fn netallow_bare_star_is_any_host_all_ports() { + let r = NetAllow::parse("*").unwrap(); + assert_eq!(r.host, None); + assert!(r.all_ports); + assert!(r.ports.is_empty()); + } + + #[test] + fn netallow_empty_spec_rejected() { + assert!(NetAllow::parse("").is_err()); + assert!(NetAllow::parse("tcp://").is_err()); } #[test] @@ -2115,6 +2166,34 @@ mod tests { assert!(NetDeny::parse("icmp://").is_err()); } + #[test] + fn netdeny_bare_star_is_any_ip_all_ports() { + // `*` with no port is the any-IP, all-ports form (port optional, + // symmetric with a bare IP/CIDR). + let rules = NetDeny::parse("*").unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].protocol, Protocol::Tcp); + assert!(matches!(rules[0].target, DenyTarget::AnyIp)); + assert!(rules[0].all_ports); + assert!(rules[0].ports.is_empty()); + } + + #[test] + fn netdeny_udp_bare_star_all_ports() { + let rules = NetDeny::parse("udp://*").unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].protocol, Protocol::Udp); + assert!(matches!(rules[0].target, DenyTarget::AnyIp)); + assert!(rules[0].all_ports); + } + + #[test] + fn netdeny_empty_spec_rejected() { + // An empty body must not silently mean "deny everything". + assert!(NetDeny::parse("").is_err()); + assert!(NetDeny::parse("udp://").is_err()); + } + // --- resolve_net_deny tests --- #[test] diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index cbfd2f9..047401a 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -1818,10 +1818,12 @@ pub struct SandboxBuilder { pub net_allow: Vec, /// `--net-deny`: default-allow networking, block these IPs/CIDRs/ports. - /// Accepts ``, ``, `:`, `:`, `[]:`, - /// and the `private` token (all internal ranges). Hostnames are rejected; - /// use `--http-deny` for domains. Mutually exclusive with `--net-allow`. - #[cfg_attr(feature = "cli", arg(long = "net-deny", value_name = "SPEC", value_delimiter = ','))] + /// Accepts ``, ``, `:`, `:`, `*`, + /// `[]:`, and the `private` token (all internal ranges). + /// The port is optional (no `:port` means all ports). Hostnames are + /// rejected; use `--http-deny` for domains. Repeat the flag for multiple + /// rules. Mutually exclusive with `--net-allow`. + #[cfg_attr(feature = "cli", arg(long = "net-deny", value_name = "SPEC"))] pub net_deny: Vec, #[cfg_attr(feature = "cli", arg(long = "net-bind"))] diff --git a/crates/sandlock-core/tests/integration/test_policy.rs b/crates/sandlock-core/tests/integration/test_policy.rs index 5ab1d9f..09dbbbd 100644 --- a/crates/sandlock-core/tests/integration/test_policy.rs +++ b/crates/sandlock-core/tests/integration/test_policy.rs @@ -47,7 +47,10 @@ fn test_net_allow_parse_grammar() { assert!(NetAllow::parse("foo.com:22,443").is_ok()); assert!(NetAllow::parse(":8080").is_ok()); assert!(NetAllow::parse("*:8080").is_ok()); - assert!(NetAllow::parse("foo.com").is_err()); // missing port + assert!(NetAllow::parse("foo.com").is_ok()); // no port -> all ports + assert!(NetAllow::parse("foo.com").unwrap().all_ports); + assert!(NetAllow::parse("*").is_ok()); // any host, all ports + assert!(NetAllow::parse("").is_err()); // empty rule assert!(NetAllow::parse("foo.com:abc").is_err()); // bad port assert!(NetAllow::parse("foo.com:0").is_err()); // port 0 reserved assert!(NetAllow::parse("foo.com:").is_err()); // empty port list From 88bd17ca99d16528128c0b531c355368063daff8 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Fri, 5 Jun 2026 16:49:59 -0700 Subject: [PATCH 13/24] landlock: test that --net-deny forces the TCP connect wildcard Signed-off-by: Cong Wang --- crates/sandlock-core/src/landlock.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index 6c3eda7..07dc149 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -737,4 +737,25 @@ mod mask_contract_tests { assert_eq!(mask, 0); assert!(!wildcard); } + + #[test] + fn net_mask_net_deny_forces_wildcard_dropping_connect_tcp() { + // `--net-deny` is default-allow and enforced on the on-behalf + // seccomp path, so Landlock must not gate CONNECT_TCP: a non-empty + // net_deny forces the wildcard treatment (BIND_TCP only), exactly + // like an all-ports --net-allow rule. This pins the reconciliation + // of the net-deny runtime relaxation with compute_net_mask. + let pol = ProtectionPolicy::strict_all(); + let sb = Sandbox::builder() + .net_deny("10.0.0.0/8") + .build() + .expect("net_deny sandbox builds"); + let (mask, wildcard) = compute_net_mask(6, &pol, &sb, true); + assert_eq!( + mask, + LANDLOCK_ACCESS_NET_BIND_TCP, + "net_deny must drop CONNECT_TCP so all TCP connects reach the on-behalf path", + ); + assert!(wildcard, "net_deny must set the wildcard flag"); + } } From 6cc4d6a59bb88909c55dbe55f843376e474d4852 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 6 Jun 2026 20:17:31 -0700 Subject: [PATCH 14/24] net: remove the --net-deny private token Signed-off-by: Cong Wang --- README.md | 6 ++-- crates/sandlock-core/src/network.rs | 56 ++--------------------------- crates/sandlock-core/src/profile.rs | 2 +- crates/sandlock-core/src/sandbox.rs | 22 ++++-------- 4 files changed, 11 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 542cc0e..a1f107d 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ sandlock run --net-allow icmp://github.com -r /usr -r /lib -r /etc -- ping githu # Denylist: default-allow networking, block specific IPs/CIDRs/ports # (inverse of --net-allow; mutually exclusive with it). Port is optional. -sandlock run --net-deny 169.254.169.254 --net-deny private \ +sandlock run --net-deny 169.254.169.254 --net-deny 10.0.0.0/8 \ -r /usr -r /lib -r /etc -- python3 agent.py # HTTP-level ACL (method + host + path rules via transparent proxy) @@ -619,8 +619,7 @@ default-allow and the listed targets are blocked. Mutually exclusive with `--net-allow`. Targets are literal IPs or CIDRs (hostnames are rejected; use `--http-deny` for domains); the port is optional and `*` matches any IP. The same scheme grammar applies (`tcp://` default, -`udp://`, `icmp://`), and the `private` token expands to all internal -ranges across every protocol. +`udp://`, `icmp://`). ``` --net-deny 10.0.0.0/8 # all ports on a CIDR (all protocols) @@ -628,7 +627,6 @@ ranges across every protocol. --net-deny 169.254.169.254:80,443 # comma-separated ports in one rule --net-deny '*' # any IP, all ports (TCP) --net-deny 'udp://192.168.0.0/16' # any UDP to a CIDR ---net-deny private # all internal ranges ``` Repeat the flag for multiple rules. diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index 03b4b77..24d78f5 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -112,25 +112,10 @@ pub struct NetDeny { pub all_ports: bool, } -/// Curated internal/private ranges expanded by the `private` token. -/// Covers loopback, RFC1918, link-local (incl. the cloud metadata -/// endpoint 169.254.169.254), IPv6 loopback, ULA, and IPv6 link-local. -pub const PRIVATE_CIDRS: &[&str] = &[ - "127.0.0.0/8", - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16", - "169.254.0.0/16", - "::1/128", - "fc00::/7", - "fe80::/10", -]; - impl NetDeny { - /// Parse a `--net-deny` spec into one or more rules. Returns a `Vec` - /// because the `private` token expands to many rules. Forms: + /// Parse a `--net-deny` spec into a rule (wrapped in a `Vec` for the + /// caller's collection). Forms: /// - /// - `private` -- the curated internal-range set, all protocols. /// - `` / `` / `*` -- all ports (the port is optional; `*` /// targets any IP). TCP is the default scheme. /// - `:` / `:` / `:*`. @@ -142,21 +127,6 @@ impl NetDeny { /// /// Hostnames are rejected (use `--http-deny` for domain blocking). pub fn parse(spec: &str) -> Result, SandboxError> { - if spec == "private" { - let mut out = Vec::new(); - for proto in [Protocol::Tcp, Protocol::Udp, Protocol::Icmp] { - for c in PRIVATE_CIDRS { - out.push(NetDeny { - protocol: proto, - target: DenyTarget::Cidr(IpCidr::parse(c)?), - ports: Vec::new(), - all_ports: true, - }); - } - } - return Ok(out); - } - let (protocol, rest) = match spec.split_once("://") { Some((scheme, body)) => { let proto = Protocol::parse(scheme).ok_or_else(|| { @@ -2128,19 +2098,6 @@ mod tests { assert!(NetDeny::parse("evil.com").is_err()); } - #[test] - fn netdeny_private_token_expands_v4_and_v6() { - let rules = NetDeny::parse("private").unwrap(); - // Each curated CIDR is emitted once per protocol (tcp, udp, icmp). - assert_eq!(rules.len(), PRIVATE_CIDRS.len() * 3); - // Metadata endpoint is covered. - let meta: IpAddr = "169.254.169.254".parse().unwrap(); - assert!(rules.iter().any(|r| matches!(&r.target, DenyTarget::Cidr(c) if c.contains(meta)))); - // IPv6 ULA is covered. - let ula: IpAddr = "fd00::1".parse().unwrap(); - assert!(rules.iter().any(|r| matches!(&r.target, DenyTarget::Cidr(c) if c.contains(ula)))); - } - #[test] fn netdeny_bare_ipv6_address_all_ports() { let rules = NetDeny::parse("::1").unwrap(); @@ -2205,15 +2162,6 @@ mod tests { assert!(set.udp.allows("10.0.0.1".parse().unwrap(), 443)); } - #[test] - fn resolve_net_deny_private_covers_metadata_all_protocols() { - let rules = NetDeny::parse("private").unwrap(); - let set = resolve_net_deny(&rules); - let meta: std::net::IpAddr = "169.254.169.254".parse().unwrap(); - assert!(!set.tcp.allows(meta, 80)); - assert!(!set.udp.allows(meta, 80)); - } - #[test] fn resolve_net_deny_any_ip_port() { let rules = NetDeny::parse(":25").unwrap(); diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index c18bcea..833db21 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -515,7 +515,7 @@ mod tests { fn profile_network_deny_parses() { let toml = r#" [network] - deny = ["10.0.0.0/8", "private"] + deny = ["10.0.0.0/8", "192.168.0.0/16"] "#; let (policy, _spec) = parse_profile(toml).unwrap(); assert!(policy.net_deny.len() > 1); diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 047401a..49ab44c 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -1818,11 +1818,10 @@ pub struct SandboxBuilder { pub net_allow: Vec, /// `--net-deny`: default-allow networking, block these IPs/CIDRs/ports. - /// Accepts ``, ``, `:`, `:`, `*`, - /// `[]:`, and the `private` token (all internal ranges). - /// The port is optional (no `:port` means all ports). Hostnames are - /// rejected; use `--http-deny` for domains. Repeat the flag for multiple - /// rules. Mutually exclusive with `--net-allow`. + /// Accepts ``, ``, `:`, `:`, `*`, and + /// `[]:`. The port is optional (no `:port` means all ports). + /// Hostnames are rejected; use `--http-deny` for domains. Repeat the flag + /// for multiple rules. Mutually exclusive with `--net-allow`. #[cfg_attr(feature = "cli", arg(long = "net-deny", value_name = "SPEC"))] pub net_deny: Vec, @@ -2400,8 +2399,8 @@ impl SandboxBuilder { .map(|s| NetAllow::parse(&s)) .collect::>()?; - // Parse --net-deny rules (the `private` token expands here). - // NetDeny::parse returns a Vec per spec, so flatten. + // Parse --net-deny rules. NetDeny::parse returns a Vec per spec, + // so flatten. let mut net_deny: Vec = Vec::new(); for spec in self.net_deny { net_deny.extend(NetDeny::parse(&spec)?); @@ -2669,15 +2668,6 @@ mod tests { assert_eq!(policy.net_deny.len(), 1); } - #[test] - fn builder_net_deny_private_expands() { - let policy = Sandbox::builder() - .net_deny("private") - .build() - .unwrap(); - assert!(policy.net_deny.len() > 3); // expanded across CIDRs x protocols - } - #[test] fn builder_rejects_net_allow_and_net_deny_together() { let err = Sandbox::builder() From 003cf007849a593512a38c5cc487f09b85b53ac4 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 6 Jun 2026 20:34:09 -0700 Subject: [PATCH 15/24] net: collapse NetDeny::parse to return a single rule Signed-off-by: Cong Wang --- crates/sandlock-core/src/network.rs | 103 +++++++++++++--------------- crates/sandlock-core/src/sandbox.rs | 12 ++-- 2 files changed, 54 insertions(+), 61 deletions(-) diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index 24d78f5..85bf66f 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -113,8 +113,7 @@ pub struct NetDeny { } impl NetDeny { - /// Parse a `--net-deny` spec into a rule (wrapped in a `Vec` for the - /// caller's collection). Forms: + /// Parse a `--net-deny` spec into a single rule. Forms: /// /// - `` / `` / `*` -- all ports (the port is optional; `*` /// targets any IP). TCP is the default scheme. @@ -126,7 +125,7 @@ impl NetDeny { /// - `tcp://...` / `udp://...` / `icmp://...` schemes (icmp: no port). /// /// Hostnames are rejected (use `--http-deny` for domain blocking). - pub fn parse(spec: &str) -> Result, SandboxError> { + pub fn parse(spec: &str) -> Result { let (protocol, rest) = match spec.split_once("://") { Some((scheme, body)) => { let proto = Protocol::parse(scheme).ok_or_else(|| { @@ -148,12 +147,12 @@ impl NetDeny { spec ))); } - return Ok(vec![NetDeny { + return Ok(NetDeny { protocol, target: parse_deny_target(rest)?, ports: Vec::new(), all_ports: true, - }]); + }); } // 1. Bracketed IPv6 with a port: `[addr]:ports`. @@ -165,12 +164,12 @@ impl NetDeny { )) })?; let (ports, all_ports) = parse_deny_ports(port_part, spec)?; - return Ok(vec![NetDeny { + return Ok(NetDeny { protocol, target: DenyTarget::Cidr(IpCidr::parse(inside)?), ports, all_ports, - }]); + }); } // An empty body must not silently mean "deny everything"; require @@ -185,12 +184,12 @@ impl NetDeny { // 2. Whole body is an IP/CIDR with no port -> all ports. This // is what makes bare `10.0.0.0/8` and IPv6 `fc00::/7` work. if let Ok(cidr) = IpCidr::parse(rest) { - return Ok(vec![NetDeny { + return Ok(NetDeny { protocol, target: DenyTarget::Cidr(cidr), ports: Vec::new(), all_ports: true, - }]); + }); } // 3. `target[:ports]` where target is an IPv4/CIDR, `*`, or empty. @@ -205,12 +204,12 @@ impl NetDeny { Some(p) => parse_deny_ports(p, spec)?, None => (Vec::new(), true), }; - Ok(vec![NetDeny { + Ok(NetDeny { protocol, target, ports, all_ports, - }]) + }) } } @@ -2043,50 +2042,48 @@ mod tests { #[test] fn netdeny_bare_cidr_is_all_ports_tcp() { - let rules = NetDeny::parse("10.0.0.0/8").unwrap(); - assert_eq!(rules.len(), 1); - assert_eq!(rules[0].protocol, Protocol::Tcp); - assert!(matches!(rules[0].target, DenyTarget::Cidr(_))); - assert!(rules[0].all_ports); + let rule = NetDeny::parse("10.0.0.0/8").unwrap(); + assert_eq!(rule.protocol, Protocol::Tcp); + assert!(matches!(rule.target, DenyTarget::Cidr(_))); + assert!(rule.all_ports); } #[test] fn netdeny_bare_ip_is_host_route_all_ports() { - let rules = NetDeny::parse("169.254.169.254").unwrap(); - assert_eq!(rules.len(), 1); - match &rules[0].target { + let rule = NetDeny::parse("169.254.169.254").unwrap(); + match &rule.target { DenyTarget::Cidr(c) => assert_eq!(c.prefix_len, 32), _ => panic!("expected cidr"), } - assert!(rules[0].all_ports); + assert!(rule.all_ports); } #[test] fn netdeny_cidr_with_port() { - let rules = NetDeny::parse("10.0.0.0/8:443").unwrap(); - assert_eq!(rules[0].ports, vec![443]); - assert!(!rules[0].all_ports); + let rule = NetDeny::parse("10.0.0.0/8:443").unwrap(); + assert_eq!(rule.ports, vec![443]); + assert!(!rule.all_ports); } #[test] fn netdeny_any_ip_port() { - let rules = NetDeny::parse(":25").unwrap(); - assert!(matches!(rules[0].target, DenyTarget::AnyIp)); - assert_eq!(rules[0].ports, vec![25]); + let rule = NetDeny::parse(":25").unwrap(); + assert!(matches!(rule.target, DenyTarget::AnyIp)); + assert_eq!(rule.ports, vec![25]); } #[test] fn netdeny_udp_scheme() { - let rules = NetDeny::parse("udp://192.168.0.0/16:53").unwrap(); - assert_eq!(rules[0].protocol, Protocol::Udp); - assert_eq!(rules[0].ports, vec![53]); + let rule = NetDeny::parse("udp://192.168.0.0/16:53").unwrap(); + assert_eq!(rule.protocol, Protocol::Udp); + assert_eq!(rule.ports, vec![53]); } #[test] fn netdeny_ipv6_bracket_port() { - let rules = NetDeny::parse("[::1]:443").unwrap(); - assert_eq!(rules[0].ports, vec![443]); - match &rules[0].target { + let rule = NetDeny::parse("[::1]:443").unwrap(); + assert_eq!(rule.ports, vec![443]); + match &rule.target { DenyTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), _ => panic!("expected cidr"), } @@ -2100,10 +2097,9 @@ mod tests { #[test] fn netdeny_bare_ipv6_address_all_ports() { - let rules = NetDeny::parse("::1").unwrap(); - assert_eq!(rules.len(), 1); - assert!(rules[0].all_ports); - match &rules[0].target { + let rule = NetDeny::parse("::1").unwrap(); + assert!(rule.all_ports); + match &rule.target { DenyTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), _ => panic!("expected cidr"), } @@ -2111,11 +2107,10 @@ mod tests { #[test] fn netdeny_bare_ipv6_cidr_all_ports() { - let rules = NetDeny::parse("fc00::/7").unwrap(); - assert_eq!(rules.len(), 1); - assert!(rules[0].all_ports); + let rule = NetDeny::parse("fc00::/7").unwrap(); + assert!(rule.all_ports); let ula: std::net::IpAddr = "fd00::1".parse().unwrap(); - assert!(matches!(&rules[0].target, DenyTarget::Cidr(c) if c.contains(ula))); + assert!(matches!(&rule.target, DenyTarget::Cidr(c) if c.contains(ula))); } #[test] @@ -2127,21 +2122,19 @@ mod tests { fn netdeny_bare_star_is_any_ip_all_ports() { // `*` with no port is the any-IP, all-ports form (port optional, // symmetric with a bare IP/CIDR). - let rules = NetDeny::parse("*").unwrap(); - assert_eq!(rules.len(), 1); - assert_eq!(rules[0].protocol, Protocol::Tcp); - assert!(matches!(rules[0].target, DenyTarget::AnyIp)); - assert!(rules[0].all_ports); - assert!(rules[0].ports.is_empty()); + let rule = NetDeny::parse("*").unwrap(); + assert_eq!(rule.protocol, Protocol::Tcp); + assert!(matches!(rule.target, DenyTarget::AnyIp)); + assert!(rule.all_ports); + assert!(rule.ports.is_empty()); } #[test] fn netdeny_udp_bare_star_all_ports() { - let rules = NetDeny::parse("udp://*").unwrap(); - assert_eq!(rules.len(), 1); - assert_eq!(rules[0].protocol, Protocol::Udp); - assert!(matches!(rules[0].target, DenyTarget::AnyIp)); - assert!(rules[0].all_ports); + let rule = NetDeny::parse("udp://*").unwrap(); + assert_eq!(rule.protocol, Protocol::Udp); + assert!(matches!(rule.target, DenyTarget::AnyIp)); + assert!(rule.all_ports); } #[test] @@ -2155,8 +2148,8 @@ mod tests { #[test] fn resolve_net_deny_groups_per_protocol() { - let rules = NetDeny::parse("10.0.0.0/8").unwrap(); - let set = resolve_net_deny(&rules); + let rule = NetDeny::parse("10.0.0.0/8").unwrap(); + let set = resolve_net_deny(std::slice::from_ref(&rule)); // TCP policy denies 10.x, UDP/ICMP unaffected (still allow-all). assert!(!set.tcp.allows("10.0.0.1".parse().unwrap(), 443)); assert!(set.udp.allows("10.0.0.1".parse().unwrap(), 443)); @@ -2164,8 +2157,8 @@ mod tests { #[test] fn resolve_net_deny_any_ip_port() { - let rules = NetDeny::parse(":25").unwrap(); - let set = resolve_net_deny(&rules); + let rule = NetDeny::parse(":25").unwrap(); + let set = resolve_net_deny(std::slice::from_ref(&rule)); assert!(!set.tcp.allows("8.8.8.8".parse().unwrap(), 25)); assert!(set.tcp.allows("8.8.8.8".parse().unwrap(), 80)); } diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 49ab44c..8b6c33a 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -2399,12 +2399,12 @@ impl SandboxBuilder { .map(|s| NetAllow::parse(&s)) .collect::>()?; - // Parse --net-deny rules. NetDeny::parse returns a Vec per spec, - // so flatten. - let mut net_deny: Vec = Vec::new(); - for spec in self.net_deny { - net_deny.extend(NetDeny::parse(&spec)?); - } + // Parse --net-deny rules (one rule per spec). + let net_deny: Vec = self + .net_deny + .into_iter() + .map(|s| NetDeny::parse(&s)) + .collect::>()?; // --net-allow and --net-deny are mutually exclusive. Check the // user-supplied allow count (the original specs), not the post-HTTP From cb265939131b97f26e2cfda6d0b5b8a4fd76980c Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 6 Jun 2026 21:13:38 -0700 Subject: [PATCH 16/24] net: unify --net-allow/--net-deny rules and add CIDR/IPv6 support to allow Signed-off-by: Cong Wang --- crates/sandlock-cli/src/main.rs | 90 ++- crates/sandlock-core/src/http.rs | 14 +- crates/sandlock-core/src/network.rs | 606 +++++++++--------- crates/sandlock-core/src/sandbox.rs | 16 +- crates/sandlock-core/src/seccomp/notif.rs | 52 +- crates/sandlock-core/src/seccomp/state.rs | 1 + .../tests/integration/test_policy.rs | 26 +- 7 files changed, 415 insertions(+), 390 deletions(-) diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 8aaa33f..9aa7fce 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -341,10 +341,10 @@ async fn run_command(args: RunArgs) -> Result { for p in &base.fs_writable { b = b.fs_write(p); } for p in &base.fs_denied { b = b.fs_deny(p); } for rule in &base.net_allow { - b = b.net_allow(format_net_allow(rule)); + b = b.net_allow(format_net_rule(rule)); } for rule in &base.net_deny { - b = b.net_deny(format_net_deny(rule)); + b = b.net_deny(format_net_rule(rule)); } for p in &base.net_bind { b = b.net_bind_port(*p); } for rule in &base.http_allow { @@ -580,7 +580,11 @@ async fn run_command(args: RunArgs) -> Result { let registered_hosts: Vec = policy .net_allow .iter() - .filter_map(|r| r.host.clone()) + .filter_map(|r| match &r.target { + sandlock_core::sandbox::NetTarget::Host(h) => Some(h.clone()), + sandlock_core::sandbox::NetTarget::Cidr(c) => Some(c.to_string()), + sandlock_core::sandbox::NetTarget::AnyIp => None, + }) .collect(); if let Err(e) = network_registry::register( &sandbox_name, pid, std::collections::HashMap::new(), @@ -755,41 +759,23 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> Ok(()) } -/// Render a parsed NetAllow rule back into a --net-allow spec string, so a -/// profile loaded via --profile-file round-trips through the builder. Mirrors -/// `format_net_deny`: bare TCP, explicit `udp://`/`icmp://`, and the all-ports -/// case drops the redundant `:*`. -fn format_net_allow(rule: &sandlock_core::sandbox::NetAllow) -> String { - use sandlock_core::sandbox::Protocol; - let host = rule.host.as_deref().unwrap_or("*"); - match rule.protocol { - Protocol::Icmp => format!("icmp://{}", host), - proto => { - let scheme = if matches!(proto, Protocol::Udp) { "udp://" } else { "" }; - if rule.all_ports { - format!("{}{}", scheme, host) - } else { - let ports = format_ports(&rule.ports); - format!("{}{}:{}", scheme, host, ports) - } - } - } -} - -/// Render a parsed NetDeny rule back into a --net-deny spec string, so a -/// profile loaded via --profile-file round-trips through the builder. -fn format_net_deny(rule: &sandlock_core::sandbox::NetDeny) -> String { - use sandlock_core::sandbox::{DenyTarget, Protocol}; +/// Render a parsed `NetRule` back into a `--net-allow` / `--net-deny` spec +/// string, so a profile loaded via `--profile-file` round-trips through the +/// builder. Allow and deny share one grammar: bare TCP, explicit +/// `udp://`/`icmp://`, IPv6 bracketed only when a port follows, and the +/// all-ports case drops the redundant `:*`. +fn format_net_rule(rule: &sandlock_core::sandbox::NetRule) -> String { + use sandlock_core::sandbox::{NetTarget, Protocol}; let target = match &rule.target { - DenyTarget::AnyIp => "*".to_string(), - DenyTarget::Cidr(c) => { - let base = format!("{}/{}", c.addr, c.prefix_len); + NetTarget::AnyIp => "*".to_string(), + NetTarget::Host(h) => h.clone(), + NetTarget::Cidr(c) => { // Bracket IPv6 only when a port suffix will follow, because a // bare addr:port is itself a valid IPv6 address. if matches!(c.addr, std::net::IpAddr::V6(_)) && !rule.all_ports { - format!("[{}]", base) + format!("[{}]", c) } else { - base + c.to_string() } } }; @@ -833,33 +819,45 @@ fn parse_branch_action(flag: &str, s: &str) -> Result { #[cfg(test)] mod render_tests { use super::*; - use sandlock_core::sandbox::NetAllow; + use sandlock_core::sandbox::NetRule; #[test] fn render_allow_drops_redundant_all_ports_star() { - let r = NetAllow::parse("udp://*:*").unwrap(); - assert_eq!(format_net_allow(&r), "udp://*"); + let r = NetRule::parse_allow("udp://*:*").unwrap(); + assert_eq!(format_net_rule(&r), "udp://*"); } #[test] fn render_allow_any_ip_all_ports_tcp_is_bare_star() { - let r = NetAllow::parse(":*").unwrap(); - assert_eq!(format_net_allow(&r), "*"); + let r = NetRule::parse_allow(":*").unwrap(); + assert_eq!(format_net_rule(&r), "*"); } #[test] fn render_allow_host_ports() { - let r = NetAllow::parse("example.com:443").unwrap(); - assert_eq!(format_net_allow(&r), "example.com:443"); + let r = NetRule::parse_allow("example.com:443").unwrap(); + assert_eq!(format_net_rule(&r), "example.com:443"); + } + + #[test] + fn render_cidr_and_ipv6_round_trip() { + // CIDR and IPv6-literal targets render identically for allow/deny. + assert_eq!(format_net_rule(&NetRule::parse_allow("10.0.0.0/8:80").unwrap()), "10.0.0.0/8:80"); + assert_eq!(format_net_rule(&NetRule::parse_deny("10.0.0.0/8").unwrap()), "10.0.0.0/8"); + assert_eq!(format_net_rule(&NetRule::parse_allow("[::1]:443").unwrap()), "[::1]:443"); + assert_eq!(format_net_rule(&NetRule::parse_allow("::1").unwrap()), "::1"); } #[test] - fn render_allow_roundtrips_through_parse() { - for spec in ["example.com:443", "udp://1.1.1.1:53", "icmp://github.com", "*", "udp://*"] { - let r = NetAllow::parse(spec).unwrap(); - let rendered = format_net_allow(&r); - let r2 = NetAllow::parse(&rendered).unwrap(); - assert_eq!(r.host, r2.host, "host mismatch for {spec}"); + fn render_roundtrips_through_parse() { + for spec in [ + "example.com:443", "udp://1.1.1.1:53", "icmp://github.com", "*", "udp://*", + "10.0.0.0/8:80", "[fc00::/7]:443", "::1", "1.2.3.4", + ] { + let r = NetRule::parse_allow(spec).unwrap(); + let rendered = format_net_rule(&r); + let r2 = NetRule::parse_allow(&rendered).unwrap(); + assert_eq!(r.target, r2.target, "target mismatch for {spec}"); assert_eq!(r.ports, r2.ports, "ports mismatch for {spec}"); assert_eq!(r.all_ports, r2.all_ports, "all_ports mismatch for {spec}"); assert_eq!(r.protocol, r2.protocol, "protocol mismatch for {spec}"); diff --git a/crates/sandlock-core/src/http.rs b/crates/sandlock-core/src/http.rs index 39c0e04..3192877 100644 --- a/crates/sandlock-core/src/http.rs +++ b/crates/sandlock-core/src/http.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use crate::error::SandboxError; -use crate::network::{NetAllow, Protocol}; +use crate::network::{NetAllow, NetTarget, Protocol}; /// An HTTP access control rule. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -205,7 +205,7 @@ pub(crate) fn extend_net_allow_for_http( if wildcard_seen || (http_allow.is_empty() && http_deny.is_empty()) { net_allow.push(NetAllow { protocol: Protocol::Tcp, - host: None, + target: NetTarget::AnyIp, ports: http_ports.to_vec(), all_ports: false, }); @@ -214,7 +214,7 @@ pub(crate) fn extend_net_allow_for_http( for host in concrete_hosts { net_allow.push(NetAllow { protocol: Protocol::Tcp, - host: Some(host), + target: NetTarget::Host(host), ports: http_ports.to_vec(), all_ports: false, }); @@ -474,10 +474,10 @@ mod tests { assert_eq!(net_allow.len(), 2); assert_eq!(net_allow[0].protocol, Protocol::Tcp); - assert_eq!(net_allow[0].host.as_deref(), Some("api.example.com")); + assert!(matches!(&net_allow[0].target, NetTarget::Host(h) if h == "api.example.com")); assert_eq!(net_allow[0].ports, vec![80, 443]); assert_eq!(net_allow[1].protocol, Protocol::Tcp); - assert_eq!(net_allow[1].host.as_deref(), Some("admin.example.com")); + assert!(matches!(&net_allow[1].target, NetTarget::Host(h) if h == "admin.example.com")); assert_eq!(net_allow[1].ports, vec![80, 443]); } @@ -487,7 +487,7 @@ mod tests { extend_net_allow_for_http(&mut net_allow, &[], &[], &[8080]); assert_eq!(net_allow.len(), 1); assert_eq!(net_allow[0].protocol, Protocol::Tcp); - assert_eq!(net_allow[0].host, None); + assert_eq!(net_allow[0].target, NetTarget::AnyIp); assert_eq!(net_allow[0].ports, vec![8080]); let allow = vec![HttpRule::parse("* */public/*").unwrap()]; @@ -495,7 +495,7 @@ mod tests { extend_net_allow_for_http(&mut net_allow, &allow, &[], &[80]); assert_eq!(net_allow.len(), 1); assert_eq!(net_allow[0].protocol, Protocol::Tcp); - assert_eq!(net_allow[0].host, None); + assert_eq!(net_allow[0].target, NetTarget::AnyIp); assert_eq!(net_allow[0].ports, vec![80]); } } diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index 85bf66f..300f610 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -36,21 +36,14 @@ impl IpCidr { let (addr_str, prefix) = match s.split_once('/') { Some((a, p)) => { let len: u8 = p.parse().map_err(|_| { - SandboxError::Invalid(format!( - "--net-deny: invalid prefix length in `{}`", - s - )) + SandboxError::Invalid(format!("invalid prefix length in `{}`", s)) })?; (a, Some(len)) } None => (s, None), }; let addr: IpAddr = addr_str.parse().map_err(|_| { - SandboxError::Invalid(format!( - "--net-deny: `{}` is not an IP or CIDR (hostnames are not \ - allowed; use --http-deny for domains)", - s - )) + SandboxError::Invalid(format!("`{}` is not a valid IP address", s)) })?; let max = match addr { IpAddr::V4(_) => 32u8, @@ -59,7 +52,7 @@ impl IpCidr { let prefix_len = prefix.unwrap_or(max); if prefix_len > max { return Err(SandboxError::Invalid(format!( - "--net-deny: prefix /{} too large for {} in `{}`", + "prefix /{} too large for {} in `{}`", prefix_len, if max == 32 { "IPv4" } else { "IPv6" }, s @@ -68,6 +61,15 @@ impl IpCidr { Ok(IpCidr { addr, prefix_len }) } + /// True iff this CIDR is a single host (`/32` IPv4 or `/128` IPv6), + /// i.e. it came from a bare IP literal rather than a range. + pub fn is_single_host(&self) -> bool { + match self.addr { + IpAddr::V4(_) => self.prefix_len == 32, + IpAddr::V6(_) => self.prefix_len == 128, + } + } + /// True iff `ip` falls within this network. Different address /// families never match. pub fn contains(&self, ip: IpAddr) -> bool { @@ -91,47 +93,94 @@ impl IpCidr { } } -/// What a deny rule targets at the IP layer. +impl std::fmt::Display for IpCidr { + /// A single host renders as the bare address (`1.2.3.4`, `::1`); a + /// range keeps its prefix (`10.0.0.0/8`). Inverse of [`IpCidr::parse`]. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.is_single_host() { + write!(f, "{}", self.addr) + } else { + write!(f, "{}/{}", self.addr, self.prefix_len) + } + } +} + +/// What a `--net-allow` / `--net-deny` rule targets at the IP layer. +/// +/// `Cidr` covers both a bare IP literal (stored as a `/32` or `/128`) and +/// an explicit CIDR range. `Host` is a hostname resolved via DNS at sandbox +/// start; it is only produced for `--net-allow` (deny rejects hostnames). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum DenyTarget { - /// Any destination IP (the `:port` / `*:port` form). +pub enum NetTarget { + /// Any destination IP (the `:port` / `*:port` / `*` form). AnyIp, - /// A specific IP or CIDR range. + /// A literal IP or CIDR range. Matched by containment, no DNS. Cidr(IpCidr), + /// A hostname, resolved to IPs at sandbox start (allow-only). + Host(String), } -/// A single `--net-deny` rule. Unlike `NetAllow`, the target is always -/// a literal IP/CIDR (or any-IP) resolved at parse time, never a -/// hostname, so no DNS is involved and matching is rebinding-safe. +/// A single `--net-allow` / `--net-deny` rule. Both flags share this +/// representation and the same grammar; they differ only in whether +/// hostnames are accepted (`--net-deny` rejects them) and in how the +/// resolved rule is enforced (allowlist vs denylist). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct NetDeny { +pub struct NetRule { + /// L4 protocol this rule applies to. + #[serde(default = "default_protocol_tcp")] pub protocol: Protocol, - pub target: DenyTarget, + /// What the rule targets at the IP layer. + pub target: NetTarget, + /// Permitted/denied ports. Empty when `all_ports` is true and always + /// empty for `Protocol::Icmp`. pub ports: Vec, /// "Any port" (bare target with no `:port`, or the `*` port token). + #[serde(default)] pub all_ports: bool, } -impl NetDeny { - /// Parse a `--net-deny` spec into a single rule. Forms: +/// `--net-allow` and `--net-deny` rules are the same shape; the aliases +/// document intent at call sites and field declarations. +pub type NetAllow = NetRule; +pub type NetDeny = NetRule; + +fn default_protocol_tcp() -> Protocol { + Protocol::Tcp +} + +impl NetRule { + /// Parse a `--net-allow` spec into a rule. Hostnames are accepted and + /// resolved to IPs at sandbox start. Grammar (shared with `--net-deny`): /// - /// - `` / `` / `*` -- all ports (the port is optional; `*` + /// - `host` / `` / `` / `*` -- all ports (port optional; `*` /// targets any IP). TCP is the default scheme. - /// - `:` / `:` / `:*`. - /// - `[]:` -- bracketed IPv6 with a port (IPv6 - /// combined with a port MUST use this bracket form because a bare - /// `addr:port` string is itself a valid IPv6 address). - /// - `:` / `*:` -- any IP, that port. + /// - `host:` / `:` / `:*` / `:port`. + /// - `[]:` -- bracketed IPv6 with a port (a bare + /// `addr:port` string is itself a valid IPv6 address, so the port + /// form needs brackets). /// - `tcp://...` / `udp://...` / `icmp://...` schemes (icmp: no port). - /// - /// Hostnames are rejected (use `--http-deny` for domain blocking). - pub fn parse(spec: &str) -> Result { + pub fn parse_allow(spec: &str) -> Result { + Self::parse_spec(spec, "--net-allow", true) + } + + /// Parse a `--net-deny` spec into a rule. Identical grammar to + /// [`parse_allow`](Self::parse_allow), except hostnames are rejected + /// (the target must be a literal IP/CIDR or `*`); use `--http-deny` + /// for domain blocking. + pub fn parse_deny(spec: &str) -> Result { + Self::parse_spec(spec, "--net-deny", false) + } + + /// Shared grammar for both flags. `label` selects the error prefix and + /// `allow_hosts` whether non-IP targets are accepted (allow) or + /// rejected (deny). + fn parse_spec(spec: &str, label: &str, allow_hosts: bool) -> Result { let (protocol, rest) = match spec.split_once("://") { Some((scheme, body)) => { let proto = Protocol::parse(scheme).ok_or_else(|| { SandboxError::Invalid(format!( - "--net-deny: unknown scheme `{}://` in `{}` (expected tcp, udp, icmp)", - scheme, spec + "{}: unknown scheme `{}://` in `{}` (expected tcp, udp, icmp)", + label, scheme, spec )) })?; (proto, body) @@ -143,13 +192,21 @@ impl NetDeny { if protocol == Protocol::Icmp { if rest.is_empty() { return Err(SandboxError::Invalid(format!( - "--net-deny: icmp rule needs an IP/CIDR or `*`, got `{}`", - spec + "{}: icmp rule needs a host/IP or `*`, got `{}`", + label, spec ))); } - return Ok(NetDeny { + // Reject an explicit port. IPv6 literals/CIDRs also contain + // `:`, so only flag a `:` that isn't part of a valid IP/CIDR. + if rest != "*" && IpCidr::parse(rest).is_err() && rest.contains(':') { + return Err(SandboxError::Invalid(format!( + "{}: icmp rule takes no port, got `{}`", + label, spec + ))); + } + return Ok(NetRule { protocol, - target: parse_deny_target(rest)?, + target: parse_target(rest, label, allow_hosts)?, ports: Vec::new(), all_ports: true, }); @@ -158,53 +215,51 @@ impl NetDeny { // 1. Bracketed IPv6 with a port: `[addr]:ports`. if let Some(stripped) = rest.strip_prefix('[') { let (inside, port_part) = stripped.rsplit_once("]:").ok_or_else(|| { - SandboxError::Invalid(format!( - "--net-deny: malformed bracketed address in `{}`", - spec - )) + SandboxError::Invalid(format!("{}: malformed bracketed address in `{}`", label, spec)) })?; - let (ports, all_ports) = parse_deny_ports(port_part, spec)?; - return Ok(NetDeny { + let (ports, all_ports) = parse_ports(port_part, label, spec)?; + return Ok(NetRule { protocol, - target: DenyTarget::Cidr(IpCidr::parse(inside)?), + target: NetTarget::Cidr(IpCidr::parse(inside)?), ports, all_ports, }); } - // An empty body must not silently mean "deny everything"; require - // an explicit `*` for the any-IP target. + // An empty body must not silently mean "everything"; require an + // explicit `*` for the any-IP target. if rest.is_empty() { return Err(SandboxError::Invalid(format!( - "--net-deny: empty rule in `{}` (use `*` for any IP)", - spec + "{}: empty rule in `{}` (use `*` for any host)", + label, spec ))); } - // 2. Whole body is an IP/CIDR with no port -> all ports. This - // is what makes bare `10.0.0.0/8` and IPv6 `fc00::/7` work. + // 2. Whole body is an IP/CIDR with no port -> all ports. Trying + // `IpCidr::parse` first is what makes bare IPv6 (`::1`) and IPv6 + // CIDRs (`fc00::/7`) work despite containing colons. if let Ok(cidr) = IpCidr::parse(rest) { - return Ok(NetDeny { + return Ok(NetRule { protocol, - target: DenyTarget::Cidr(cidr), + target: NetTarget::Cidr(cidr), ports: Vec::new(), all_ports: true, }); } - // 3. `target[:ports]` where target is an IPv4/CIDR, `*`, or empty. - // The port suffix is optional: a bare `*` (or any target with no - // `:port`) covers all ports, mirroring the bare IP/CIDR form above. + // 3. `target[:ports]` where target is an IP/CIDR, hostname, `*`, or + // empty. The port suffix is optional: a target with no `:port` + // covers all ports, mirroring the bare-target form above. let (host_part, port_part) = match rest.rsplit_once(':') { Some((h, p)) => (h, Some(p)), None => (rest, None), }; - let target = parse_deny_target(host_part)?; + let target = parse_target(host_part, label, allow_hosts)?; let (ports, all_ports) = match port_part { - Some(p) => parse_deny_ports(p, spec)?, + Some(p) => parse_ports(p, label, spec)?, None => (Vec::new(), true), }; - Ok(NetDeny { + Ok(NetRule { protocol, target, ports, @@ -213,17 +268,35 @@ impl NetDeny { } } -/// Parse a deny target: `*` / empty -> any IP, otherwise an IP/CIDR. -/// Hostnames fail here via `IpCidr::parse`. -fn parse_deny_target(s: &str) -> Result { +/// Parse a rule target: `*` / empty -> any IP, an IP/CIDR literal -> +/// `Cidr`, otherwise a hostname (`Host`) when `allow_hosts`, else an error. +fn parse_target(s: &str, label: &str, allow_hosts: bool) -> Result { match s { - "" | "*" => Ok(DenyTarget::AnyIp), - _ => Ok(DenyTarget::Cidr(IpCidr::parse(s)?)), + "" | "*" => Ok(NetTarget::AnyIp), + // A `/` signals CIDR intent: parse strictly so a bad prefix is a + // clear error rather than being misread as a hostname. + _ if s.contains('/') => Ok(NetTarget::Cidr( + IpCidr::parse(s).map_err(|e| SandboxError::Invalid(format!("{}: {}", label, e)))?, + )), + _ => { + if let Ok(cidr) = IpCidr::parse(s) { + Ok(NetTarget::Cidr(cidr)) + } else if allow_hosts { + Ok(NetTarget::Host(s.to_string())) + } else { + Err(SandboxError::Invalid(format!( + "{}: `{}` is not an IP or CIDR (hostnames are not allowed; \ + use --http-deny for domains)", + label, s + ))) + } + } } } -/// Parse the port suffix of a deny spec. `*` means all ports. -fn parse_deny_ports(s: &str, full: &str) -> Result<(Vec, bool), SandboxError> { +/// Parse a port suffix. `*` means all ports; mixing `*` with concrete +/// ports, port 0, and an empty list are all rejected. +fn parse_ports(s: &str, label: &str, full: &str) -> Result<(Vec, bool), SandboxError> { let mut ports = Vec::new(); let mut saw_wildcard = false; for p in s.split(',') { @@ -233,26 +306,26 @@ fn parse_deny_ports(s: &str, full: &str) -> Result<(Vec, bool), SandboxErro continue; } let n: u16 = p.parse().map_err(|_| { - SandboxError::Invalid(format!("--net-deny: invalid port `{}` in `{}`", p, full)) + SandboxError::Invalid(format!("{}: invalid port `{}` in `{}`", label, p, full)) })?; if n == 0 { return Err(SandboxError::Invalid(format!( - "--net-deny: port 0 is not valid in `{}`", - full + "{}: port 0 is not valid in `{}`", + label, full ))); } ports.push(n); } if saw_wildcard && !ports.is_empty() { return Err(SandboxError::Invalid(format!( - "--net-deny: cannot mix `*` with concrete ports in `{}`", - full + "{}: cannot mix `*` with concrete ports in `{}`", + label, full ))); } if !saw_wildcard && ports.is_empty() { return Err(SandboxError::Invalid(format!( - "--net-deny: at least one port required in `{}`", - full + "{}: at least one port required in `{}`", + label, full ))); } Ok((ports, saw_wildcard)) @@ -291,169 +364,6 @@ impl Protocol { } } -/// A network endpoint allow rule. -/// -/// Each rule permits one protocol's traffic to one host (or any IP, for -/// the `:port` form) on a specific set of ports. Multiple rules are -/// OR'd: traffic is permitted if any rule matches the protocol, the -/// destination IP, and the destination port. -/// -/// ICMP rules carry no port (ICMP has none); their `ports` is empty -/// and `all_ports` is false. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub struct NetAllow { - /// L4 protocol this rule applies to. - #[serde(default = "default_protocol_tcp")] - pub protocol: Protocol, - /// Hostname; `None` means "any IP" (the `:port` form, or `icmp://*`). - pub host: Option, - /// Permitted ports. Must be non-empty unless `all_ports` is true, - /// in which case it must be empty. Always empty for `Protocol::Icmp`. - pub ports: Vec, - /// "Any port" wildcard from the `*` token in port position. When - /// true, `ports` is empty; the rule permits every TCP/UDP port to - /// the host (or to any IP, when `host` is `None`). - #[serde(default)] - pub all_ports: bool, -} - -fn default_protocol_tcp() -> Protocol { - Protocol::Tcp -} - -impl NetAllow { - /// Parse a rule spec. Forms: - /// - /// - `host:port[,port,...]`, `:port`, `*:port`, `host:*`, `:*`, `*:*` - /// — TCP (the default scheme). - /// - `host` / `*` — no port suffix means all ports (the port is - /// optional, `*` matches any host); equivalent to `host:*` / `*:*`. - /// - `tcp://...` — explicit TCP, same suffix grammar as the bare form. - /// - `udp://...` — UDP, same suffix grammar as the bare form. - /// - `icmp://host` or `icmp://*` — ICMP echo (kernel ping socket). - /// No port field; `icmp://host:80` is rejected. - /// - /// `*` in port position means "any port" (the all-ports wildcard). - /// Mixing `*` with concrete ports (e.g. `host:80,*`) is rejected. - pub fn parse(s: &str) -> Result { - // Split off the optional scheme prefix `://`. If absent, - // default to TCP and the rest of the parser is unchanged. - let (protocol, rest) = match s.split_once("://") { - Some((scheme, body)) => { - let proto = Protocol::parse(scheme).ok_or_else(|| { - SandboxError::Invalid(format!( - "--net-allow: unknown scheme `{}://` in `{}` (expected tcp, udp, icmp)", - scheme, s - )) - })?; - (proto, body) - } - None => (Protocol::Tcp, s), - }; - - if protocol == Protocol::Icmp { - return Self::parse_icmp(rest, s); - } - - if rest.is_empty() { - return Err(SandboxError::Invalid(format!( - "--net-allow: empty rule in `{}` (use `*` for any host)", - s - ))); - } - - // The port suffix is optional: a host (or `*`) with no `:port` - // covers all ports, mirroring the `host:*` form. - let (host_part, port_part) = match rest.rsplit_once(':') { - Some((h, p)) => (h, Some(p)), - None => (rest, None), - }; - let host = match host_part { - "" | "*" => None, - h => Some(h.to_string()), - }; - let port_part = match port_part { - Some(p) => p, - None => { - return Ok(NetAllow { - protocol, - host, - ports: Vec::new(), - all_ports: true, - }); - } - }; - - // Detect the wildcard token. We split on ',' first so a - // single `*` is a clean match — `*,80` is rejected explicitly - // below rather than letting `*` parse as port 0. - let mut ports = Vec::new(); - let mut saw_wildcard = false; - for p in port_part.split(',') { - let p = p.trim(); - if p == "*" { - saw_wildcard = true; - continue; - } - let n: u16 = p.parse().map_err(|_| { - SandboxError::Invalid(format!("--net-allow: invalid port `{}` in `{}`", p, s)) - })?; - if n == 0 { - return Err(SandboxError::Invalid(format!( - "--net-allow: port 0 is not valid in `{}`", - s - ))); - } - ports.push(n); - } - if saw_wildcard && !ports.is_empty() { - return Err(SandboxError::Invalid(format!( - "--net-allow: cannot mix `*` with concrete ports in `{}`", - s - ))); - } - if !saw_wildcard && ports.is_empty() { - return Err(SandboxError::Invalid(format!( - "--net-allow: at least one port required in `{}`", - s - ))); - } - Ok(NetAllow { - protocol, - host, - ports, - all_ports: saw_wildcard, - }) - } - - /// Parse the body of an `icmp://` rule. Accepts a host or `*` — - /// ICMP has no ports, so any `:` separator is rejected. - fn parse_icmp(body: &str, full: &str) -> Result { - if body.contains(':') { - return Err(SandboxError::Invalid(format!( - "--net-allow: icmp rules take no port, got `{}`", - full - ))); - } - if body.is_empty() { - return Err(SandboxError::Invalid(format!( - "--net-allow: icmp rule needs a host or `*`, got `{}`", - full - ))); - } - let host = match body { - "*" => None, - h => Some(h.to_string()), - }; - Ok(NetAllow { - protocol: Protocol::Icmp, - host, - ports: Vec::new(), - all_ports: false, - }) - } -} - // ============================================================ // parse_ip_from_sockaddr — parse IP from a sockaddr byte buffer // ============================================================ @@ -1278,6 +1188,10 @@ pub struct ResolvedNetAllow { /// `PortAllow::Any` — the entry in `per_ip` is kept as a /// placeholder for diagnostic / `/etc/hosts` purposes. pub per_ip_all_ports: HashSet, + /// IP/CIDR-literal targets, matched by containment with no DNS (an + /// exact IP literal is a `/32` or `/128`). Each carries the ports + /// permitted to that range (`PortAllow::Any` for all-ports rules). + pub cidrs: Vec<(IpCidr, crate::seccomp::notif::PortAllow)>, /// Ports permitted to any IP (the `:port` form). pub any_ip_ports: HashSet, /// Any-host any-port wildcard (`:*` / `*:*`, or `icmp://*`). When @@ -1314,16 +1228,18 @@ pub struct ResolvedNetAllowSet { pub async fn resolve_net_allow( rules: &[NetAllow], ) -> io::Result { + use crate::seccomp::notif::PortAllow; let per_proto = |target: Protocol| async move { let mut per_ip: HashMap> = HashMap::new(); let mut per_ip_all_ports: HashSet = HashSet::new(); + let mut cidrs: Vec<(IpCidr, PortAllow)> = Vec::new(); let mut any_ip_ports: HashSet = HashSet::new(); let mut any_ip_all_ports = false; let mut local_etc_hosts = String::new(); for rule in rules.iter().filter(|r| r.protocol == target) { - match &rule.host { - None => { + match &rule.target { + NetTarget::AnyIp => { if rule.all_ports || target == Protocol::Icmp { // ICMP rules never carry ports, so a wildcard-host // ICMP rule (`icmp://*`) means "any destination." @@ -1334,7 +1250,17 @@ pub async fn resolve_net_allow( } } } - Some(host) => { + NetTarget::Cidr(c) => { + // IP/CIDR literals are matched by containment with no + // DNS, exactly like `--net-deny` targets. + let pa = if rule.all_ports || target == Protocol::Icmp { + PortAllow::Any + } else { + PortAllow::Specific(rule.ports.iter().copied().collect()) + }; + cidrs.push((*c, pa)); + } + NetTarget::Host(host) => { let addr = format!("{}:0", host); let resolved = tokio::net::lookup_host(addr.as_str()).await.map_err(|e| { io::Error::new( @@ -1363,6 +1289,7 @@ pub async fn resolve_net_allow( ResolvedNetAllow { per_ip, per_ip_all_ports, + cidrs, any_ip_ports, any_ip_all_ports, }, @@ -1408,7 +1335,7 @@ pub fn resolve_net_deny(rules: &[NetDeny]) -> ResolvedNetDenySet { for rule in rules.iter().filter(|r| r.protocol == target) { saw_rule = true; match &rule.target { - DenyTarget::AnyIp => { + NetTarget::AnyIp => { if rule.all_ports || target == Protocol::Icmp { deny_all = true; } else { @@ -1417,7 +1344,7 @@ pub fn resolve_net_deny(rules: &[NetDeny]) -> ResolvedNetDenySet { } } } - DenyTarget::Cidr(c) => { + NetTarget::Cidr(c) => { let pa = if rule.all_ports || target == Protocol::Icmp { PortAllow::Any } else { @@ -1425,6 +1352,9 @@ pub fn resolve_net_deny(rules: &[NetDeny]) -> ResolvedNetDenySet { }; cidrs.push((*c, pa)); } + // `--net-deny` rejects hostnames at parse time, so a deny + // rule never carries a `Host` target. + NetTarget::Host(_) => unreachable!("net-deny rejects hostnames"), } } @@ -1517,53 +1447,53 @@ mod tests { #[test] fn netallow_parse_concrete_host_port() { - let r = NetAllow::parse("example.com:443").unwrap(); - assert_eq!(r.host.as_deref(), Some("example.com")); + let r = NetRule::parse_allow("example.com:443").unwrap(); + assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com")); assert_eq!(r.ports, vec![443]); assert!(!r.all_ports); } #[test] fn netallow_parse_any_host_port() { - let r = NetAllow::parse(":8080").unwrap(); - assert_eq!(r.host, None); + let r = NetRule::parse_allow(":8080").unwrap(); + assert_eq!(r.target, NetTarget::AnyIp); assert_eq!(r.ports, vec![8080]); assert!(!r.all_ports); - let r = NetAllow::parse("*:8080").unwrap(); - assert_eq!(r.host, None); + let r = NetRule::parse_allow("*:8080").unwrap(); + assert_eq!(r.target, NetTarget::AnyIp); assert_eq!(r.ports, vec![8080]); assert!(!r.all_ports); } #[test] fn netallow_parse_multiple_ports() { - let r = NetAllow::parse("github.com:22,80,443").unwrap(); - assert_eq!(r.host.as_deref(), Some("github.com")); + let r = NetRule::parse_allow("github.com:22,80,443").unwrap(); + assert!(matches!(&r.target, NetTarget::Host(h) if h == "github.com")); assert_eq!(r.ports, vec![22, 80, 443]); assert!(!r.all_ports); } #[test] fn netallow_parse_wildcard_any_host_any_port_colon() { - let r = NetAllow::parse(":*").unwrap(); - assert_eq!(r.host, None); + let r = NetRule::parse_allow(":*").unwrap(); + assert_eq!(r.target, NetTarget::AnyIp); assert!(r.ports.is_empty()); assert!(r.all_ports); } #[test] fn netallow_parse_wildcard_any_host_any_port_star() { - let r = NetAllow::parse("*:*").unwrap(); - assert_eq!(r.host, None); + let r = NetRule::parse_allow("*:*").unwrap(); + assert_eq!(r.target, NetTarget::AnyIp); assert!(r.ports.is_empty()); assert!(r.all_ports); } #[test] fn netallow_parse_wildcard_concrete_host_any_port() { - let r = NetAllow::parse("example.com:*").unwrap(); - assert_eq!(r.host.as_deref(), Some("example.com")); + let r = NetRule::parse_allow("example.com:*").unwrap(); + assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com")); assert!(r.ports.is_empty()); assert!(r.all_ports); } @@ -1573,21 +1503,21 @@ mod tests { // `host:80,*` and `host:*,80` are both ambiguous: the user // either meant "any port" (wildcard wins) or "ports 80 plus // some weird placeholder". Refuse and force a clean spec. - let err = NetAllow::parse("example.com:80,*").unwrap_err(); + let err = NetRule::parse_allow("example.com:80,*").unwrap_err(); assert!(format!("{}", err).contains("cannot mix")); - let err = NetAllow::parse("example.com:*,80").unwrap_err(); + let err = NetRule::parse_allow("example.com:*,80").unwrap_err(); assert!(format!("{}", err).contains("cannot mix")); } #[test] fn netallow_parse_rejects_port_zero() { - let err = NetAllow::parse("example.com:0").unwrap_err(); + let err = NetRule::parse_allow("example.com:0").unwrap_err(); assert!(format!("{}", err).contains("port 0")); } #[test] fn netallow_parse_rejects_empty_port() { - let err = NetAllow::parse("example.com:").unwrap_err(); + let err = NetRule::parse_allow("example.com:").unwrap_err(); assert!(format!("{}", err).contains("invalid port")); } @@ -1595,31 +1525,72 @@ mod tests { fn netallow_bare_host_is_all_ports() { // No port suffix means "all ports" (port optional), symmetric // with the `host:*` form. - let r = NetAllow::parse("example.com").unwrap(); - assert_eq!(r.host.as_deref(), Some("example.com")); + let r = NetRule::parse_allow("example.com").unwrap(); + assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com")); assert!(r.all_ports); assert!(r.ports.is_empty()); } #[test] fn netallow_bare_star_is_any_host_all_ports() { - let r = NetAllow::parse("*").unwrap(); - assert_eq!(r.host, None); + let r = NetRule::parse_allow("*").unwrap(); + assert_eq!(r.target, NetTarget::AnyIp); assert!(r.all_ports); assert!(r.ports.is_empty()); } #[test] fn netallow_empty_spec_rejected() { - assert!(NetAllow::parse("").is_err()); - assert!(NetAllow::parse("tcp://").is_err()); + assert!(NetRule::parse_allow("").is_err()); + assert!(NetRule::parse_allow("tcp://").is_err()); + } + + #[test] + fn netallow_cidr_target_with_port() { + // CIDR ranges are now first-class in --net-allow (matched by + // containment, no DNS), symmetric with --net-deny. + let r = NetRule::parse_allow("10.0.0.0/8:80").unwrap(); + assert!(matches!(&r.target, NetTarget::Cidr(c) if !c.is_single_host())); + assert_eq!(r.ports, vec![80]); + assert!(!r.all_ports); + } + + #[test] + fn netallow_ipv6_literal_and_bracket() { + let lo: std::net::IpAddr = "::1".parse().unwrap(); + // Bare IPv6 literal (previously mis-split on its colons). + let r = NetRule::parse_allow("::1").unwrap(); + assert!(matches!(&r.target, NetTarget::Cidr(c) if c.addr == lo && c.is_single_host())); + assert!(r.all_ports); + // Bracketed IPv6 with a port. + let r = NetRule::parse_allow("[::1]:443").unwrap(); + assert!(matches!(&r.target, NetTarget::Cidr(c) if c.addr == lo && c.is_single_host())); + assert_eq!(r.ports, vec![443]); + // IPv6 CIDR. + let r = NetRule::parse_allow("fc00::/7").unwrap(); + assert!(matches!(&r.target, NetTarget::Cidr(c) if !c.is_single_host())); + assert!(r.all_ports); + } + + #[tokio::test] + async fn test_resolve_net_allow_cidr_no_dns() { + // A CIDR / IP-literal target resolves into `cidrs` directly, with + // no DNS lookup and no `per_ip` / `/etc/hosts` entry. + let rules = vec![ + NetAllow { protocol: Protocol::Tcp, target: NetTarget::Cidr(IpCidr::parse("10.0.0.0/8").unwrap()), ports: vec![80], all_ports: false }, + NetAllow { protocol: Protocol::Tcp, target: NetTarget::Cidr(IpCidr::parse("1.2.3.4").unwrap()), ports: vec![], all_ports: true }, + ]; + let resolved = resolve_net_allow(&rules).await.unwrap(); + assert_eq!(resolved.tcp.cidrs.len(), 2); + assert!(resolved.tcp.per_ip.is_empty()); + assert!(resolved.concrete_host_entries.is_empty()); } #[test] fn netallow_parse_repeated_wildcard_is_idempotent() { // `*,*` collapses to a single wildcard — neither token contributes // a concrete port, so the rule remains "any port". - let r = NetAllow::parse(":*,*").unwrap(); + let r = NetRule::parse_allow(":*,*").unwrap(); assert!(r.all_ports); assert!(r.ports.is_empty()); } @@ -1628,51 +1599,54 @@ mod tests { #[test] fn netallow_bare_form_defaults_to_tcp() { - let r = NetAllow::parse("example.com:443").unwrap(); + let r = NetRule::parse_allow("example.com:443").unwrap(); assert_eq!(r.protocol, Protocol::Tcp); } #[test] fn netallow_explicit_tcp_scheme() { - let r = NetAllow::parse("tcp://example.com:443").unwrap(); + let r = NetRule::parse_allow("tcp://example.com:443").unwrap(); assert_eq!(r.protocol, Protocol::Tcp); - assert_eq!(r.host.as_deref(), Some("example.com")); + assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com")); assert_eq!(r.ports, vec![443]); } #[test] fn netallow_udp_scheme_with_host_port() { - let r = NetAllow::parse("udp://1.1.1.1:53").unwrap(); + let r = NetRule::parse_allow("udp://1.1.1.1:53").unwrap(); assert_eq!(r.protocol, Protocol::Udp); - assert_eq!(r.host.as_deref(), Some("1.1.1.1")); + // An IP literal becomes a single-host CIDR target (no DNS). + let one: std::net::IpAddr = "1.1.1.1".parse().unwrap(); + assert!(matches!(&r.target, NetTarget::Cidr(c) if c.addr == one && c.is_single_host())); assert_eq!(r.ports, vec![53]); } #[test] fn netallow_udp_wildcard_any_anywhere() { // The "any UDP" gate, equivalent to the old `allow_udp = true`. - let r = NetAllow::parse("udp://*:*").unwrap(); + let r = NetRule::parse_allow("udp://*:*").unwrap(); assert_eq!(r.protocol, Protocol::Udp); - assert_eq!(r.host, None); + assert_eq!(r.target, NetTarget::AnyIp); assert!(r.all_ports); } #[test] fn netallow_icmp_scheme_with_host() { - let r = NetAllow::parse("icmp://github.com").unwrap(); + let r = NetRule::parse_allow("icmp://github.com").unwrap(); assert_eq!(r.protocol, Protocol::Icmp); - assert_eq!(r.host.as_deref(), Some("github.com")); + assert!(matches!(&r.target, NetTarget::Host(h) if h == "github.com")); assert!(r.ports.is_empty()); - assert!(!r.all_ports); + // ICMP carries no ports, so the rule is "all ports" by convention. + assert!(r.all_ports); } #[test] fn netallow_icmp_wildcard() { // The "any ICMP echo" gate, equivalent to the old // `allow_icmp = true` for the SOCK_DGRAM path. - let r = NetAllow::parse("icmp://*").unwrap(); + let r = NetRule::parse_allow("icmp://*").unwrap(); assert_eq!(r.protocol, Protocol::Icmp); - assert_eq!(r.host, None); + assert_eq!(r.target, NetTarget::AnyIp); } #[test] @@ -1680,14 +1654,14 @@ mod tests { // ICMP has no port — `:port` is meaningless and refused // explicitly so users can't write a rule that doesn't do what // they think. - let err = NetAllow::parse("icmp://github.com:80").unwrap_err(); - assert!(format!("{}", err).contains("icmp rules take no port")); + let err = NetRule::parse_allow("icmp://github.com:80").unwrap_err(); + assert!(format!("{}", err).contains("icmp rule takes no port")); } #[test] fn netallow_icmp_rejects_empty_body() { - let err = NetAllow::parse("icmp://").unwrap_err(); - assert!(format!("{}", err).contains("needs a host or `*`")); + let err = NetRule::parse_allow("icmp://").unwrap_err(); + assert!(format!("{}", err).contains("needs a host/IP or `*`")); } #[test] @@ -1695,7 +1669,7 @@ mod tests { // Including `icmp-raw` — sandlock does not expose raw ICMP, so // the scheme is unknown rather than a special-case error. for spec in ["sctp://host:1234", "icmp-raw://*"] { - let err = NetAllow::parse(spec).unwrap_err(); + let err = NetRule::parse_allow(spec).unwrap_err(); assert!(format!("{}", err).contains("unknown scheme"), "spec: {}", spec); } } @@ -1715,7 +1689,7 @@ mod tests { async fn test_resolve_net_allow_concrete_host() { let rules = vec![NetAllow { protocol: Protocol::Tcp, - host: Some("localhost".to_string()), + target: NetTarget::Host("localhost".to_string()), ports: vec![80, 443], all_ports: false, }]; @@ -1737,7 +1711,7 @@ mod tests { async fn test_resolve_net_allow_any_ip() { let rules = vec![NetAllow { protocol: Protocol::Tcp, - host: None, + target: NetTarget::AnyIp, ports: vec![8080], all_ports: false, }]; @@ -1754,7 +1728,7 @@ mod tests { // `:*` — fully unrestricted egress, TCP-only. let rules = vec![NetAllow { protocol: Protocol::Tcp, - host: None, + target: NetTarget::AnyIp, ports: vec![], all_ports: true, }]; @@ -1773,7 +1747,7 @@ mod tests { // `localhost:*` — every port to localhost only, TCP. let rules = vec![NetAllow { protocol: Protocol::Tcp, - host: Some("localhost".to_string()), + target: NetTarget::Host("localhost".to_string()), ports: vec![], all_ports: true, }]; @@ -1798,13 +1772,13 @@ mod tests { let rules = vec![ NetAllow { protocol: Protocol::Tcp, - host: None, + target: NetTarget::AnyIp, ports: vec![], all_ports: true, }, NetAllow { protocol: Protocol::Tcp, - host: Some("localhost".to_string()), + target: NetTarget::Host("localhost".to_string()), ports: vec![22], all_ports: false, }, @@ -1825,13 +1799,13 @@ mod tests { let rules = vec![ NetAllow { protocol: Protocol::Tcp, - host: Some("localhost".to_string()), + target: NetTarget::Host("localhost".to_string()), ports: vec![443], all_ports: false, }, NetAllow { protocol: Protocol::Udp, - host: None, + target: NetTarget::AnyIp, ports: vec![53], all_ports: false, }, @@ -1859,7 +1833,7 @@ mod tests { // PortAllow::Any-style empty port set, plus per_ip_all_ports. let rules = vec![NetAllow { protocol: Protocol::Icmp, - host: Some("localhost".to_string()), + target: NetTarget::Host("localhost".to_string()), ports: vec![], all_ports: false, }]; @@ -1883,7 +1857,7 @@ mod tests { // `icmp://*` — any ICMP destination. let rules = vec![NetAllow { protocol: Protocol::Icmp, - host: None, + target: NetTarget::AnyIp, ports: vec![], all_ports: false, }]; @@ -2042,17 +2016,17 @@ mod tests { #[test] fn netdeny_bare_cidr_is_all_ports_tcp() { - let rule = NetDeny::parse("10.0.0.0/8").unwrap(); + let rule = NetRule::parse_deny("10.0.0.0/8").unwrap(); assert_eq!(rule.protocol, Protocol::Tcp); - assert!(matches!(rule.target, DenyTarget::Cidr(_))); + assert!(matches!(rule.target, NetTarget::Cidr(_))); assert!(rule.all_ports); } #[test] fn netdeny_bare_ip_is_host_route_all_ports() { - let rule = NetDeny::parse("169.254.169.254").unwrap(); + let rule = NetRule::parse_deny("169.254.169.254").unwrap(); match &rule.target { - DenyTarget::Cidr(c) => assert_eq!(c.prefix_len, 32), + NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 32), _ => panic!("expected cidr"), } assert!(rule.all_ports); @@ -2060,95 +2034,95 @@ mod tests { #[test] fn netdeny_cidr_with_port() { - let rule = NetDeny::parse("10.0.0.0/8:443").unwrap(); + let rule = NetRule::parse_deny("10.0.0.0/8:443").unwrap(); assert_eq!(rule.ports, vec![443]); assert!(!rule.all_ports); } #[test] fn netdeny_any_ip_port() { - let rule = NetDeny::parse(":25").unwrap(); - assert!(matches!(rule.target, DenyTarget::AnyIp)); + let rule = NetRule::parse_deny(":25").unwrap(); + assert!(matches!(rule.target, NetTarget::AnyIp)); assert_eq!(rule.ports, vec![25]); } #[test] fn netdeny_udp_scheme() { - let rule = NetDeny::parse("udp://192.168.0.0/16:53").unwrap(); + let rule = NetRule::parse_deny("udp://192.168.0.0/16:53").unwrap(); assert_eq!(rule.protocol, Protocol::Udp); assert_eq!(rule.ports, vec![53]); } #[test] fn netdeny_ipv6_bracket_port() { - let rule = NetDeny::parse("[::1]:443").unwrap(); + let rule = NetRule::parse_deny("[::1]:443").unwrap(); assert_eq!(rule.ports, vec![443]); match &rule.target { - DenyTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), + NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), _ => panic!("expected cidr"), } } #[test] fn netdeny_rejects_hostname() { - assert!(NetDeny::parse("evil.com:443").is_err()); - assert!(NetDeny::parse("evil.com").is_err()); + assert!(NetRule::parse_deny("evil.com:443").is_err()); + assert!(NetRule::parse_deny("evil.com").is_err()); } #[test] fn netdeny_bare_ipv6_address_all_ports() { - let rule = NetDeny::parse("::1").unwrap(); + let rule = NetRule::parse_deny("::1").unwrap(); assert!(rule.all_ports); match &rule.target { - DenyTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), + NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), _ => panic!("expected cidr"), } } #[test] fn netdeny_bare_ipv6_cidr_all_ports() { - let rule = NetDeny::parse("fc00::/7").unwrap(); + let rule = NetRule::parse_deny("fc00::/7").unwrap(); assert!(rule.all_ports); let ula: std::net::IpAddr = "fd00::1".parse().unwrap(); - assert!(matches!(&rule.target, DenyTarget::Cidr(c) if c.contains(ula))); + assert!(matches!(&rule.target, NetTarget::Cidr(c) if c.contains(ula))); } #[test] fn netdeny_empty_icmp_body_is_rejected() { - assert!(NetDeny::parse("icmp://").is_err()); + assert!(NetRule::parse_deny("icmp://").is_err()); } #[test] fn netdeny_bare_star_is_any_ip_all_ports() { // `*` with no port is the any-IP, all-ports form (port optional, // symmetric with a bare IP/CIDR). - let rule = NetDeny::parse("*").unwrap(); + let rule = NetRule::parse_deny("*").unwrap(); assert_eq!(rule.protocol, Protocol::Tcp); - assert!(matches!(rule.target, DenyTarget::AnyIp)); + assert!(matches!(rule.target, NetTarget::AnyIp)); assert!(rule.all_ports); assert!(rule.ports.is_empty()); } #[test] fn netdeny_udp_bare_star_all_ports() { - let rule = NetDeny::parse("udp://*").unwrap(); + let rule = NetRule::parse_deny("udp://*").unwrap(); assert_eq!(rule.protocol, Protocol::Udp); - assert!(matches!(rule.target, DenyTarget::AnyIp)); + assert!(matches!(rule.target, NetTarget::AnyIp)); assert!(rule.all_ports); } #[test] fn netdeny_empty_spec_rejected() { // An empty body must not silently mean "deny everything". - assert!(NetDeny::parse("").is_err()); - assert!(NetDeny::parse("udp://").is_err()); + assert!(NetRule::parse_deny("").is_err()); + assert!(NetRule::parse_deny("udp://").is_err()); } // --- resolve_net_deny tests --- #[test] fn resolve_net_deny_groups_per_protocol() { - let rule = NetDeny::parse("10.0.0.0/8").unwrap(); + let rule = NetRule::parse_deny("10.0.0.0/8").unwrap(); let set = resolve_net_deny(std::slice::from_ref(&rule)); // TCP policy denies 10.x, UDP/ICMP unaffected (still allow-all). assert!(!set.tcp.allows("10.0.0.1".parse().unwrap(), 443)); @@ -2157,7 +2131,7 @@ mod tests { #[test] fn resolve_net_deny_any_ip_port() { - let rule = NetDeny::parse(":25").unwrap(); + let rule = NetRule::parse_deny(":25").unwrap(); let set = resolve_net_deny(std::slice::from_ref(&rule)); assert!(!set.tcp.allows("8.8.8.8".parse().unwrap(), 25)); assert!(set.tcp.allows("8.8.8.8".parse().unwrap(), 80)); diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 8b6c33a..73805c7 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -10,7 +10,7 @@ use tokio::task::JoinHandle; use crate::context; use crate::error::SandboxError; pub use crate::http::{http_acl_check, normalize_path, prefix_or_exact_match, HttpRule}; -pub use crate::network::{DenyTarget, IpCidr, NetAllow, NetDeny, Protocol}; +pub use crate::network::{IpCidr, NetAllow, NetDeny, NetRule, NetTarget, Protocol}; use crate::protection::{Protection, ProtectionPolicy, ProtectionState, ProtectionStatus}; /// A byte size value. @@ -1456,6 +1456,7 @@ impl Sandbox { .collect(); crate::seccomp::notif::NetworkPolicy::AllowList { per_ip, + cidrs: resolved.cidrs.clone(), any_ip_ports: resolved.any_ip_ports.clone(), } } @@ -1494,8 +1495,15 @@ impl Sandbox { let mut allowed_ips: std::collections::HashSet = std::collections::HashSet::new(); for p in [&net_state.tcp_policy, &net_state.udp_policy, &net_state.icmp_policy] { - if let crate::seccomp::notif::NetworkPolicy::AllowList { per_ip, .. } = p { + if let crate::seccomp::notif::NetworkPolicy::AllowList { per_ip, cidrs, .. } = p { allowed_ips.extend(per_ip.keys().copied()); + // IP literals resolve to single-host CIDRs (/32 or + // /128); surface them as concrete allowed IPs too. + for (net, _) in cidrs { + if net.is_single_host() { + allowed_ips.insert(net.addr); + } + } } } let live = crate::policy_fn::LivePolicy { @@ -2396,14 +2404,14 @@ impl SandboxBuilder { let mut net_allow: Vec = self .net_allow .into_iter() - .map(|s| NetAllow::parse(&s)) + .map(|s| NetRule::parse_allow(&s)) .collect::>()?; // Parse --net-deny rules (one rule per spec). let net_deny: Vec = self .net_deny .into_iter() - .map(|s| NetDeny::parse(&s)) + .map(|s| NetRule::parse_deny(&s)) .collect::>()?; // --net-allow and --net-deny are mutually exclusive. Check the diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index 248c782..fb47cfc 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -244,6 +244,10 @@ pub enum NetworkPolicy { /// Per-IP port rules. From `--net-allow host:ports` after /// hostname resolution, or from `policy_fn` overrides. per_ip: HashMap, + /// (network, allowed-ports) rules from `--net-allow` IP/CIDR + /// targets, matched by containment with no DNS. `PortAllow::Any` + /// permits every port to the range. + cidrs: Vec<(crate::network::IpCidr, PortAllow)>, /// Ports permitted for any IP (from `--net-allow :port` / /// `*:port`). any_ip_ports: HashSet, @@ -267,15 +271,28 @@ impl NetworkPolicy { pub fn allows(&self, ip: IpAddr, port: u16) -> bool { match self { NetworkPolicy::Unrestricted => true, - NetworkPolicy::AllowList { per_ip, any_ip_ports } => { + NetworkPolicy::AllowList { per_ip, cidrs, any_ip_ports } => { if any_ip_ports.contains(&port) { return true; } match per_ip.get(&ip) { - Some(PortAllow::Any) => true, - Some(PortAllow::Specific(s)) => s.contains(&port), - None => false, + Some(PortAllow::Any) => return true, + Some(PortAllow::Specific(s)) if s.contains(&port) => return true, + _ => {} } + for (net, allowed) in cidrs { + if net.contains(ip) { + match allowed { + PortAllow::Any => return true, + PortAllow::Specific(s) => { + if s.contains(&port) { + return true; + } + } + } + } + } + false } NetworkPolicy::DenyList { cidrs, any_ip_ports, deny_all } => { if *deny_all { @@ -1914,4 +1931,31 @@ mod tests { assert!(!policy.allows("1.2.3.4".parse().unwrap(), 443)); // denied assert!(policy.allows("1.2.3.4".parse().unwrap(), 80)); // allowed } + + #[test] + fn allowlist_permits_matching_cidr_only() { + use crate::network::IpCidr; + let mut ports = HashSet::new(); + ports.insert(80u16); + let policy = NetworkPolicy::AllowList { + per_ip: HashMap::new(), + cidrs: vec![(IpCidr::parse("10.0.0.0/8").unwrap(), PortAllow::Specific(ports))], + any_ip_ports: HashSet::new(), + }; + assert!(policy.allows("10.1.2.3".parse().unwrap(), 80)); // in range, port ok + assert!(!policy.allows("10.1.2.3".parse().unwrap(), 443)); // in range, wrong port + assert!(!policy.allows("8.8.8.8".parse().unwrap(), 80)); // out of range + } + + #[test] + fn allowlist_cidr_all_ports() { + use crate::network::IpCidr; + let policy = NetworkPolicy::AllowList { + per_ip: HashMap::new(), + cidrs: vec![(IpCidr::parse("192.168.0.0/16").unwrap(), PortAllow::Any)], + any_ip_ports: HashSet::new(), + }; + assert!(policy.allows("192.168.5.5".parse().unwrap(), 9999)); // any port in range + assert!(!policy.allows("10.0.0.1".parse().unwrap(), 9999)); // out of range + } } diff --git a/crates/sandlock-core/src/seccomp/state.rs b/crates/sandlock-core/src/seccomp/state.rs index e8bb110..b0ab288 100644 --- a/crates/sandlock-core/src/seccomp/state.rs +++ b/crates/sandlock-core/src/seccomp/state.rs @@ -354,6 +354,7 @@ impl NetworkState { let per_ip = ips.iter().map(|&ip| (ip, PortAllow::Any)).collect(); NetworkPolicy::AllowList { per_ip, + cidrs: Vec::new(), any_ip_ports: HashSet::new(), } }; diff --git a/crates/sandlock-core/tests/integration/test_policy.rs b/crates/sandlock-core/tests/integration/test_policy.rs index 09dbbbd..0f57989 100644 --- a/crates/sandlock-core/tests/integration/test_policy.rs +++ b/crates/sandlock-core/tests/integration/test_policy.rs @@ -36,24 +36,24 @@ fn test_builder_network() { assert_eq!(policy.net_bind, vec![8080]); assert_eq!(policy.net_allow.len(), 1); let rule = &policy.net_allow[0]; - assert_eq!(rule.host.as_deref(), Some("api.example.com")); + assert!(matches!(&rule.target, sandlock_core::sandbox::NetTarget::Host(h) if h == "api.example.com")); assert_eq!(rule.ports, vec![443, 80]); } #[test] fn test_net_allow_parse_grammar() { - use sandlock_core::sandbox::NetAllow; - assert!(NetAllow::parse("foo.com:443").is_ok()); - assert!(NetAllow::parse("foo.com:22,443").is_ok()); - assert!(NetAllow::parse(":8080").is_ok()); - assert!(NetAllow::parse("*:8080").is_ok()); - assert!(NetAllow::parse("foo.com").is_ok()); // no port -> all ports - assert!(NetAllow::parse("foo.com").unwrap().all_ports); - assert!(NetAllow::parse("*").is_ok()); // any host, all ports - assert!(NetAllow::parse("").is_err()); // empty rule - assert!(NetAllow::parse("foo.com:abc").is_err()); // bad port - assert!(NetAllow::parse("foo.com:0").is_err()); // port 0 reserved - assert!(NetAllow::parse("foo.com:").is_err()); // empty port list + use sandlock_core::sandbox::NetRule; + assert!(NetRule::parse_allow("foo.com:443").is_ok()); + assert!(NetRule::parse_allow("foo.com:22,443").is_ok()); + assert!(NetRule::parse_allow(":8080").is_ok()); + assert!(NetRule::parse_allow("*:8080").is_ok()); + assert!(NetRule::parse_allow("foo.com").is_ok()); // no port -> all ports + assert!(NetRule::parse_allow("foo.com").unwrap().all_ports); + assert!(NetRule::parse_allow("*").is_ok()); // any host, all ports + assert!(NetRule::parse_allow("").is_err()); // empty rule + assert!(NetRule::parse_allow("foo.com:abc").is_err()); // bad port + assert!(NetRule::parse_allow("foo.com:0").is_err()); // port 0 reserved + assert!(NetRule::parse_allow("foo.com:").is_err()); // empty port list } #[test] From c3067ea78a413e71525d7ea340c8ec9bb06d4a0b Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 6 Jun 2026 22:03:45 -0700 Subject: [PATCH 17/24] docs: document --net-allow/--net-deny grammar parity (CIDR/IPv6 in allow) Signed-off-by: Cong Wang --- README.md | 75 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index a1f107d..cb539c5 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,11 @@ sandlock run --net-allow github.com:22,443 --net-allow :8080 \ # Wildcard port (optional): a bare `host` (or `host:*`) permits every port sandlock run --net-allow github.com -r /usr -r /lib -r /etc -- ssh user@github.com +# IP, CIDR range, or IPv6 literal as the target (matched by containment, +# no DNS); same grammar as --net-deny +sandlock run --net-allow 10.0.0.0/8:443 --net-allow '[2606:4700::/32]:443' \ + -r /usr -r /lib -r /etc -- python3 agent.py + # Unrestricted outbound: `*` opens any host and any TCP port (`:*` / `*:*` # are equivalent). For full egress add a UDP wildcard, `udp://*`. sandlock run --net-allow '*' --net-allow 'udp://*' \ @@ -580,18 +585,25 @@ Landlock + seccomp confinement. `CLONE_ID=0..N-1` is set automatically. ### Network Model -Outbound traffic is gated by a single endpoint allowlist that names -**protocol × destination**. Each `--net-allow` rule is one of: +Outbound traffic is gated by an endpoint list naming +**protocol × destination**. `--net-allow` (allowlist) and `--net-deny` +(denylist) share one grammar and are mutually exclusive: ``` ---net-allow repeatable; no rules = deny all outbound - bare form host[:port[,port,...]] / :port / *:port / host:* / :* / *:* (TCP) - the port is optional: `host` or `*` alone means all ports - tcp:// same suffix grammar — explicit TCP - udp:// same suffix grammar — UDP (`udp://*` opens any UDP) - icmp:// host or `*`, no port — kernel ping socket (SOCK_DGRAM) + repeatable; the port is optional (a bare target = all ports) + target host | | | * (`*` or empty target = any IP) + forms target[:port[,port,...]] · :port · host:* · :* · *:* + []:port (bracket IPv6 when a port follows) + scheme tcp:// (default) · udp:// (`udp://*` = any UDP) · icmp:// (no port) + + --net-allow target may also be a hostname, resolved via DNS at start + --net-deny target must be a literal IP/CIDR (no hostnames; use --http-deny) ``` +A comma groups ports within one spec (`host:80,443`); to pass multiple +rules, repeat the flag. IP and CIDR targets are matched by containment +with no DNS (an IP literal is a `/32` or `/128`); only hostnames resolve. + Multiple rules are OR'd. A destination is permitted iff some rule matches the **same protocol** as the socket plus the destination IP and port (port is N/A for ICMP). @@ -614,12 +626,11 @@ denied at the seccomp layer, and there is no on-behalf path active. For unrestricted TCP egress, opt in explicitly with `--net-allow '*'`; for any UDP, add `--net-allow 'udp://*'`. -**Denylist (`--net-deny`).** The inverse model: networking is -default-allow and the listed targets are blocked. Mutually exclusive -with `--net-allow`. Targets are literal IPs or CIDRs (hostnames are -rejected; use `--http-deny` for domains); the port is optional and -`*` matches any IP. The same scheme grammar applies (`tcp://` default, -`udp://`, `icmp://`). +**Denylist (`--net-deny`).** The inverse of the allowlist: networking is +default-allow and the listed targets are blocked. It uses the same +grammar as `--net-allow` above, the only difference being that targets +must be literal IPs/CIDRs (hostnames are rejected; use `--http-deny` for +domains). Examples: ``` --net-deny 10.0.0.0/8 # all ports on a CIDR (all protocols) @@ -629,18 +640,19 @@ rejected; use `--http-deny` for domains); the port is optional and --net-deny 'udp://192.168.0.0/16' # any UDP to a CIDR ``` -Repeat the flag for multiple rules. - -**Resolution.** Concrete hostnames are resolved once at sandbox start -and pinned in a synthetic `/etc/hosts` (across all protocols). The -synthetic file replaces the real one only when at least one rule has -a concrete host; pure `:port` / `udp://*` / `icmp://*` rules leave -the real `/etc/hosts` and DNS visible. +**Resolution.** Only hostname targets touch DNS: they are resolved once +at sandbox start and pinned in a synthetic `/etc/hosts` (across all +protocols). IP and CIDR targets are matched by containment directly, so +they never resolve and never appear in `/etc/hosts`. The synthetic file +replaces the real one only when at least one rule has a concrete +hostname; rules made purely of IPs/CIDRs, `:port`, `udp://*`, or +`icmp://*` leave the real `/etc/hosts` and DNS visible. **Wildcards.** Hostnames are matched literally — `--net-allow -*.example.com:443` is **not** supported, list each domain you need. -The `*` token is allowed as the host (alias for empty: `*:port` ≡ -`:port`) and as the port for TCP/UDP rules (`host:*`, `:*`, `*:*`). +*.example.com:443` is **not** supported, list each domain you need (or +use a CIDR/IP target for an address range). The `*` token is allowed as +the target (alias for empty: `*:port` ≡ `:port`) and as the port for +TCP/UDP rules (`host:*`, `:*`, `*:*`). The port is optional: omitting it means all ports, so `host` ≡ `host:*` and `*` ≡ `:*` ≡ `*:*` (and `udp://*` ≡ `udp://*:*`). Mixing `*` with concrete ports (`host:80,*`) is rejected. When any TCP rule @@ -652,12 +664,15 @@ allow-all. **Implementation.** Two enforcement paths: - * **Direct path** — pure `:port` TCP policies (no concrete host) - and no HTTP ACL. Landlock enforces the TCP port allowlist at the - kernel level; no per-syscall overhead. UDP and ICMP are not - covered by Landlock and always use the on-behalf path when allowed. - * **On-behalf path** — any concrete host, any HTTP ACL rule, or any - UDP / ICMP rule. Seccomp traps `connect()`, `sendto()`, `sendmsg()`, + * **Direct path** — pure `:port` TCP policies (any IP, no concrete + host/IP/CIDR) and no HTTP ACL. Landlock enforces the TCP port + allowlist at the kernel level; no per-syscall overhead. UDP and ICMP + are not covered by Landlock and always use the on-behalf path when + allowed. + * **On-behalf path** — any host, IP, or CIDR target, any HTTP ACL + rule, or any UDP / ICMP rule (the destination IP must be checked, + which Landlock cannot do). Seccomp traps `connect()`, `sendto()`, + `sendmsg()`, and `sendmmsg()`; the supervisor dups the child fd, queries `getsockopt(SOL_SOCKET, SO_PROTOCOL)` to learn whether the socket is TCP / UDP / ICMP, then checks the destination against that From 33bff86691ba55bb5eb9de6a23bed7f4919f96eb Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sun, 7 Jun 2026 15:11:41 -0700 Subject: [PATCH 18/24] net: rename --net-bind to --net-allow-bind (flag, field, profile key, Python, docs) Signed-off-by: Cong Wang --- README.md | 12 ++++++------ crates/sandlock-cli/src/main.rs | 8 ++++---- crates/sandlock-core/src/landlock.rs | 2 +- crates/sandlock-core/src/profile.rs | 6 +++--- crates/sandlock-core/src/sandbox.rs | 18 +++++++++--------- .../tests/integration/test_netlink_virt.rs | 2 +- .../tests/integration/test_network.rs | 2 +- .../tests/integration/test_policy.rs | 4 ++-- .../tests/integration/test_port_remap.rs | 10 +++++----- .../tests/integration/test_procfs.rs | 2 +- crates/sandlock-ffi/src/lib.rs | 4 ++-- docs/sandbox-reference.md | 6 +++--- python/README.md | 2 +- python/src/sandlock/_profile.py | 6 +++--- python/src/sandlock/_sdk.py | 8 ++++---- python/src/sandlock/mcp/_policy.py | 2 +- python/src/sandlock/sandbox.py | 4 ++-- python/tests/test_mcp.py | 2 +- python/tests/test_profile.py | 4 ++-- python/tests/test_sandbox.py | 4 ++-- python/tests/test_sandbox_config.py | 4 ++-- 21 files changed, 56 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index cb539c5..0e3decf 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,8 @@ sandlock run \ --http-ca ca.pem --http-key ca-key.pem \ -r /usr -r /lib -r /etc -- python3 agent.py -# Server listening on a port (Landlock --net-bind, separate from --net-allow) -sandlock run --net-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py +# Server listening on a port (Landlock --net-allow-bind, separate from --net-allow) +sandlock run --net-allow-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py # Clean environment sandlock run --clean-env --env CC=gcc \ @@ -177,11 +177,11 @@ sandlock run --clean-env --env CC=gcc \ sandlock run --time-start "2000-01-01T00:00:00Z" --random-seed 42 -- ./build.sh # Port virtualization (multiple sandboxes can bind the same port) -sandlock run --port-remap --net-bind 6379 -r /usr -r /lib -r /etc -- redis-server --port 6379 +sandlock run --port-remap --net-allow-bind 6379 -r /usr -r /lib -r /etc -- redis-server --port 6379 # Port virtualization with named sandboxes (enables network discovery) -sandlock run --name api.local --port-remap --net-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py -sandlock run --name web.local --port-remap --net-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py +sandlock run --name api.local --port-remap --net-allow-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py +sandlock run --name web.local --port-remap --net-allow-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py # List all running sandboxes sandlock list @@ -695,7 +695,7 @@ var at it (e.g. `NODE_EXTRA_CA_CERTS`). Without any of these, port 443 is not intercepted: `--net-allow host:443` permits raw TLS to the host with no content inspection. -**Bind.** `--net-bind ` is independent from `--net-allow` and +**Bind.** `--net-allow-bind ` is independent from `--net-allow` and governs server-side `bind()`. Landlock enforces it (TCP only); `--port-remap` adds on-behalf virtualization for binding. diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 9aa7fce..879f178 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -346,7 +346,7 @@ async fn run_command(args: RunArgs) -> Result { for rule in &base.net_deny { b = b.net_deny(format_net_rule(rule)); } - for p in &base.net_bind { b = b.net_bind_port(*p); } + for p in &base.net_allow_bind { b = b.net_allow_bind_port(*p); } for rule in &base.http_allow { let s = format!("{} {}{}", rule.method, rule.host, rule.path); b = b.http_allow(&s); @@ -406,7 +406,7 @@ async fn run_command(args: RunArgs) -> Result { if let Some(n) = pb.max_processes { builder = builder.max_processes(n); } for spec in &pb.net_allow { builder = builder.net_allow(spec); } for spec in &pb.net_deny { builder = builder.net_deny(spec); } - for p in &pb.net_bind { builder = builder.net_bind_port(*p); } + for p in &pb.net_allow_bind { builder = builder.net_allow_bind_port(*p); } if let Some(seed) = pb.random_seed { builder = builder.random_seed(seed); } if pb.clean_env { builder = builder.clean_env(true); } if let Some(n) = pb.num_cpus { builder = builder.num_cpus(n); } @@ -659,7 +659,7 @@ fn validate_no_supervisor(args: &RunArgs) -> Result<()> { if args.timeout.is_some() { bad.push("--timeout"); } if !pb.net_allow.is_empty() { bad.push("--net-allow"); } if !pb.net_deny.is_empty() { bad.push("--net-deny"); } - if !pb.net_bind.is_empty() { bad.push("--net-bind"); } + if !pb.net_allow_bind.is_empty() { bad.push("--net-allow-bind"); } if !pb.http_allow.is_empty() { bad.push("--http-allow"); } if !pb.http_deny.is_empty() { bad.push("--http-deny"); } if !pb.http_ports.is_empty() { bad.push("--http-port"); } @@ -718,7 +718,7 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> if !profile.fs_denied.is_empty() { bad.push("[filesystem].deny"); } if !profile.net_allow.is_empty() { bad.push("[network].allow"); } if !profile.net_deny.is_empty() { bad.push("[network].deny"); } - if !profile.net_bind.is_empty() { bad.push("[network].bind"); } + if !profile.net_allow_bind.is_empty() { bad.push("[network].bind"); } if profile.port_remap { bad.push("[network].port_remap"); } if !profile.http_allow.is_empty() { bad.push("[http].allow"); } if !profile.http_deny.is_empty() { bad.push("[http].deny"); } diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index 07dc149..add070b 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -483,7 +483,7 @@ fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError let net_tcp_active = ProtectionStatus::resolve(Protection::NetTcp, abi, pol) == ProtectionStatus::Active; if handle_net && net_tcp_active { - for &port in &policy.net_bind { + for &port in &policy.net_allow_bind { add_net_rule(&ruleset_fd, port, LANDLOCK_ACCESS_NET_BIND_TCP).map_err(|e| { SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) })?; diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 833db21..d01697f 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -80,7 +80,7 @@ pub struct FilesystemSection { #[derive(Debug, Clone, Default, Deserialize, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct NetworkSection { - pub bind: Vec, + pub allow_bind: Vec, pub allow: Vec, pub deny: Vec, pub port_remap: bool, @@ -168,7 +168,7 @@ pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), Sandlo if let Some(s) = input.filesystem.on_error.as_deref() { b = b.on_error(parse_branch_action(s)?); } // [network] - for p in input.network.bind.iter() { b = b.net_bind_port(*p); } + for p in input.network.allow_bind.iter() { b = b.net_allow_bind_port(*p); } for r in input.network.allow.iter() { b = b.net_allow(r.as_str()); } for r in input.network.deny.iter() { b = b.net_deny(r.as_str()); } if input.network.port_remap { b = b.port_remap(true); } @@ -430,7 +430,7 @@ mod tests { on_error = "abort" [network] - bind = [8080] + allow_bind = [8080] allow = ["tcp://cache.internal:6379"] port_remap = true diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 73805c7..e284cf5 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -109,7 +109,7 @@ impl TryFrom<&Sandbox> for Confinement { if !sandbox.fs_denied.is_empty() { unsupported.push("fs_denied"); } if !sandbox.extra_deny_syscalls.is_empty() { unsupported.push("extra_deny_syscalls"); } if !sandbox.net_allow.is_empty() { unsupported.push("net_allow"); } - if !sandbox.net_bind.is_empty() { unsupported.push("net_bind"); } + if !sandbox.net_allow_bind.is_empty() { unsupported.push("net_allow_bind"); } if sandbox.allows_sysv_ipc() { unsupported.push("extra_allow_syscalls=[\"sysv_ipc\"]"); } if !sandbox.http_allow.is_empty() { unsupported.push("http_allow"); } if !sandbox.http_deny.is_empty() { unsupported.push("http_deny"); } @@ -255,7 +255,7 @@ pub struct Sandbox { /// Parsed `--net-deny` rules (default-allow, IP/CIDR/port denylist). /// Mutually exclusive with `net_allow`. pub net_deny: Vec, - pub net_bind: Vec, + pub net_allow_bind: Vec, // HTTP ACL pub http_allow: Vec, pub http_deny: Vec, @@ -379,7 +379,7 @@ impl Clone for Sandbox { protection_policy: self.protection_policy.clone(), net_allow: self.net_allow.clone(), net_deny: self.net_deny.clone(), - net_bind: self.net_bind.clone(), + net_allow_bind: self.net_allow_bind.clone(), http_allow: self.http_allow.clone(), http_deny: self.http_deny.clone(), http_ports: self.http_ports.clone(), @@ -1833,8 +1833,8 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(long = "net-deny", value_name = "SPEC"))] pub net_deny: Vec, - #[cfg_attr(feature = "cli", arg(long = "net-bind"))] - pub net_bind: Vec, + #[cfg_attr(feature = "cli", arg(long = "net-allow-bind"))] + pub net_allow_bind: Vec, #[cfg_attr(feature = "cli", arg(long = "http-allow", value_name = "RULE"))] pub http_allow: Vec, @@ -2004,7 +2004,7 @@ impl Clone for SandboxBuilder { extra_allow_syscalls: self.extra_allow_syscalls.clone(), net_allow: self.net_allow.clone(), net_deny: self.net_deny.clone(), - net_bind: self.net_bind.clone(), + net_allow_bind: self.net_allow_bind.clone(), http_allow: self.http_allow.clone(), http_deny: self.http_deny.clone(), http_ports: self.http_ports.clone(), @@ -2126,8 +2126,8 @@ impl SandboxBuilder { self } - pub fn net_bind_port(mut self, port: u16) -> Self { - self.net_bind.push(port); + pub fn net_allow_bind_port(mut self, port: u16) -> Self { + self.net_allow_bind.push(port); self } @@ -2439,7 +2439,7 @@ impl SandboxBuilder { protection_policy: self.protection_policy, net_allow, net_deny, - net_bind: self.net_bind, + net_allow_bind: self.net_allow_bind, http_allow, http_deny, http_ports, diff --git a/crates/sandlock-core/tests/integration/test_netlink_virt.rs b/crates/sandlock-core/tests/integration/test_netlink_virt.rs index 1cfed8b..7ef9082 100644 --- a/crates/sandlock-core/tests/integration/test_netlink_virt.rs +++ b/crates/sandlock-core/tests/integration/test_netlink_virt.rs @@ -53,7 +53,7 @@ async fn loopback_bind_succeeds() { ), out = out.display()); // port 0 in Landlock net rules means "allow any port" - let policy = base_policy().net_bind_port(0).build().unwrap(); + let policy = base_policy().net_allow_bind_port(0).build().unwrap(); let result = policy.clone().with_name("test").run_interactive(&["python3", "-c", &script]) .await.unwrap(); diff --git a/crates/sandlock-core/tests/integration/test_network.rs b/crates/sandlock-core/tests/integration/test_network.rs index 8cf6a99..4dedda2 100644 --- a/crates/sandlock-core/tests/integration/test_network.rs +++ b/crates/sandlock-core/tests/integration/test_network.rs @@ -330,7 +330,7 @@ async fn test_net_allow_permits_listed_endpoint() { let test_port: u16 = 19753; let policy = base_policy() .net_allow(format!("127.0.0.1:{}", test_port)) - .net_bind_port(test_port) + .net_allow_bind_port(test_port) .port_remap(true) .build() .unwrap(); diff --git a/crates/sandlock-core/tests/integration/test_policy.rs b/crates/sandlock-core/tests/integration/test_policy.rs index 0f57989..1839cd8 100644 --- a/crates/sandlock-core/tests/integration/test_policy.rs +++ b/crates/sandlock-core/tests/integration/test_policy.rs @@ -29,11 +29,11 @@ fn test_builder_fs_paths() { #[test] fn test_builder_network() { let policy = Sandbox::builder() - .net_bind_port(8080) + .net_allow_bind_port(8080) .net_allow("api.example.com:443,80") .build() .unwrap(); - assert_eq!(policy.net_bind, vec![8080]); + assert_eq!(policy.net_allow_bind, vec![8080]); assert_eq!(policy.net_allow.len(), 1); let rule = &policy.net_allow[0]; assert!(matches!(&rule.target, sandlock_core::sandbox::NetTarget::Host(h) if h == "api.example.com")); diff --git a/crates/sandlock-core/tests/integration/test_port_remap.rs b/crates/sandlock-core/tests/integration/test_port_remap.rs index 0df0d3f..0457817 100644 --- a/crates/sandlock-core/tests/integration/test_port_remap.rs +++ b/crates/sandlock-core/tests/integration/test_port_remap.rs @@ -25,7 +25,7 @@ async fn test_port_remap_bind() { let out = temp_file("bind"); let policy = base_policy() - .net_bind_port(port) + .net_allow_bind_port(port) .port_remap(true) .build() .unwrap(); @@ -55,7 +55,7 @@ async fn test_port_remap_loopback() { let out = temp_file("loopback"); let policy = base_policy() - .net_bind_port(port) + .net_allow_bind_port(port) .net_allow(format!("127.0.0.1:{}", port)) .port_remap(true) .build() @@ -104,7 +104,7 @@ async fn test_port_remap_getsockname() { let out = temp_file("getsockname"); let policy = base_policy() - .net_bind_port(port) + .net_allow_bind_port(port) .port_remap(true) .build() .unwrap(); @@ -136,7 +136,7 @@ async fn test_port_remap_conflict() { let out = temp_file("conflict"); let policy = base_policy() - .net_bind_port(occupied_port) + .net_allow_bind_port(occupied_port) .port_remap(true) .build() .unwrap(); @@ -190,7 +190,7 @@ async fn test_port_remap_loopback_under_conflict() { let out = temp_file("loopback-conflict"); let policy = base_policy() - .net_bind_port(occupied_port) + .net_allow_bind_port(occupied_port) .net_allow(format!("127.0.0.1:{}", occupied_port)) .port_remap(true) .build() diff --git a/crates/sandlock-core/tests/integration/test_procfs.rs b/crates/sandlock-core/tests/integration/test_procfs.rs index 1997777..9402a94 100644 --- a/crates/sandlock-core/tests/integration/test_procfs.rs +++ b/crates/sandlock-core/tests/integration/test_procfs.rs @@ -201,7 +201,7 @@ async fn test_proc_net_tcp_filtered() { .fs_read("/usr").fs_read("/lib").fs_read_if_exists("/lib64").fs_read("/bin") .fs_read("/etc").fs_read("/proc").fs_read("/dev") .fs_write("/tmp") - .net_bind_port(port) + .net_allow_bind_port(port) .port_remap(true) .build() .unwrap(); diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index bca1e9f..cca7cab 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -329,12 +329,12 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_net_allow( /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_sandbox_builder_net_bind_port( +pub unsafe extern "C" fn sandlock_sandbox_builder_net_allow_bind_port( b: *mut SandboxBuilder, port: u16, ) -> *mut SandboxBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.net_bind_port(port))) + Box::into_raw(Box::new(builder.net_allow_bind_port(port))) } /// # Safety diff --git a/docs/sandbox-reference.md b/docs/sandbox-reference.md index 4ff762c..7cbd103 100644 --- a/docs/sandbox-reference.md +++ b/docs/sandbox-reference.md @@ -39,7 +39,7 @@ sandbox = Sandbox( on_exit=BranchAction.COMMIT, on_error=BranchAction.ABORT, # [network] - net_bind=(), net_allow=(), port_remap=False, + net_allow_bind=(), net_allow=(), port_remap=False, # [http] http_ports=(), http_allow=(), http_deny=(), @@ -97,7 +97,7 @@ on_exit = "commit" # "commit" | "abort" | "keep" on_error = "abort" [network] -bind = [8080] +allow_bind = [8080] allow = ["api.example.com:443", "udp://1.1.1.1:53"] port_remap = false @@ -215,7 +215,7 @@ Rule shapes: | Python | TOML | Type | Default | Description | | ------------ | ------------ | ----------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `net_allow` | `allow` | `Sequence[str]` | `()` | Outbound endpoint allowlist. Empty list denies all outbound. | -| `net_bind` | `bind` | `Sequence[int \| str]` | `()` | TCP ports the sandbox may bind. Each entry is a port or a `"lo-hi"` range. Landlock ABI v4+ (TCP only; UDP `bind()` is not separately gated). | +| `net_allow_bind` | `allow_bind` | `Sequence[int \| str]` | `()` | TCP ports the sandbox may bind/listen on (default-deny allowlist). Each entry is a port or a `"lo-hi"` range. Landlock ABI v4+ (TCP only; UDP `bind()` is not separately gated). | | `port_remap` | `port_remap` | `bool` | `False` | Enable transparent TCP port virtualization. Each sandbox receives an independent virtual port space; conflicting binds are remapped to unique real ports via `pidfd_getfd`. | Hostnames are resolved once at sandbox creation and pinned via a diff --git a/python/README.md b/python/README.md index dccf01e..d7108f4 100644 --- a/python/README.md +++ b/python/README.md @@ -111,7 +111,7 @@ with Sandbox(fs_readable=["/usr", "/lib"]) as sb: | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `net_allow` | `list[str]` | `[]` | Outbound endpoint rules. Bare `host:port` is TCP; protocol prefixes opt others in: `tcp://host:port`, `udp://host:port` (or `udp://*:*` for any UDP), `icmp://host` (or `icmp://*` for any ICMP echo via the kernel ping socket — gated by `net.ipv4.ping_group_range` on the host). Empty = deny all. Raw ICMP is not exposed. | -| `net_bind` | `list[int \| str]` | `[]` | TCP ports the sandbox may bind (empty = deny all) | +| `net_allow_bind` | `list[int \| str]` | `[]` | TCP ports the sandbox may bind (empty = deny all) | | `port_remap` | `bool` | `False` | Transparent TCP port virtualization | #### HTTP ACL diff --git a/python/src/sandlock/_profile.py b/python/src/sandlock/_profile.py index fee630e..cd10f62 100644 --- a/python/src/sandlock/_profile.py +++ b/python/src/sandlock/_profile.py @@ -14,7 +14,7 @@ [filesystem] → fs_readable (read), fs_writable (write), fs_denied (deny), chroot, fs_mount (mount), on_exit, on_error - [network] → net_bind (bind), net_allow (allow), port_remap + [network] → net_allow_bind (allow_bind), net_allow (allow), port_remap [http] → http_ports (ports), http_allow (allow), http_deny (deny) [syscalls] → extra_allow_syscalls (extra_allow), @@ -83,7 +83,7 @@ "on_error": ("on_error", str), }, "network": { - "bind": ("net_bind", list), + "allow_bind": ("net_allow_bind", list), "allow": ("net_allow", list), "port_remap": ("port_remap", bool), }, @@ -240,7 +240,7 @@ def _coerce( ) mount[virt] = host return mount - if sandbox_key == "net_bind": + if sandbox_key == "net_allow_bind": # Coerce TOML integers to strings for port specs (existing behaviour). return [str(v) if isinstance(v, int) else v for v in value] return value diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index eb01459..e612c67 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -88,7 +88,7 @@ def _builder_fn(name, *extra_args): _b_max_cpu = _builder_fn("sandlock_sandbox_builder_max_cpu", ctypes.c_uint8) _b_num_cpus = _builder_fn("sandlock_sandbox_builder_num_cpus", ctypes.c_uint32) _b_net_allow = _builder_fn("sandlock_sandbox_builder_net_allow", ctypes.c_char_p) -_b_net_bind_port = _builder_fn("sandlock_sandbox_builder_net_bind_port", ctypes.c_uint16) +_b_net_allow_bind_port = _builder_fn("sandlock_sandbox_builder_net_allow_bind_port", ctypes.c_uint16) _b_port_remap = _builder_fn("sandlock_sandbox_builder_port_remap", ctypes.c_bool) _b_http_allow = _builder_fn("sandlock_sandbox_builder_http_allow", ctypes.c_char_p) _b_http_deny = _builder_fn("sandlock_sandbox_builder_http_deny", ctypes.c_char_p) @@ -951,7 +951,7 @@ def __del__(self): "workdir", "cwd", "chroot", "fs_mount", "on_exit", "on_error", "max_memory", "max_disk", "max_processes", "max_cpu", "num_cpus", "cpu_cores", "gpu_devices", - "net_allow", "net_bind", + "net_allow", "net_allow_bind", "port_remap", "http_allow", "http_deny", "http_ports", "http_ca", "http_key", "uid", @@ -1035,8 +1035,8 @@ def _build_from_policy(policy: PolicyDataclass): # the native build(). for spec in (policy.net_allow or []): b = _b_net_allow(b, _encode(str(spec))) - for port in parse_ports(policy.net_bind) if policy.net_bind else []: - b = _b_net_bind_port(b, port) + for port in parse_ports(policy.net_allow_bind) if policy.net_allow_bind else []: + b = _b_net_allow_bind_port(b, port) for rule in (policy.http_allow or []): b = _b_http_allow(b, _encode(str(rule))) diff --git a/python/src/sandlock/mcp/_policy.py b/python/src/sandlock/mcp/_policy.py index 530177e..73a2792 100644 --- a/python/src/sandlock/mcp/_policy.py +++ b/python/src/sandlock/mcp/_policy.py @@ -97,7 +97,7 @@ def policy_for_tool( workspace, "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", _PYTHON_PREFIX, *_INTERP_READABLE, *extra_readable, ])), - "net_bind": [], + "net_allow_bind": [], "net_allow": [], "clean_env": True, } diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 987aeae..0ed19e2 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -175,7 +175,7 @@ class Sandbox: leaking sandbox memory contents but breaks gdb/strace/perf.""" # Network — bind allowlist (Landlock ABI v4+, TCP only) - net_bind: Sequence[int | str] = field(default_factory=list) + net_allow_bind: Sequence[int | str] = field(default_factory=list) """TCP ports the sandbox may bind. Empty = deny all. Each entry is a port number or a ``"lo-hi"`` range string. Landlock's port hooks are TCP-only — UDP bind is not separately gated.""" @@ -405,7 +405,7 @@ def _ensure_native(self): def bind_ports(self) -> list[int]: """Return parsed bind port list, or empty if unrestricted.""" - return parse_ports(self.net_bind) if self.net_bind else [] + return parse_ports(self.net_allow_bind) if self.net_allow_bind else [] def memory_bytes(self) -> int | None: """Return max_memory as bytes, or None if unset.""" diff --git a/python/tests/test_mcp.py b/python/tests/test_mcp.py index 76013c0..e0172dc 100644 --- a/python/tests/test_mcp.py +++ b/python/tests/test_mcp.py @@ -17,7 +17,7 @@ def test_no_capabilities(self): assert policy.fs_writable == [] assert "/tmp/ws" in policy.fs_readable assert policy.net_allow == [] - assert policy.net_bind == [] + assert policy.net_allow_bind == [] def test_empty_capabilities(self): policy = policy_for_tool(workspace="/tmp/ws", capabilities={}) diff --git a/python/tests/test_profile.py b/python/tests/test_profile.py index bfefbb9..12c08f4 100644 --- a/python/tests/test_profile.py +++ b/python/tests/test_profile.py @@ -85,12 +85,12 @@ def test_limits_section(self): def test_network_section(self): p = policy_from_dict({ "network": { - "bind": [8080], + "allow_bind": [8080], "allow": ["api.example.com:443", ":8080"], "port_remap": True, }, }) - assert p.net_bind == ["8080"] # ints coerced to strings + assert p.net_allow_bind == ["8080"] # ints coerced to strings assert list(p.net_allow) == ["api.example.com:443", ":8080"] assert p.port_remap is True diff --git a/python/tests/test_sandbox.py b/python/tests/test_sandbox.py index 16c7867..e2c40bf 100644 --- a/python/tests/test_sandbox.py +++ b/python/tests/test_sandbox.py @@ -288,7 +288,7 @@ def test_slow_path_host_holds_virtual_port(self): "print(s.getsockname()[1]); " "s.close()" ) - policy = _policy(port_remap=True, net_bind=[8080]) + policy = _policy(port_remap=True, net_allow_bind=[8080]) holder = socket.socket(socket.AF_INET, socket.SOCK_STREAM) holder.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -358,7 +358,7 @@ def test_tcp_sendmsg_2mb_with_port_remap(self): "print(json.dumps({'server_port': server_port, 'sent': total_sent, " "'received': len(received), 'data_ok': bytes(received) == payload}))" ) - policy = _policy(port_remap=True, net_bind=[7070], net_allow=["127.0.0.1:7070"]) + policy = _policy(port_remap=True, net_allow_bind=[7070], net_allow=["127.0.0.1:7070"]) result = policy.run(["python3", "-c", code]) assert result.success, f"Sandbox failed: {result}" diff --git a/python/tests/test_sandbox_config.py b/python/tests/test_sandbox_config.py index 0cc5008..012d7e8 100644 --- a/python/tests/test_sandbox_config.py +++ b/python/tests/test_sandbox_config.py @@ -76,7 +76,7 @@ def test_defaults(self): assert p.fs_denied == [] assert p.extra_deny_syscalls == [] assert p.extra_allow_syscalls == [] - assert p.net_bind == [] + assert p.net_allow_bind == [] assert p.net_allow == [] assert p.max_memory is None assert p.max_processes == 64 @@ -168,7 +168,7 @@ def test_empty(self): class TestNetPolicy: def test_bind_ports(self): - p = Sandbox(net_bind=[80, "443", "8000-8002"]) + p = Sandbox(net_allow_bind=[80, "443", "8000-8002"]) assert p.bind_ports() == [80, 443, 8000, 8001, 8002] def test_unrestricted_by_default(self): From 9ffbbfff8c5fc26aec2943b0edcd8561506c8fbc Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sun, 7 Jun 2026 15:22:21 -0700 Subject: [PATCH 19/24] cli: accept comma-separated ports and lo-hi ranges in --net-allow-bind Signed-off-by: Cong Wang --- README.md | 13 +++-- crates/sandlock-cli/src/main.rs | 2 +- crates/sandlock-core/src/sandbox.rs | 82 +++++++++++++++++++++++++++-- 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 0e3decf..f5c09cf 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,9 @@ sandlock run \ --http-ca ca.pem --http-key ca-key.pem \ -r /usr -r /lib -r /etc -- python3 agent.py -# Server listening on a port (Landlock --net-allow-bind, separate from --net-allow) -sandlock run --net-allow-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py +# Server listening on ports (Landlock --net-allow-bind, separate from --net-allow; +# accepts comma-separated ports and lo-hi ranges, repeatable) +sandlock run --net-allow-bind 8080,9000-9005 -r /usr -r /lib -r /etc -- python3 server.py # Clean environment sandlock run --clean-env --env CC=gcc \ @@ -695,9 +696,11 @@ var at it (e.g. `NODE_EXTRA_CA_CERTS`). Without any of these, port 443 is not intercepted: `--net-allow host:443` permits raw TLS to the host with no content inspection. -**Bind.** `--net-allow-bind ` is independent from `--net-allow` and -governs server-side `bind()`. Landlock enforces it (TCP only); -`--port-remap` adds on-behalf virtualization for binding. +**Bind.** `--net-allow-bind ` is independent from `--net-allow` and +governs server-side `bind()` as a default-deny allowlist. Each value is a +comma-separated list of single ports or inclusive `lo-hi` ranges (e.g. +`--net-allow-bind 8080,9000-9005`), and the flag repeats. Landlock enforces +it (TCP only); `--port-remap` adds on-behalf virtualization for binding. **AF_UNIX sockets** are governed by Landlock's `LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET`, independent from `--net-allow`. diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 879f178..8c9ff64 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -406,7 +406,7 @@ async fn run_command(args: RunArgs) -> Result { if let Some(n) = pb.max_processes { builder = builder.max_processes(n); } for spec in &pb.net_allow { builder = builder.net_allow(spec); } for spec in &pb.net_deny { builder = builder.net_deny(spec); } - for p in &pb.net_allow_bind { builder = builder.net_allow_bind_port(*p); } + for spec in &pb.net_allow_bind { builder = builder.net_allow_bind(spec); } if let Some(seed) = pb.random_seed { builder = builder.random_seed(seed); } if pb.clean_env { builder = builder.clean_env(true); } if let Some(n) = pb.num_cpus { builder = builder.num_cpus(n); } diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index e284cf5..f91ff31 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -1833,8 +1833,11 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(long = "net-deny", value_name = "SPEC"))] pub net_deny: Vec, - #[cfg_attr(feature = "cli", arg(long = "net-allow-bind"))] - pub net_allow_bind: Vec, + /// `--net-allow-bind`: TCP ports the sandbox may bind/listen on + /// (default-deny). Each value is a comma-separated list of single ports + /// or inclusive `lo-hi` ranges, e.g. `8080,9000-9005`. Repeatable. + #[cfg_attr(feature = "cli", arg(long = "net-allow-bind", value_name = "PORTS"))] + pub net_allow_bind: Vec, #[cfg_attr(feature = "cli", arg(long = "http-allow", value_name = "RULE"))] pub http_allow: Vec, @@ -2126,8 +2129,17 @@ impl SandboxBuilder { self } + /// Allow binding a single TCP port. For comma-separated lists or + /// `lo-hi` ranges, use [`net_allow_bind`](Self::net_allow_bind). pub fn net_allow_bind_port(mut self, port: u16) -> Self { - self.net_allow_bind.push(port); + self.net_allow_bind.push(port.to_string()); + self + } + + /// Allow binding TCP ports from a spec: a comma-separated list of single + /// ports or inclusive `lo-hi` ranges (e.g. `"8080,9000-9005"`). + pub fn net_allow_bind(mut self, spec: impl Into) -> Self { + self.net_allow_bind.push(spec.into()); self } @@ -2439,7 +2451,7 @@ impl SandboxBuilder { protection_policy: self.protection_policy, net_allow, net_deny, - net_allow_bind: self.net_allow_bind, + net_allow_bind: parse_bind_ports(&self.net_allow_bind)?, http_allow, http_deny, http_ports, @@ -2490,6 +2502,48 @@ impl SandboxBuilder { } } +/// Expand `--net-allow-bind` specs into a sorted, deduplicated port list. +/// Each spec is a comma-separated list of single ports (`8080`) or inclusive +/// `lo-hi` ranges (`8000-8010`). Mirrors the Python SDK's `parse_ports`. +fn parse_bind_ports(specs: &[String]) -> Result, SandboxError> { + let mut ports: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for spec in specs { + for part in spec.split(',') { + let part = part.trim(); + if part.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-allow-bind: empty port in `{}`", + spec + ))); + } + match part.split_once('-') { + Some((lo, hi)) => { + let lo: u16 = lo.trim().parse().map_err(|_| { + SandboxError::Invalid(format!("--net-allow-bind: invalid port range `{}`", part)) + })?; + let hi: u16 = hi.trim().parse().map_err(|_| { + SandboxError::Invalid(format!("--net-allow-bind: invalid port range `{}`", part)) + })?; + if lo > hi { + return Err(SandboxError::Invalid(format!( + "--net-allow-bind: reversed port range `{}` (lo > hi)", + part + ))); + } + ports.extend(lo..=hi); + } + None => { + let p: u16 = part.parse().map_err(|_| { + SandboxError::Invalid(format!("--net-allow-bind: invalid port `{}`", part)) + })?; + ports.insert(p); + } + } + } + } + Ok(ports.into_iter().collect()) +} + /// Resolve a path as seen inside the sandbox to its host-side location, so its /// existence can be checked before spawn. Honors `--fs-mount` (virtual:host) /// mappings (which take precedence) and chroot. Used to validate @@ -2676,6 +2730,26 @@ mod tests { assert_eq!(policy.net_deny.len(), 1); } + #[test] + fn builder_net_allow_bind_comma_and_ranges() { + // Comma-separated ports and `lo-hi` ranges expand, sort, and dedup. + let policy = Sandbox::builder() + .net_allow_bind("8080,9000-9002") + .net_allow_bind_port(443) + .net_allow_bind("9001,443") // overlaps dedup away + .build() + .unwrap(); + assert_eq!(policy.net_allow_bind, vec![443, 8080, 9000, 9001, 9002]); + } + + #[test] + fn builder_net_allow_bind_rejects_bad_specs() { + assert!(Sandbox::builder().net_allow_bind("9000-8000").build().is_err()); // reversed + assert!(Sandbox::builder().net_allow_bind("80,abc").build().is_err()); // bad port + assert!(Sandbox::builder().net_allow_bind("70000").build().is_err()); // > u16 + assert!(Sandbox::builder().net_allow_bind("8080,").build().is_err()); // empty part + } + #[test] fn builder_rejects_net_allow_and_net_deny_together() { let err = Sandbox::builder() From 2fc8524b6be6cce92d02f0de663d53e1b9bb97f2 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sun, 7 Jun 2026 15:26:21 -0700 Subject: [PATCH 20/24] profile: accept port range strings in [network].allow_bind for CLI/Python parity Signed-off-by: Cong Wang --- crates/sandlock-core/src/profile.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index d01697f..a05f929 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -77,10 +77,21 @@ pub struct FilesystemSection { pub on_error: Option, } +/// One `[network].allow_bind` entry: a bare integer port (`8080`) or a +/// quoted string holding a comma list and/or `lo-hi` range (`"9000-9005"`). +/// The untagged form lets a TOML array mix the two, e.g. +/// `allow_bind = [8080, "9000-9005"]`. +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum PortSpec { + Port(u16), + Spec(String), +} + #[derive(Debug, Clone, Default, Deserialize, PartialEq)] #[serde(deny_unknown_fields, default)] pub struct NetworkSection { - pub allow_bind: Vec, + pub allow_bind: Vec, pub allow: Vec, pub deny: Vec, pub port_remap: bool, @@ -168,7 +179,12 @@ pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), Sandlo if let Some(s) = input.filesystem.on_error.as_deref() { b = b.on_error(parse_branch_action(s)?); } // [network] - for p in input.network.allow_bind.iter() { b = b.net_allow_bind_port(*p); } + for entry in input.network.allow_bind.iter() { + b = match entry { + PortSpec::Port(p) => b.net_allow_bind_port(*p), + PortSpec::Spec(s) => b.net_allow_bind(s), + }; + } for r in input.network.allow.iter() { b = b.net_allow(r.as_str()); } for r in input.network.deny.iter() { b = b.net_deny(r.as_str()); } if input.network.port_remap { b = b.port_remap(true); } @@ -430,7 +446,7 @@ mod tests { on_error = "abort" [network] - allow_bind = [8080] + allow_bind = [8080, "9000-9002"] allow = ["tcp://cache.internal:6379"] port_remap = true @@ -459,6 +475,8 @@ mod tests { // rule that the builder auto-merges (api.internal on http.ports). The // merge is the contract being verified here. assert!(policy.net_allow.len() >= 2); + // allow_bind mixes a bare int port and a quoted range string. + assert_eq!(policy.net_allow_bind, vec![8080, 9000, 9001, 9002]); assert_eq!(policy.http_allow.len(), 1); assert_eq!(policy.fs_mount.len(), 1); } From de3f269d1ccbbae74c335835379fc6117655bb19 Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sun, 7 Jun 2026 15:32:58 -0700 Subject: [PATCH 21/24] python: split commas in parse_ports so net_allow_bind matches the CLI/profile grammar Signed-off-by: Cong Wang --- python/src/sandlock/sandbox.py | 25 +++++++++++++++---------- python/tests/test_sandbox_config.py | 10 ++++++++++ 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 0ed19e2..8e5c817 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -58,8 +58,11 @@ def parse_memory_size(s: str) -> int: def parse_ports(specs: Sequence[int | str]) -> list[int]: """Parse port specifications into a sorted list of unique port numbers. - Each spec is an int (single port) or a string like ``"80"``, - ``"8000-9000"``. Raises ValueError on out-of-range or bad format. + Each spec is an int (single port) or a string holding a comma-separated + list of single ports / inclusive ``"lo-hi"`` ranges, e.g. ``"80"``, + ``"8000-9000"``, or ``"8080,9000-9005"`` (matching the CLI's + ``--net-allow-bind`` grammar). Raises ValueError on out-of-range or bad + format. """ ports: set[int] = set() for spec in specs: @@ -68,14 +71,16 @@ def parse_ports(specs: Sequence[int | str]) -> list[int]: raise ValueError(f"port out of range: {spec}") ports.add(spec) continue - m = _PORT_RANGE_RE.match(spec.strip()) - if m is None: - raise ValueError(f"invalid port spec: {spec!r}") - lo = int(m.group(1)) - hi = int(m.group(2)) if m.group(2) else lo - if lo > hi or not 0 <= lo <= 65535 or not 0 <= hi <= 65535: - raise ValueError(f"invalid port range: {spec!r}") - ports.update(range(lo, hi + 1)) + for part in spec.split(","): + part = part.strip() + m = _PORT_RANGE_RE.match(part) + if m is None: + raise ValueError(f"invalid port spec: {part!r}") + lo = int(m.group(1)) + hi = int(m.group(2)) if m.group(2) else lo + if lo > hi or not 0 <= lo <= 65535 or not 0 <= hi <= 65535: + raise ValueError(f"invalid port range: {part!r}") + ports.update(range(lo, hi + 1)) return sorted(ports) diff --git a/python/tests/test_sandbox_config.py b/python/tests/test_sandbox_config.py index 012d7e8..5a1504d 100644 --- a/python/tests/test_sandbox_config.py +++ b/python/tests/test_sandbox_config.py @@ -147,6 +147,16 @@ def test_range(self): def test_mixed(self): assert parse_ports([80, "443", "8000-8002"]) == [80, 443, 8000, 8001, 8002] + def test_comma_in_string(self): + # A string element may hold a comma list / ranges, matching the CLI's + # --net-allow-bind grammar. + assert parse_ports(["8080,9090"]) == [8080, 9090] + assert parse_ports(["8080,9000-9002", 443]) == [443, 8080, 9000, 9001, 9002] + + def test_comma_empty_part_rejected(self): + with pytest.raises(ValueError): + parse_ports(["8080,"]) + def test_dedup(self): assert parse_ports([80, "80", "79-81"]) == [79, 80, 81] From a2a470db56045f77687c62781b4b9507e041f49b Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sun, 7 Jun 2026 15:43:09 -0700 Subject: [PATCH 22/24] ffi/python: wire --net-deny into the FFI, Python SDK, and profile loader Signed-off-by: Cong Wang --- crates/sandlock-ffi/include/sandlock.h | 9 ++++++++- crates/sandlock-ffi/src/lib.rs | 12 ++++++++++++ python/src/sandlock/_profile.py | 3 ++- python/src/sandlock/_sdk.py | 10 +++++++--- python/src/sandlock/sandbox.py | 18 ++++++++++++++---- python/tests/test_profile.py | 6 ++++++ python/tests/test_sandbox.py | 18 ++++++++++++++++++ python/tests/test_sandbox_config.py | 12 ++++++++++++ 8 files changed, 79 insertions(+), 9 deletions(-) diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 5d9bffc..470608d 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -392,11 +392,18 @@ sandlock_builder_t *sandlock_sandbox_builder_cpu_cores(sandlock_builder_t *b, */ sandlock_builder_t *sandlock_sandbox_builder_net_allow(sandlock_builder_t *b, const char *spec); +/** + * # Safety + * `b` and `spec` must be valid pointers. + */ +sandlock_builder_t *sandlock_sandbox_builder_net_deny(sandlock_builder_t *b, const char *spec); + /** * # Safety * `b` must be a valid builder pointer. */ -sandlock_builder_t *sandlock_sandbox_builder_net_bind_port(sandlock_builder_t *b, uint16_t port); +sandlock_builder_t *sandlock_sandbox_builder_net_allow_bind_port(sandlock_builder_t *b, + uint16_t port); /** * # Safety diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index cca7cab..5d204f7 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -326,6 +326,18 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_net_allow( Box::into_raw(Box::new(builder.net_allow(spec))) } +/// # Safety +/// `b` and `spec` must be valid pointers. +#[no_mangle] +pub unsafe extern "C" fn sandlock_sandbox_builder_net_deny( + b: *mut SandboxBuilder, spec: *const c_char, +) -> *mut SandboxBuilder { + if b.is_null() || spec.is_null() { return b; } + let spec = CStr::from_ptr(spec).to_str().unwrap_or(""); + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.net_deny(spec))) +} + /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] diff --git a/python/src/sandlock/_profile.py b/python/src/sandlock/_profile.py index cd10f62..05e2fcd 100644 --- a/python/src/sandlock/_profile.py +++ b/python/src/sandlock/_profile.py @@ -14,7 +14,7 @@ [filesystem] → fs_readable (read), fs_writable (write), fs_denied (deny), chroot, fs_mount (mount), on_exit, on_error - [network] → net_allow_bind (allow_bind), net_allow (allow), port_remap + [network] → net_allow_bind (allow_bind), net_allow (allow), net_deny (deny), port_remap [http] → http_ports (ports), http_allow (allow), http_deny (deny) [syscalls] → extra_allow_syscalls (extra_allow), @@ -85,6 +85,7 @@ "network": { "allow_bind": ("net_allow_bind", list), "allow": ("net_allow", list), + "deny": ("net_deny", list), "port_remap": ("port_remap", bool), }, "http": { diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index e612c67..e13453a 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -88,6 +88,7 @@ def _builder_fn(name, *extra_args): _b_max_cpu = _builder_fn("sandlock_sandbox_builder_max_cpu", ctypes.c_uint8) _b_num_cpus = _builder_fn("sandlock_sandbox_builder_num_cpus", ctypes.c_uint32) _b_net_allow = _builder_fn("sandlock_sandbox_builder_net_allow", ctypes.c_char_p) +_b_net_deny = _builder_fn("sandlock_sandbox_builder_net_deny", ctypes.c_char_p) _b_net_allow_bind_port = _builder_fn("sandlock_sandbox_builder_net_allow_bind_port", ctypes.c_uint16) _b_port_remap = _builder_fn("sandlock_sandbox_builder_port_remap", ctypes.c_bool) _b_http_allow = _builder_fn("sandlock_sandbox_builder_http_allow", ctypes.c_char_p) @@ -951,7 +952,7 @@ def __del__(self): "workdir", "cwd", "chroot", "fs_mount", "on_exit", "on_error", "max_memory", "max_disk", "max_processes", "max_cpu", "num_cpus", "cpu_cores", "gpu_devices", - "net_allow", "net_allow_bind", + "net_allow", "net_deny", "net_allow_bind", "port_remap", "http_allow", "http_deny", "http_ports", "http_ca", "http_key", "uid", @@ -1031,10 +1032,13 @@ def _build_from_policy(policy: PolicyDataclass): # net_allow: list of endpoint specs. Bare `host:port` means TCP; # `tcp://`/`udp://`/`icmp://` schemes opt other protocols in. - # Empty = deny all outbound. Validation of each spec happens in - # the native build(). + # Empty = deny all outbound. net_deny is the inverse (default-allow + # denylist of IP/CIDR/port specs); the two are mutually exclusive. + # Validation of each spec happens in the native build(). for spec in (policy.net_allow or []): b = _b_net_allow(b, _encode(str(spec))) + for spec in (policy.net_deny or []): + b = _b_net_deny(b, _encode(str(spec))) for port in parse_ports(policy.net_allow_bind) if policy.net_allow_bind else []: b = _b_net_allow_bind_port(b, port) diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 8e5c817..782c63f 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -169,10 +169,20 @@ class Sandbox: Protocol gating falls out of rule presence: with no UDP/ICMP rules, UDP and ICMP socket creation are denied at the seccomp layer. - Hostnames are resolved at sandbox-creation time and pinned via a - synthetic ``/etc/hosts``. Empty = deny all outbound. HTTP rules with - concrete hosts auto-add a matching TCP entry on :attr:`http_ports`. - See README "Network Model" for details.""" + A target may also be an IP, a CIDR range, or an IPv6 literal + (``"10.0.0.0/8:443"``, ``"[2606:4700::/32]:443"``), matched by + containment with no DNS. Hostnames are resolved at sandbox-creation + time and pinned via a synthetic ``/etc/hosts``. Empty = deny all + outbound. HTTP rules with concrete hosts auto-add a matching TCP entry + on :attr:`http_ports`. See README "Network Model" for details.""" + + net_deny: Sequence[str] = field(default_factory=list) + """Outbound endpoint denylist: default-allow networking, block these + targets. The inverse of :attr:`net_allow` and **mutually exclusive** + with it. Same grammar as ``net_allow`` except targets must be a literal + IP/CIDR or ``"*"`` (hostnames are rejected; use :attr:`http_deny` for + domains), e.g. ``["10.0.0.0/8", "169.254.169.254:80", "udp://*"]``. + Empty = no denylist. See README "Network Model" for details.""" no_coredump: bool = False """Disable core dumps and restrict /proc/pid access from other diff --git a/python/tests/test_profile.py b/python/tests/test_profile.py index 12c08f4..ceea9db 100644 --- a/python/tests/test_profile.py +++ b/python/tests/test_profile.py @@ -94,6 +94,12 @@ def test_network_section(self): assert list(p.net_allow) == ["api.example.com:443", ":8080"] assert p.port_remap is True + def test_network_deny_section(self): + p = policy_from_dict({ + "network": {"deny": ["10.0.0.0/8", "169.254.169.254:80"]}, + }) + assert list(p.net_deny) == ["10.0.0.0/8", "169.254.169.254:80"] + def test_http_section(self): p = policy_from_dict({ "http": { diff --git a/python/tests/test_sandbox.py b/python/tests/test_sandbox.py index e2c40bf..711b2c8 100644 --- a/python/tests/test_sandbox.py +++ b/python/tests/test_sandbox.py @@ -89,6 +89,24 @@ def test_fs_denied_blocks_read(self, tmp_dir): assert not result.success +class TestNetDeny: + """`net_deny` wired through the FFI: default-allow networking with an + IP/CIDR/port denylist, mutually exclusive with `net_allow`.""" + + def test_net_deny_builds_and_runs(self): + result = _policy( + net_deny=["10.0.0.0/8", "169.254.169.254:80", "udp://*"] + ).run(["echo", "ok"]) + assert result.success + assert result.stdout.strip() == b"ok" + + def test_net_allow_and_net_deny_mutually_exclusive(self): + with pytest.raises(RuntimeError, match="mutually exclusive"): + _policy( + net_allow=["github.com:443"], net_deny=["10.0.0.0/8"] + ).run(["echo", "ok"]) + + class TestSandlockRunCAbiMultiThreaded: """Regression for issue #47 covering only the C ABI ``sandlock_run`` path. diff --git a/python/tests/test_sandbox_config.py b/python/tests/test_sandbox_config.py index 5a1504d..ce3eb8a 100644 --- a/python/tests/test_sandbox_config.py +++ b/python/tests/test_sandbox_config.py @@ -248,3 +248,15 @@ def test_specs_preserved_as_strings(self): ":8080", ] + +class TestNetDeny: + """Endpoint denylist semantics for `net_deny` (default-allow, inverse of + `net_allow`, mutually exclusive with it). Targets are literal IP/CIDR.""" + + def test_default_is_empty(self): + assert Sandbox().net_deny == [] + + def test_specs_preserved_as_strings(self): + p = Sandbox(net_deny=["10.0.0.0/8", "169.254.169.254:80", "udp://*"]) + assert list(p.net_deny) == ["10.0.0.0/8", "169.254.169.254:80", "udp://*"] + From fcdb7b7d0a59ba0e289cd3a637b70c0d9ee822cd Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sun, 7 Jun 2026 15:49:08 -0700 Subject: [PATCH 23/24] go: fix net-allow-bind rename, add NetDeny, split commas in ParsePorts Signed-off-by: Cong Wang --- go/README.md | 13 ++++++---- go/internal/policy/spec.go | 43 ++++++++++++++++++--------------- go/internal/policy/spec_test.go | 3 +++ go/sandbox.go | 21 ++++++++++------ go/sandlock_linux.go | 13 +++++++--- 5 files changed, 57 insertions(+), 36 deletions(-) diff --git a/go/README.md b/go/README.md index 7bbf1de..a10aa2c 100644 --- a/go/README.md +++ b/go/README.md @@ -68,7 +68,7 @@ fresh native policy on each call. | Group | Fields | |---|---| | Filesystem | `FSReadable`, `FSWritable`, `FSDenied`, `Workdir`, `Cwd`, `Chroot`, `FSMount` | -| Network | `NetAllow`, `NetBind`, `PortRemap` | +| Network | `NetAllow`, `NetDeny`, `NetAllowBind`, `PortRemap` | | HTTP ACL | `HTTPAllow`, `HTTPDeny`, `HTTPPorts`, `HTTPCAFile`, `HTTPKeyFile` | | Resources | `MaxMemory`, `MaxDisk`, `MaxProcesses`, `MaxCPU`, `MaxOpenFiles`, `CPUCores`, `NumCPUs`, `GPUDevices` | | Syscalls | `ExtraAllowSyscalls`, `ExtraDenySyscalls` | @@ -78,10 +78,13 @@ fresh native policy on each call. | COW branch | `FSStorage`, `OnExit`, `OnError` | `NetAllow` entries follow sandlock's rule grammar: bare `host:port` is TCP -(`"api.openai.com:443"`, `"github.com:22,443"`, `":53"`); scheme prefixes opt -other protocols in (`"udp://1.1.1.1:53"`, `"udp://*:*"`, `"icmp://host"`, -`"icmp://*"`). `NetBind` entries are single ports (`"8080"`) or inclusive -ranges (`"3000-3010"`). +(`"api.openai.com:443"`, `"github.com:22,443"`, `":53"`); a target may be a +host, IP, or CIDR (`"10.0.0.0/8:443"`, `"[2606:4700::/32]:443"`); scheme +prefixes opt other protocols in (`"udp://1.1.1.1:53"`, `"udp://*"`, +`"icmp://host"`, `"icmp://*"`). `NetDeny` is the inverse (default-allow +denylist, IP/CIDR targets only, mutually exclusive with `NetAllow`). +`NetAllowBind` entries are comma-separated single ports or inclusive ranges +(`"8080"`, `"3000-3010"`, `"8080,9000-9005"`). ### Execution diff --git a/go/internal/policy/spec.go b/go/internal/policy/spec.go index fb1e6d3..455e922 100644 --- a/go/internal/policy/spec.go +++ b/go/internal/policy/spec.go @@ -45,31 +45,34 @@ func ParseMemory(s string) (uint64, error) { } // ParsePorts expands a list of port specs into a sorted, de-duplicated list of -// individual port numbers. Each spec is a single port ("80") or an inclusive -// range ("8000-9000"). Values must fall in [0, 65535]. +// individual port numbers. Each spec is a comma-separated list of single ports +// ("80") or inclusive ranges ("8000-9000"), e.g. "8080,9000-9005" (matching +// the CLI's --net-allow-bind grammar). Values must fall in [0, 65535]. func ParsePorts(specs []string) ([]uint16, error) { set := map[uint16]struct{}{} for _, spec := range specs { - m := portRe.FindStringSubmatch(strings.TrimSpace(spec)) - if m == nil { - return nil, fmt.Errorf("invalid port spec: %q", spec) - } - lo, err := strconv.Atoi(m[1]) - if err != nil { - return nil, fmt.Errorf("invalid port spec: %q", spec) - } - hi := lo - if m[2] != "" { - hi, err = strconv.Atoi(m[2]) + for _, part := range strings.Split(spec, ",") { + m := portRe.FindStringSubmatch(strings.TrimSpace(part)) + if m == nil { + return nil, fmt.Errorf("invalid port spec: %q", part) + } + lo, err := strconv.Atoi(m[1]) if err != nil { - return nil, fmt.Errorf("invalid port spec: %q", spec) + return nil, fmt.Errorf("invalid port spec: %q", part) + } + hi := lo + if m[2] != "" { + hi, err = strconv.Atoi(m[2]) + if err != nil { + return nil, fmt.Errorf("invalid port spec: %q", part) + } + } + if lo > hi || lo < 0 || hi > 65535 { + return nil, fmt.Errorf("invalid port range: %q", part) + } + for p := lo; p <= hi; p++ { + set[uint16(p)] = struct{}{} } - } - if lo > hi || lo < 0 || hi > 65535 { - return nil, fmt.Errorf("invalid port range: %q", spec) - } - for p := lo; p <= hi; p++ { - set[uint16(p)] = struct{}{} } } out := make([]uint16, 0, len(set)) diff --git a/go/internal/policy/spec_test.go b/go/internal/policy/spec_test.go index 8d205dd..eb3b6db 100644 --- a/go/internal/policy/spec_test.go +++ b/go/internal/policy/spec_test.go @@ -51,10 +51,13 @@ func TestParsePorts(t *testing.T) { {[]string{"8000-8002"}, []uint16{8000, 8001, 8002}, false}, {[]string{"443", "80", "443"}, []uint16{80, 443}, false}, {[]string{"3000-3001", "3001-3002"}, []uint16{3000, 3001, 3002}, false}, + {[]string{"8080,9090"}, []uint16{8080, 9090}, false}, + {[]string{"8080,9000-9002", "443"}, []uint16{443, 8080, 9000, 9001, 9002}, false}, {nil, []uint16{}, false}, {[]string{"70000"}, nil, true}, {[]string{"10-5"}, nil, true}, {[]string{"x"}, nil, true}, + {[]string{"8080,"}, nil, true}, } for _, c := range cases { got, err := ParsePorts(c.in) diff --git a/go/sandbox.go b/go/sandbox.go index 50a3c3c..553c111 100644 --- a/go/sandbox.go +++ b/go/sandbox.go @@ -71,14 +71,21 @@ type Sandbox struct { // Network. // // NetAllow entries are outbound endpoint rules. The bare form is TCP - // ("api.openai.com:443", "github.com:22,443", ":53"); scheme prefixes opt - // other protocols in ("tcp://", "udp://host:port", "udp://*:*", - // "icmp://host", "icmp://*"). Empty denies all outbound. + // ("api.openai.com:443", "github.com:22,443", ":53"); a target may be a + // host, IP, or CIDR ("10.0.0.0/8:443", "[2606:4700::/32]:443"), and + // scheme prefixes opt other protocols in ("tcp://", "udp://host:port", + // "udp://*", "icmp://host", "icmp://*"). Empty denies all outbound. NetAllow []string - // NetBind lists TCP ports the sandbox may bind. Each entry is a single - // port ("8080") or an inclusive range ("3000-3010"). Empty denies all. - NetBind []string - PortRemap bool // transparent per-sandbox TCP port virtualization + // NetDeny is the inverse of NetAllow: default-allow networking, block + // these targets. Same grammar as NetAllow except targets must be a + // literal IP/CIDR or "*" (no hostnames; use HTTPDeny for domains). + // Mutually exclusive with NetAllow. + NetDeny []string + // NetAllowBind lists TCP ports the sandbox may bind/listen on + // (default-deny). Each entry is a comma-separated list of single ports + // or inclusive "lo-hi" ranges ("8080", "3000-3010", "8080,9000-9005"). + NetAllowBind []string + PortRemap bool // transparent per-sandbox TCP port virtualization // HTTP ACL (method + host + path rules via a transparent proxy). HTTPAllow []string // allow rules, "METHOD host/path" diff --git a/go/sandlock_linux.go b/go/sandlock_linux.go index 37ed41b..f903179 100644 --- a/go/sandlock_linux.go +++ b/go/sandlock_linux.go @@ -40,7 +40,7 @@ func cbool(v bool) C.bool { return C.bool(v) } func (s *Sandbox) validateStrings() error { groups := [][]string{ s.FSReadable, s.FSWritable, s.FSDenied, - s.NetAllow, s.NetBind, + s.NetAllow, s.NetDeny, s.NetAllowBind, s.HTTPAllow, s.HTTPDeny, s.ExtraAllowSyscalls, s.ExtraDenySyscalls, {s.Workdir, s.Cwd, s.Chroot, s.FSStorage, s.MaxMemory, s.MaxDisk, @@ -125,14 +125,19 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { return C.sandlock_sandbox_builder_net_allow(b, c) }, spec) } - if len(s.NetBind) > 0 { - ports, err := policy.ParsePorts(s.NetBind) + for _, spec := range s.NetDeny { + str(func(b *C.sandlock_builder_t, c *C.char) *C.sandlock_builder_t { + return C.sandlock_sandbox_builder_net_deny(b, c) + }, spec) + } + if len(s.NetAllowBind) > 0 { + ports, err := policy.ParsePorts(s.NetAllowBind) if err != nil { freeBuilderViaBuild(b) return nil, err } for _, p := range ports { - b = C.sandlock_sandbox_builder_net_bind_port(b, C.uint16_t(p)) + b = C.sandlock_sandbox_builder_net_allow_bind_port(b, C.uint16_t(p)) } } if s.PortRemap { From 632e31cdfca8ab135b7cb6d4dde713a6e358aadf Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sun, 7 Jun 2026 17:22:08 -0700 Subject: [PATCH 24/24] net: add --net-deny-bind (default-allow bind denylist) across CLI/FFI/Python/Go Signed-off-by: Cong Wang --- README.md | 5 ++ crates/sandlock-cli/src/main.rs | 6 +- crates/sandlock-core/src/context.rs | 1 + crates/sandlock-core/src/landlock.rs | 34 +++++++- crates/sandlock-core/src/network.rs | 2 +- crates/sandlock-core/src/port_remap.rs | 14 +++ crates/sandlock-core/src/profile.rs | 19 ++++ crates/sandlock-core/src/resource.rs | 1 + crates/sandlock-core/src/sandbox.rs | 86 +++++++++++++++++-- crates/sandlock-core/src/seccomp/dispatch.rs | 3 +- crates/sandlock-core/src/seccomp/notif.rs | 4 + crates/sandlock-core/src/seccomp/state.rs | 5 ++ .../tests/integration/test_network.rs | 63 ++++++++++++++ crates/sandlock-ffi/include/sandlock.h | 7 ++ crates/sandlock-ffi/src/lib.rs | 11 +++ docs/sandbox-reference.md | 3 +- go/README.md | 6 +- go/sandbox.go | 7 +- go/sandlock_linux.go | 12 ++- python/src/sandlock/_profile.py | 3 +- python/src/sandlock/_sdk.py | 5 +- python/src/sandlock/sandbox.py | 19 +++- python/tests/test_profile.py | 6 ++ python/tests/test_sandbox.py | 18 ++++ 24 files changed, 316 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index f5c09cf..ad9809e 100644 --- a/README.md +++ b/README.md @@ -701,6 +701,11 @@ governs server-side `bind()` as a default-deny allowlist. Each value is a comma-separated list of single ports or inclusive `lo-hi` ranges (e.g. `--net-allow-bind 8080,9000-9005`), and the flag repeats. Landlock enforces it (TCP only); `--port-remap` adds on-behalf virtualization for binding. +`--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 +the Landlock `BIND_TCP` hook and enforces the denylist on the on-behalf +seccomp `bind()` path instead. **AF_UNIX sockets** are governed by Landlock's `LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET`, independent from `--net-allow`. diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 8c9ff64..60ce90d 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -347,6 +347,7 @@ async fn run_command(args: RunArgs) -> Result { b = b.net_deny(format_net_rule(rule)); } for p in &base.net_allow_bind { b = b.net_allow_bind_port(*p); } + for p in &base.net_deny_bind { b = b.net_deny_bind_port(*p); } for rule in &base.http_allow { let s = format!("{} {}{}", rule.method, rule.host, rule.path); b = b.http_allow(&s); @@ -407,6 +408,7 @@ async fn run_command(args: RunArgs) -> Result { for spec in &pb.net_allow { builder = builder.net_allow(spec); } for spec in &pb.net_deny { builder = builder.net_deny(spec); } for spec in &pb.net_allow_bind { builder = builder.net_allow_bind(spec); } + for spec in &pb.net_deny_bind { builder = builder.net_deny_bind(spec); } if let Some(seed) = pb.random_seed { builder = builder.random_seed(seed); } if pb.clean_env { builder = builder.clean_env(true); } if let Some(n) = pb.num_cpus { builder = builder.num_cpus(n); } @@ -660,6 +662,7 @@ fn validate_no_supervisor(args: &RunArgs) -> Result<()> { if !pb.net_allow.is_empty() { bad.push("--net-allow"); } if !pb.net_deny.is_empty() { bad.push("--net-deny"); } if !pb.net_allow_bind.is_empty() { bad.push("--net-allow-bind"); } + if !pb.net_deny_bind.is_empty() { bad.push("--net-deny-bind"); } if !pb.http_allow.is_empty() { bad.push("--http-allow"); } if !pb.http_deny.is_empty() { bad.push("--http-deny"); } if !pb.http_ports.is_empty() { bad.push("--http-port"); } @@ -718,7 +721,8 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> if !profile.fs_denied.is_empty() { bad.push("[filesystem].deny"); } if !profile.net_allow.is_empty() { bad.push("[network].allow"); } if !profile.net_deny.is_empty() { bad.push("[network].deny"); } - if !profile.net_allow_bind.is_empty() { bad.push("[network].bind"); } + if !profile.net_allow_bind.is_empty() { bad.push("[network].allow_bind"); } + if !profile.net_deny_bind.is_empty() { bad.push("[network].deny_bind"); } if profile.port_remap { bad.push("[network].port_remap"); } if !profile.http_allow.is_empty() { bad.push("[http].allow"); } if !profile.http_deny.is_empty() { bad.push("[http].deny"); } diff --git a/crates/sandlock-core/src/context.rs b/crates/sandlock-core/src/context.rs index fa1133a..86fd165 100644 --- a/crates/sandlock-core/src/context.rs +++ b/crates/sandlock-core/src/context.rs @@ -374,6 +374,7 @@ const PORT_REMAP_SYSCALLS: &[i64] = &[ fn needs_network_supervision(policy: &Sandbox) -> bool { !policy.net_allow.is_empty() || !policy.net_deny.is_empty() + || !policy.net_deny_bind.is_empty() || policy.policy_fn.is_some() || !policy.http_allow.is_empty() || !policy.http_deny.is_empty() diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index add070b..2d47e7e 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -312,11 +312,19 @@ pub fn compute_net_mask( .net_allow .iter() .any(|r| r.protocol == Protocol::Tcp && r.all_ports); - let mask = if net_wildcard { + let mut mask = if net_wildcard { LANDLOCK_ACCESS_NET_BIND_TCP } else { LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP }; + // `--net-deny-bind` is default-allow: every TCP bind must reach the + // on-behalf seccomp handler (the bind denylist enforcer), so Landlock + // must not gate BIND_TCP. Drop it from the handled set; the on-behalf + // path becomes the sole bind enforcer. (Mutually exclusive with + // `--net-allow-bind`, so no kernel bind rules are installed either.) + if !sandbox.net_deny_bind.is_empty() { + mask &= !LANDLOCK_ACCESS_NET_BIND_TCP; + } (mask, net_wildcard) } @@ -758,4 +766,28 @@ mod mask_contract_tests { ); assert!(wildcard, "net_deny must set the wildcard flag"); } + + #[test] + fn net_mask_net_deny_bind_drops_bind_tcp() { + // `--net-deny-bind` is default-allow and enforced on the on-behalf + // bind() path, so Landlock must NOT gate BIND_TCP: every TCP bind has + // to reach the supervisor's denylist check. The mask keeps CONNECT_TCP + // (no connect rules here) but drops BIND_TCP. + let pol = ProtectionPolicy::strict_all(); + let sb = Sandbox::builder() + .net_deny_bind("8080") + .build() + .expect("net_deny_bind sandbox builds"); + let (mask, _wildcard) = compute_net_mask(6, &pol, &sb, true); + assert_eq!( + mask & LANDLOCK_ACCESS_NET_BIND_TCP, + 0, + "net_deny_bind must drop BIND_TCP so all TCP binds reach the on-behalf path", + ); + assert_ne!( + mask & LANDLOCK_ACCESS_NET_CONNECT_TCP, + 0, + "net_deny_bind must not affect CONNECT_TCP handling", + ); + } } diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index 300f610..052a35b 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -433,7 +433,7 @@ fn set_port_in_sockaddr(bytes: &mut [u8], port: u16) { /// Returns `None` for protocols sandlock does not gate via `net_allow` /// (raw, SCTP, etc.) — the handler treats those as "no rule applies" /// which collapses to the default-deny path. -fn query_socket_protocol(fd: RawFd) -> Option { +pub(crate) fn query_socket_protocol(fd: RawFd) -> Option { let mut proto: libc::c_int = 0; let mut len: libc::socklen_t = std::mem::size_of::() as libc::socklen_t; let rc = unsafe { diff --git a/crates/sandlock-core/src/port_remap.rs b/crates/sandlock-core/src/port_remap.rs index a8bfb2b..1289c4f 100644 --- a/crates/sandlock-core/src/port_remap.rs +++ b/crates/sandlock-core/src/port_remap.rs @@ -159,6 +159,20 @@ pub(crate) async fn handle_bind( _ => 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) + }; + if denied + && crate::network::query_socket_protocol(dup_fd.as_raw_fd()) + == Some(crate::network::Protocol::Tcp) + { + return NotifAction::Errno(libc::EACCES); + } + // 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/profile.rs b/crates/sandlock-core/src/profile.rs index a05f929..cd434ad 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -92,6 +92,7 @@ pub enum PortSpec { #[serde(deny_unknown_fields, default)] pub struct NetworkSection { pub allow_bind: Vec, + pub deny_bind: Vec, pub allow: Vec, pub deny: Vec, pub port_remap: bool, @@ -185,6 +186,12 @@ pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), Sandlo PortSpec::Spec(s) => b.net_allow_bind(s), }; } + for entry in input.network.deny_bind.iter() { + b = match entry { + PortSpec::Port(p) => b.net_deny_bind_port(*p), + PortSpec::Spec(s) => b.net_deny_bind(s), + }; + } for r in input.network.allow.iter() { b = b.net_allow(r.as_str()); } for r in input.network.deny.iter() { b = b.net_deny(r.as_str()); } if input.network.port_remap { b = b.port_remap(true); } @@ -539,6 +546,18 @@ mod tests { assert!(policy.net_deny.len() > 1); } + #[test] + fn profile_network_deny_bind_parses() { + // Mixed int + range string, same as allow_bind. + let toml = r#" + [network] + deny_bind = [8080, "9000-9002"] + "#; + let (policy, _spec) = parse_profile(toml).unwrap(); + assert_eq!(policy.net_deny_bind, vec![8080, 9000, 9001, 9002]); + assert!(policy.net_allow_bind.is_empty()); + } + #[test] fn isolation_key_is_rejected() { let toml = r#" diff --git a/crates/sandlock-core/src/resource.rs b/crates/sandlock-core/src/resource.rs index dd52790..e5a2974 100644 --- a/crates/sandlock-core/src/resource.rs +++ b/crates/sandlock-core/src/resource.rs @@ -638,6 +638,7 @@ mod tests { max_processes: 0, has_memory_limit: false, has_net_allowlist: false, + has_bind_denylist: false, has_random_seed: false, has_time_start: false, argv_safety_required, diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index f91ff31..19083ad 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -109,7 +109,9 @@ impl TryFrom<&Sandbox> for Confinement { if !sandbox.fs_denied.is_empty() { unsupported.push("fs_denied"); } if !sandbox.extra_deny_syscalls.is_empty() { unsupported.push("extra_deny_syscalls"); } if !sandbox.net_allow.is_empty() { unsupported.push("net_allow"); } + if !sandbox.net_deny.is_empty() { unsupported.push("net_deny"); } if !sandbox.net_allow_bind.is_empty() { unsupported.push("net_allow_bind"); } + if !sandbox.net_deny_bind.is_empty() { unsupported.push("net_deny_bind"); } if sandbox.allows_sysv_ipc() { unsupported.push("extra_allow_syscalls=[\"sysv_ipc\"]"); } if !sandbox.http_allow.is_empty() { unsupported.push("http_allow"); } if !sandbox.http_deny.is_empty() { unsupported.push("http_deny"); } @@ -255,7 +257,13 @@ pub struct Sandbox { /// Parsed `--net-deny` rules (default-allow, IP/CIDR/port denylist). /// Mutually exclusive with `net_allow`. pub net_deny: Vec, + /// `--net-allow-bind`: TCP ports the sandbox may bind (default-deny + /// allowlist, Landlock-enforced). Mutually exclusive with `net_deny_bind`. pub net_allow_bind: Vec, + /// `--net-deny-bind`: TCP ports the sandbox may NOT bind (default-allow + /// denylist, enforced on the on-behalf `bind()` path). Mutually + /// exclusive with `net_allow_bind`. + pub net_deny_bind: Vec, // HTTP ACL pub http_allow: Vec, pub http_deny: Vec, @@ -380,6 +388,7 @@ impl Clone for Sandbox { net_allow: self.net_allow.clone(), net_deny: self.net_deny.clone(), net_allow_bind: self.net_allow_bind.clone(), + net_deny_bind: self.net_deny_bind.clone(), http_allow: self.http_allow.clone(), http_deny: self.http_deny.clone(), http_ports: self.http_ports.clone(), @@ -1398,6 +1407,7 @@ impl Sandbox { || self.policy_fn.is_some() || !self.http_allow.is_empty() || !self.http_deny.is_empty(), + has_bind_denylist: !self.net_deny_bind.is_empty(), has_random_seed: self.random_seed.is_some(), has_time_start: self.time_start.is_some(), argv_safety_required: self.policy_fn.is_some() @@ -1468,6 +1478,7 @@ impl Sandbox { net_state.http_acl_addr = self.rt().http_acl_handle.as_ref().map(|h| h.addr); 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(); if let Some(cb) = self.rt_mut().on_bind.take() { net_state.port_map.on_bind = Some(cb); } @@ -1839,6 +1850,13 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(long = "net-allow-bind", value_name = "PORTS"))] pub net_allow_bind: Vec, + /// `--net-deny-bind`: TCP ports the sandbox may NOT bind/listen on + /// (default-allow denylist; the inverse of `--net-allow-bind`). Same + /// port syntax (comma-separated ports / `lo-hi` ranges). Repeatable. + /// Mutually exclusive with `--net-allow-bind`. + #[cfg_attr(feature = "cli", arg(long = "net-deny-bind", value_name = "PORTS"))] + pub net_deny_bind: Vec, + #[cfg_attr(feature = "cli", arg(long = "http-allow", value_name = "RULE"))] pub http_allow: Vec, @@ -2008,6 +2026,7 @@ impl Clone for SandboxBuilder { net_allow: self.net_allow.clone(), net_deny: self.net_deny.clone(), net_allow_bind: self.net_allow_bind.clone(), + net_deny_bind: self.net_deny_bind.clone(), http_allow: self.http_allow.clone(), http_deny: self.http_deny.clone(), http_ports: self.http_ports.clone(), @@ -2143,6 +2162,22 @@ impl SandboxBuilder { self } + /// Deny binding a single TCP port (default-allow denylist). For + /// comma-separated lists or `lo-hi` ranges, use + /// [`net_deny_bind`](Self::net_deny_bind). + pub fn net_deny_bind_port(mut self, port: u16) -> Self { + self.net_deny_bind.push(port.to_string()); + self + } + + /// Deny binding TCP ports from a spec: a comma-separated list of single + /// ports or inclusive `lo-hi` ranges (e.g. `"8080,9000-9005"`). The + /// inverse of [`net_allow_bind`](Self::net_allow_bind). + pub fn net_deny_bind(mut self, spec: impl Into) -> Self { + self.net_deny_bind.push(spec.into()); + self + } + pub fn http_allow(mut self, rule: &str) -> Self { self.http_allow.push(rule.to_string()); self @@ -2435,6 +2470,16 @@ impl SandboxBuilder { )); } + // Expand bind port specs. --net-allow-bind (default-deny allowlist) + // and --net-deny-bind (default-allow denylist) are contradictory. + let net_allow_bind = parse_bind_ports(&self.net_allow_bind, "--net-allow-bind")?; + let net_deny_bind = parse_bind_ports(&self.net_deny_bind, "--net-deny-bind")?; + if !net_allow_bind.is_empty() && !net_deny_bind.is_empty() { + return Err(SandboxError::Invalid( + "--net-allow-bind and --net-deny-bind are mutually exclusive".into(), + )); + } + crate::http::extend_net_allow_for_http( &mut net_allow, &http_allow, @@ -2451,7 +2496,8 @@ impl SandboxBuilder { protection_policy: self.protection_policy, net_allow, net_deny, - net_allow_bind: parse_bind_ports(&self.net_allow_bind)?, + net_allow_bind, + net_deny_bind, http_allow, http_deny, http_ports, @@ -2505,36 +2551,36 @@ impl SandboxBuilder { /// Expand `--net-allow-bind` specs into a sorted, deduplicated port list. /// Each spec is a comma-separated list of single ports (`8080`) or inclusive /// `lo-hi` ranges (`8000-8010`). Mirrors the Python SDK's `parse_ports`. -fn parse_bind_ports(specs: &[String]) -> Result, SandboxError> { +fn parse_bind_ports(specs: &[String], label: &str) -> Result, SandboxError> { let mut ports: std::collections::BTreeSet = std::collections::BTreeSet::new(); for spec in specs { for part in spec.split(',') { let part = part.trim(); if part.is_empty() { return Err(SandboxError::Invalid(format!( - "--net-allow-bind: empty port in `{}`", - spec + "{}: empty port in `{}`", + label, spec ))); } match part.split_once('-') { Some((lo, hi)) => { let lo: u16 = lo.trim().parse().map_err(|_| { - SandboxError::Invalid(format!("--net-allow-bind: invalid port range `{}`", part)) + SandboxError::Invalid(format!("{}: invalid port range `{}`", label, part)) })?; let hi: u16 = hi.trim().parse().map_err(|_| { - SandboxError::Invalid(format!("--net-allow-bind: invalid port range `{}`", part)) + SandboxError::Invalid(format!("{}: invalid port range `{}`", label, part)) })?; if lo > hi { return Err(SandboxError::Invalid(format!( - "--net-allow-bind: reversed port range `{}` (lo > hi)", - part + "{}: reversed port range `{}` (lo > hi)", + label, part ))); } ports.extend(lo..=hi); } None => { let p: u16 = part.parse().map_err(|_| { - SandboxError::Invalid(format!("--net-allow-bind: invalid port `{}`", part)) + SandboxError::Invalid(format!("{}: invalid port `{}`", label, part)) })?; ports.insert(p); } @@ -2759,6 +2805,28 @@ mod tests { assert!(err.is_err()); } + #[test] + fn builder_net_deny_bind_comma_and_ranges() { + // Same port grammar as --net-allow-bind (comma lists + lo-hi ranges). + let policy = Sandbox::builder() + .net_deny_bind("8080,9000-9002") + .net_deny_bind_port(443) + .build() + .unwrap(); + assert_eq!(policy.net_deny_bind, vec![443, 8080, 9000, 9001, 9002]); + assert!(policy.net_allow_bind.is_empty()); + } + + #[test] + fn builder_rejects_allow_bind_and_deny_bind_together() { + let err = Sandbox::builder() + .net_allow_bind("8080") + .net_deny_bind("9090") + .build(); + assert!(err.is_err()); + assert!(format!("{}", err.unwrap_err()).contains("mutually exclusive")); + } + #[test] fn builder_net_deny_rejects_hostname() { let err = Sandbox::builder().net_deny("evil.com:443").build(); diff --git a/crates/sandlock-core/src/seccomp/dispatch.rs b/crates/sandlock-core/src/seccomp/dispatch.rs index e28c62a..acf994b 100644 --- a/crates/sandlock-core/src/seccomp/dispatch.rs +++ b/crates/sandlock-core/src/seccomp/dispatch.rs @@ -668,7 +668,7 @@ pub(crate) fn build_dispatch_table( // ------------------------------------------------------------------ // Bind — on-behalf // ------------------------------------------------------------------ - if policy.port_remap || policy.has_net_allowlist { + if policy.port_remap || policy.has_net_allowlist || policy.has_bind_denylist { let __sup = Arc::clone(ctx); table.register(libc::SYS_bind, move |cx: &HandlerCtx| { let notif = cx.notif; @@ -1084,6 +1084,7 @@ mod handler_tests { max_processes: 0, has_memory_limit: false, has_net_allowlist: false, + has_bind_denylist: false, has_random_seed: false, has_time_start: false, time_offset: 0, diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index fb47cfc..f3c9c29 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -405,6 +405,10 @@ pub struct NotifPolicy { pub max_processes: u32, pub has_memory_limit: bool, pub has_net_allowlist: bool, + /// `--net-deny-bind` is active: trap `bind()` and register the on-behalf + /// handler so denied TCP ports can be refused (independent of the + /// connect-side `has_net_allowlist`). + pub has_bind_denylist: bool, pub has_random_seed: bool, pub has_time_start: bool, /// Argv-safety gate: the supervisor must freeze every task that diff --git a/crates/sandlock-core/src/seccomp/state.rs b/crates/sandlock-core/src/seccomp/state.rs index b0ab288..0478d9d 100644 --- a/crates/sandlock-core/src/seccomp/state.rs +++ b/crates/sandlock-core/src/seccomp/state.rs @@ -310,6 +310,10 @@ pub struct NetworkState { pub icmp_policy: crate::seccomp::notif::NetworkPolicy, /// Port binding and remapping tracker. pub port_map: crate::port_remap::PortMap, + /// `--net-deny-bind`: TCP ports the sandbox may NOT bind (default-allow + /// 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, /// 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>>>, @@ -328,6 +332,7 @@ impl NetworkState { udp_policy: crate::seccomp::notif::NetworkPolicy::Unrestricted, icmp_policy: crate::seccomp::notif::NetworkPolicy::Unrestricted, port_map: crate::port_remap::PortMap::new(), + bind_deny_ports: HashSet::new(), 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 4dedda2..c19d4b7 100644 --- a/crates/sandlock-core/tests/integration/test_network.rs +++ b/crates/sandlock-core/tests/integration/test_network.rs @@ -593,3 +593,66 @@ async fn test_net_allow_wildcard_host_only() { srv.join().unwrap(); let _ = std::fs::remove_file(&out); } + +/// `--net-deny-bind` is default-allow: a denied TCP port fails to bind with +/// EACCES, other TCP ports bind fine, and UDP on the denied port is +/// unaffected (the deny is TCP-only, mirroring --net-allow-bind). +#[tokio::test] +async fn test_net_deny_bind_blocks_tcp_only() { + fn free_port() -> u16 { + TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port() + } + let denied = free_port(); + let mut allowed = free_port(); + while allowed == denied { + allowed = free_port(); + } + let out = temp_file("denybind"); + + // A `udp://*` egress rule lets the child create UDP sockets, so the + // TCP-only nature of the bind denylist can be observed below. (net_allow + // is egress-only and orthogonal to the bind denylist.) + let policy = base_policy() + .net_allow("udp://*") + .net_deny_bind_port(denied) + .build() + .unwrap(); + + let script = format!(concat!( + "import socket, json\n", + "res = {{}}\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', {denied}))\n", + " res['tcp_denied'] = 'bound'\n", + "except PermissionError:\n", + " res['tcp_denied'] = 'eacces'\n", + "s.close()\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', {allowed}))\n", + " res['tcp_allowed'] = 'ok'\n", + "except OSError as e:\n", + " res['tcp_allowed'] = 'err:%d' % e.errno\n", + "s.close()\n", + "u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n", + "try:\n", + " u.bind(('127.0.0.1', {denied}))\n", + " res['udp_denied'] = 'ok'\n", + "except OSError as e:\n", + " res['udp_denied'] = 'err:%d' % e.errno\n", + "u.close()\n", + "open('{out}', 'w').write(json.dumps(res))\n", + ), denied = denied, allowed = allowed, out = out.display()); + + let result = policy.clone().with_name("test") + .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!(content.contains("\"tcp_denied\": \"eacces\""), "denied TCP bind must fail with EACCES; got: {content}"); + assert!(content.contains("\"tcp_allowed\": \"ok\""), "non-denied TCP bind must succeed; got: {content}"); + assert!(content.contains("\"udp_denied\": \"ok\""), "UDP on the denied port must be allowed (TCP-only); got: {content}"); +} diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 470608d..c6ebf2d 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -405,6 +405,13 @@ sandlock_builder_t *sandlock_sandbox_builder_net_deny(sandlock_builder_t *b, con sandlock_builder_t *sandlock_sandbox_builder_net_allow_bind_port(sandlock_builder_t *b, uint16_t port); +/** + * # Safety + * `b` must be a valid builder pointer. + */ +sandlock_builder_t *sandlock_sandbox_builder_net_deny_bind_port(sandlock_builder_t *b, + uint16_t port); + /** * # Safety * `b` must be a valid builder pointer. diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index 5d204f7..2a79185 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -349,6 +349,17 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_net_allow_bind_port( Box::into_raw(Box::new(builder.net_allow_bind_port(port))) } +/// # Safety +/// `b` must be a valid builder pointer. +#[no_mangle] +pub unsafe extern "C" fn sandlock_sandbox_builder_net_deny_bind_port( + b: *mut SandboxBuilder, port: u16, +) -> *mut SandboxBuilder { + if b.is_null() { return b; } + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.net_deny_bind_port(port))) +} + /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] diff --git a/docs/sandbox-reference.md b/docs/sandbox-reference.md index 7cbd103..a52781a 100644 --- a/docs/sandbox-reference.md +++ b/docs/sandbox-reference.md @@ -215,7 +215,8 @@ Rule shapes: | Python | TOML | Type | Default | Description | | ------------ | ------------ | ----------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `net_allow` | `allow` | `Sequence[str]` | `()` | Outbound endpoint allowlist. Empty list denies all outbound. | -| `net_allow_bind` | `allow_bind` | `Sequence[int \| str]` | `()` | TCP ports the sandbox may bind/listen on (default-deny allowlist). Each entry is a port or a `"lo-hi"` range. Landlock ABI v4+ (TCP only; UDP `bind()` is not separately gated). | +| `net_allow_bind` | `allow_bind` | `Sequence[int \| str]` | `()` | TCP ports the sandbox may bind/listen on (default-deny allowlist). Each entry is a port or a `"lo-hi"` range. Landlock ABI v4+ (TCP only; UDP `bind()` is not separately gated). Mutually exclusive with `net_deny_bind`. | +| `net_deny_bind` | `deny_bind` | `Sequence[int \| str]` | `()` | TCP ports the sandbox may NOT bind (default-allow denylist; inverse of `net_allow_bind`). Same port syntax. Enforced on the on-behalf `bind()` path (Landlock `BIND_TCP` is relaxed). Mutually exclusive with `net_allow_bind`. | | `port_remap` | `port_remap` | `bool` | `False` | Enable transparent TCP port virtualization. Each sandbox receives an independent virtual port space; conflicting binds are remapped to unique real ports via `pidfd_getfd`. | Hostnames are resolved once at sandbox creation and pinned via a diff --git a/go/README.md b/go/README.md index a10aa2c..c9c4dd2 100644 --- a/go/README.md +++ b/go/README.md @@ -68,7 +68,7 @@ fresh native policy on each call. | Group | Fields | |---|---| | Filesystem | `FSReadable`, `FSWritable`, `FSDenied`, `Workdir`, `Cwd`, `Chroot`, `FSMount` | -| Network | `NetAllow`, `NetDeny`, `NetAllowBind`, `PortRemap` | +| Network | `NetAllow`, `NetDeny`, `NetAllowBind`, `NetDenyBind`, `PortRemap` | | HTTP ACL | `HTTPAllow`, `HTTPDeny`, `HTTPPorts`, `HTTPCAFile`, `HTTPKeyFile` | | Resources | `MaxMemory`, `MaxDisk`, `MaxProcesses`, `MaxCPU`, `MaxOpenFiles`, `CPUCores`, `NumCPUs`, `GPUDevices` | | Syscalls | `ExtraAllowSyscalls`, `ExtraDenySyscalls` | @@ -84,7 +84,9 @@ prefixes opt other protocols in (`"udp://1.1.1.1:53"`, `"udp://*"`, `"icmp://host"`, `"icmp://*"`). `NetDeny` is the inverse (default-allow denylist, IP/CIDR targets only, mutually exclusive with `NetAllow`). `NetAllowBind` entries are comma-separated single ports or inclusive ranges -(`"8080"`, `"3000-3010"`, `"8080,9000-9005"`). +(`"8080"`, `"3000-3010"`, `"8080,9000-9005"`). `NetDenyBind` is the inverse +(default-allow bind, deny these TCP ports; same syntax, mutually exclusive +with `NetAllowBind`). ### Execution diff --git a/go/sandbox.go b/go/sandbox.go index 553c111..1cca045 100644 --- a/go/sandbox.go +++ b/go/sandbox.go @@ -84,8 +84,13 @@ type Sandbox struct { // NetAllowBind lists TCP ports the sandbox may bind/listen on // (default-deny). Each entry is a comma-separated list of single ports // or inclusive "lo-hi" ranges ("8080", "3000-3010", "8080,9000-9005"). + // Mutually exclusive with NetDenyBind. NetAllowBind []string - PortRemap bool // transparent per-sandbox TCP port virtualization + // NetDenyBind is the inverse of NetAllowBind: default-allow binding, + // deny these TCP ports (same port syntax). Mutually exclusive with + // NetAllowBind. + NetDenyBind []string + PortRemap bool // transparent per-sandbox TCP port virtualization // HTTP ACL (method + host + path rules via a transparent proxy). HTTPAllow []string // allow rules, "METHOD host/path" diff --git a/go/sandlock_linux.go b/go/sandlock_linux.go index f903179..ea1b9b6 100644 --- a/go/sandlock_linux.go +++ b/go/sandlock_linux.go @@ -40,7 +40,7 @@ func cbool(v bool) C.bool { return C.bool(v) } func (s *Sandbox) validateStrings() error { groups := [][]string{ s.FSReadable, s.FSWritable, s.FSDenied, - s.NetAllow, s.NetDeny, s.NetAllowBind, + s.NetAllow, s.NetDeny, s.NetAllowBind, s.NetDenyBind, s.HTTPAllow, s.HTTPDeny, s.ExtraAllowSyscalls, s.ExtraDenySyscalls, {s.Workdir, s.Cwd, s.Chroot, s.FSStorage, s.MaxMemory, s.MaxDisk, @@ -140,6 +140,16 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { b = C.sandlock_sandbox_builder_net_allow_bind_port(b, C.uint16_t(p)) } } + if len(s.NetDenyBind) > 0 { + ports, err := policy.ParsePorts(s.NetDenyBind) + if err != nil { + freeBuilderViaBuild(b) + return nil, err + } + for _, p := range ports { + b = C.sandlock_sandbox_builder_net_deny_bind_port(b, C.uint16_t(p)) + } + } if s.PortRemap { b = C.sandlock_sandbox_builder_port_remap(b, cbool(true)) } diff --git a/python/src/sandlock/_profile.py b/python/src/sandlock/_profile.py index 05e2fcd..7ff28f3 100644 --- a/python/src/sandlock/_profile.py +++ b/python/src/sandlock/_profile.py @@ -14,7 +14,7 @@ [filesystem] → fs_readable (read), fs_writable (write), fs_denied (deny), chroot, fs_mount (mount), on_exit, on_error - [network] → net_allow_bind (allow_bind), net_allow (allow), net_deny (deny), port_remap + [network] → net_allow_bind (allow_bind), net_deny_bind (deny_bind), net_allow (allow), net_deny (deny), port_remap [http] → http_ports (ports), http_allow (allow), http_deny (deny) [syscalls] → extra_allow_syscalls (extra_allow), @@ -84,6 +84,7 @@ }, "network": { "allow_bind": ("net_allow_bind", list), + "deny_bind": ("net_deny_bind", list), "allow": ("net_allow", list), "deny": ("net_deny", list), "port_remap": ("port_remap", bool), diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index e13453a..13d87f8 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -90,6 +90,7 @@ def _builder_fn(name, *extra_args): _b_net_allow = _builder_fn("sandlock_sandbox_builder_net_allow", ctypes.c_char_p) _b_net_deny = _builder_fn("sandlock_sandbox_builder_net_deny", ctypes.c_char_p) _b_net_allow_bind_port = _builder_fn("sandlock_sandbox_builder_net_allow_bind_port", ctypes.c_uint16) +_b_net_deny_bind_port = _builder_fn("sandlock_sandbox_builder_net_deny_bind_port", ctypes.c_uint16) _b_port_remap = _builder_fn("sandlock_sandbox_builder_port_remap", ctypes.c_bool) _b_http_allow = _builder_fn("sandlock_sandbox_builder_http_allow", ctypes.c_char_p) _b_http_deny = _builder_fn("sandlock_sandbox_builder_http_deny", ctypes.c_char_p) @@ -952,7 +953,7 @@ def __del__(self): "workdir", "cwd", "chroot", "fs_mount", "on_exit", "on_error", "max_memory", "max_disk", "max_processes", "max_cpu", "num_cpus", "cpu_cores", "gpu_devices", - "net_allow", "net_deny", "net_allow_bind", + "net_allow", "net_deny", "net_allow_bind", "net_deny_bind", "port_remap", "http_allow", "http_deny", "http_ports", "http_ca", "http_key", "uid", @@ -1041,6 +1042,8 @@ def _build_from_policy(policy: PolicyDataclass): b = _b_net_deny(b, _encode(str(spec))) for port in parse_ports(policy.net_allow_bind) if policy.net_allow_bind else []: b = _b_net_allow_bind_port(b, port) + for port in parse_ports(policy.net_deny_bind) if policy.net_deny_bind else []: + b = _b_net_deny_bind_port(b, port) for rule in (policy.http_allow or []): b = _b_http_allow(b, _encode(str(rule))) diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 782c63f..c22b1d9 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -191,9 +191,16 @@ class Sandbox: # Network — bind allowlist (Landlock ABI v4+, TCP only) net_allow_bind: Sequence[int | str] = field(default_factory=list) - """TCP ports the sandbox may bind. Empty = deny all. Each entry is - a port number or a ``"lo-hi"`` range string. Landlock's port hooks - are TCP-only — UDP bind is not separately gated.""" + """TCP ports the sandbox may bind (default-deny allowlist). Empty = deny + all. Each entry is a port number or a ``"lo-hi"`` range string (or a + comma-separated list). Landlock's port hooks are TCP-only — UDP bind is + not separately gated. Mutually exclusive with :attr:`net_deny_bind`.""" + + net_deny_bind: Sequence[int | str] = field(default_factory=list) + """TCP ports the sandbox may NOT bind (default-allow denylist; the + inverse of :attr:`net_allow_bind`, enforced on the on-behalf ``bind()`` + path). Same port syntax. Empty = no bind denylist. Mutually exclusive + with :attr:`net_allow_bind`.""" # HTTP ACL http_allow: Sequence[str] = field(default_factory=list) @@ -419,9 +426,13 @@ def _ensure_native(self): # ------------------------------------------------------------------ def bind_ports(self) -> list[int]: - """Return parsed bind port list, or empty if unrestricted.""" + """Return parsed allow-bind port list, or empty if unrestricted.""" return parse_ports(self.net_allow_bind) if self.net_allow_bind else [] + def deny_bind_ports(self) -> list[int]: + """Return parsed deny-bind port list, or empty if none.""" + return parse_ports(self.net_deny_bind) if self.net_deny_bind else [] + def memory_bytes(self) -> int | None: """Return max_memory as bytes, or None if unset.""" if self.max_memory is None: diff --git a/python/tests/test_profile.py b/python/tests/test_profile.py index ceea9db..db8a059 100644 --- a/python/tests/test_profile.py +++ b/python/tests/test_profile.py @@ -100,6 +100,12 @@ def test_network_deny_section(self): }) assert list(p.net_deny) == ["10.0.0.0/8", "169.254.169.254:80"] + def test_network_deny_bind_section(self): + p = policy_from_dict({ + "network": {"deny_bind": [8080, "9000-9002"]}, + }) + assert p.deny_bind_ports() == [8080, 9000, 9001, 9002] + def test_http_section(self): p = policy_from_dict({ "http": { diff --git a/python/tests/test_sandbox.py b/python/tests/test_sandbox.py index 711b2c8..836844f 100644 --- a/python/tests/test_sandbox.py +++ b/python/tests/test_sandbox.py @@ -107,6 +107,24 @@ def test_net_allow_and_net_deny_mutually_exclusive(self): ).run(["echo", "ok"]) +class TestNetDenyBind: + """`net_deny_bind` wired through the FFI: default-allow bind with a TCP + port denylist, mutually exclusive with `net_allow_bind`.""" + + def test_net_deny_bind_builds_and_runs(self): + result = _policy(net_deny_bind=["8080,9000-9002", 443]).run(["echo", "ok"]) + assert result.success + assert result.stdout.strip() == b"ok" + + def test_deny_bind_ports_expands(self): + sb = _policy(net_deny_bind=["8080,9000-9002", 443]) + assert sb.deny_bind_ports() == [443, 8080, 9000, 9001, 9002] + + def test_allow_bind_and_deny_bind_mutually_exclusive(self): + with pytest.raises(RuntimeError, match="mutually exclusive"): + _policy(net_allow_bind=[8080], net_deny_bind=[9090]).run(["echo", "ok"]) + + class TestSandlockRunCAbiMultiThreaded: """Regression for issue #47 covering only the C ABI ``sandlock_run`` path.