From 3c547c5b4e3e166ebc6b1a798bd4931fe11a80df Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Sat, 11 Jul 2026 22:12:38 -0700 Subject: [PATCH] feat(net): apply scheme-less --net-allow/--net-deny rules to both TCP and UDP Signed-off-by: Cong Wang --- README.md | 35 +- crates/sandlock-cli/src/main.rs | 53 +-- crates/sandlock-core/src/network/rules.rs | 390 +++++++++++------- crates/sandlock-core/src/sandbox.rs | 6 +- crates/sandlock-core/src/sandbox/builder.rs | 32 +- crates/sandlock-core/src/sandbox/tests.rs | 3 +- crates/sandlock-core/src/seccomp/state.rs | 5 +- .../tests/integration/test_policy.rs | 12 +- docs/sandbox-reference.md | 9 +- python/src/sandlock/_sdk.py | 4 +- python/src/sandlock/sandbox.py | 20 +- 11 files changed, 351 insertions(+), 218 deletions(-) diff --git a/README.md b/README.md index c05266a5..5556ef4c 100644 --- a/README.md +++ b/README.md @@ -135,14 +135,15 @@ sandlock run --net-allow github.com -r /usr -r /lib -r /etc -- ssh user@github.c 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://*' \ +# Unrestricted outbound: `*` opens any host and any port over both TCP +# and UDP (`:*` / `*:*` are equivalent). ICMP still needs `icmp://`. +sandlock run --net-allow '*' \ -r /usr -r /lib -r /etc -- ./client -# UDP — scheme prefix gates the protocol and scopes the destination -# (e.g. DNS to 1.1.1.1, plus TCP HTTPS to anywhere) -sandlock run --net-allow udp://1.1.1.1:53 --net-allow :443 \ +# Scheme prefix: a spec with no scheme covers TCP and UDP; `tcp://` or +# `udp://` pins one protocol (e.g. UDP DNS to 1.1.1.1 only, plus +# TCP-only HTTPS to anywhere) +sandlock run --net-allow udp://1.1.1.1:53 --net-allow tcp://:443 \ -r /usr -r /lib -r /etc -- ./client # Ping — kernel ping socket (SOCK_DGRAM) gated by net.ipv4.ping_group_range @@ -623,12 +624,16 @@ Outbound traffic is gated by an endpoint list naming 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) + scheme none = tcp + udp · tcp:// · 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 spec with no scheme applies to both TCP and UDP (it expands to one +rule per protocol at parse time); a scheme pins the spec to that one +protocol. ICMP is never implied and always needs `icmp://`. + 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. @@ -639,7 +644,8 @@ and port (port is N/A for ICMP). **Protocol gating** falls out of rule presence per scheme: - * No UDP rule → UDP socket creation is denied at the seccomp layer. + * No UDP rule → UDP socket creation is denied at the seccomp layer + (a scheme-less rule counts for both TCP and UDP). * No ICMP rule → kernel ping socket creation (SOCK_DGRAM + IPPROTO_ICMP) is denied at the seccomp layer. * Raw ICMP (SOCK_RAW + IPPROTO_ICMP) is **never exposed** — packet @@ -652,8 +658,8 @@ and port (port is N/A for ICMP). **Defaults.** With no `--net-allow` and no HTTP ACL flags, Landlock 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://*'`. +For unrestricted TCP and UDP egress, opt in explicitly with +`--net-allow '*'`; ICMP needs its own `--net-allow 'icmp://*'`. **Denylist (`--net-deny`).** The inverse of the allowlist: networking is default-allow and the listed targets are blocked. It uses the same @@ -662,11 +668,12 @@ 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) ---net-deny 169.254.169.254:80 # one IP, one port +--net-deny 10.0.0.0/8 # all ports on a CIDR (TCP and UDP) +--net-deny 169.254.169.254:80 # one IP, one port (TCP and UDP) --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 '*' # any IP, all ports (TCP and UDP) +--net-deny 'udp://192.168.0.0/16' # UDP only, to a CIDR +--net-deny 'tcp://10.0.0.1:22' # TCP only, one IP and port ``` **Resolution.** Only hostname targets touch DNS: they are resolved once diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 1838c8f8..b335ad95 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -818,9 +818,11 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> /// 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 `:*`. +/// builder. Allow and deny share one grammar. The scheme is always +/// rendered: a scheme-less spec parses as a TCP + UDP pair, so a +/// single-protocol rule must carry its scheme to round-trip exactly. +/// IPv6 is 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 { @@ -839,7 +841,7 @@ fn format_net_rule(rule: &sandlock_core::sandbox::NetRule) -> String { match rule.protocol { Protocol::Icmp => format!("icmp://{}", target), proto => { - let scheme = if matches!(proto, Protocol::Udp) { "udp://" } else { "" }; + let scheme = if matches!(proto, Protocol::Udp) { "udp://" } else { "tcp://" }; if rule.all_ports { format!("{}{}", scheme, target) } else { @@ -880,44 +882,49 @@ mod render_tests { #[test] fn render_allow_drops_redundant_all_ports_star() { - let r = NetRule::parse_allow("udp://*:*").unwrap(); - assert_eq!(format_net_rule(&r), "udp://*"); + let r = &NetRule::parse_allow("udp://*:*").unwrap()[0]; + assert_eq!(format_net_rule(r), "udp://*"); } #[test] - fn render_allow_any_ip_all_ports_tcp_is_bare_star() { - let r = NetRule::parse_allow(":*").unwrap(); - assert_eq!(format_net_rule(&r), "*"); + fn render_allow_any_ip_all_ports_schemeless_pair() { + // `:*` expands to a TCP + UDP pair; each rule renders its scheme + // so the pair round-trips without widening. + let rules = NetRule::parse_allow(":*").unwrap(); + let rendered: Vec = rules.iter().map(format_net_rule).collect(); + assert_eq!(rendered, vec!["tcp://*", "udp://*"]); } #[test] fn render_allow_host_ports() { - let r = NetRule::parse_allow("example.com:443").unwrap(); - assert_eq!(format_net_rule(&r), "example.com:443"); + let rules = NetRule::parse_allow("example.com:443").unwrap(); + assert_eq!(format_net_rule(&rules[0]), "tcp://example.com:443"); + assert_eq!(format_net_rule(&rules[1]), "udp://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"); + assert_eq!(format_net_rule(&NetRule::parse_allow("10.0.0.0/8:80").unwrap()[0]), "tcp://10.0.0.0/8:80"); + assert_eq!(format_net_rule(&NetRule::parse_deny("10.0.0.0/8").unwrap()[0]), "tcp://10.0.0.0/8"); + assert_eq!(format_net_rule(&NetRule::parse_allow("[::1]:443").unwrap()[0]), "tcp://[::1]:443"); + assert_eq!(format_net_rule(&NetRule::parse_allow("::1").unwrap()[0]), "tcp://::1"); } #[test] 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", + "tcp://*", "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}"); + for r in &NetRule::parse_allow(spec).unwrap() { + let rendered = format_net_rule(r); + // A rendered rule always carries its scheme, so it must + // reparse to exactly one rule equal to the original. + let reparsed = NetRule::parse_allow(&rendered).unwrap(); + assert_eq!(reparsed.len(), 1, "rendered `{rendered}` from {spec} not single"); + assert_eq!(*r, reparsed[0], "round-trip mismatch for {spec} via `{rendered}`"); + } } } } diff --git a/crates/sandlock-core/src/network/rules.rs b/crates/sandlock-core/src/network/rules.rs index c0dca2b9..9d5544ee 100644 --- a/crates/sandlock-core/src/network/rules.rs +++ b/crates/sandlock-core/src/network/rules.rs @@ -115,10 +115,12 @@ pub enum NetTarget { /// 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). +/// +/// A rule always names exactly one concrete protocol: a scheme-less +/// spec expands to a TCP rule plus a UDP rule at parse time. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct NetRule { /// L4 protocol this rule applies to. - #[serde(default = "default_protocol_tcp")] pub protocol: Protocol, /// What the rule targets at the IP layer. pub target: NetTarget, @@ -135,38 +137,38 @@ pub struct NetRule { 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 + /// Parse a `--net-allow` spec into rules. Hostnames are accepted and /// resolved to IPs at sandbox start. Grammar (shared with `--net-deny`): /// /// - `host` / `` / `` / `*` -- all ports (port optional; `*` - /// targets any IP). TCP is the default scheme. + /// targets any IP). /// - `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). - pub fn parse_allow(spec: &str) -> Result { + /// + /// A spec with no scheme applies to both TCP and UDP and expands to + /// one rule per protocol; a scheme restricts the spec to that one + /// protocol. ICMP is never implied: it always needs `icmp://`. + pub fn parse_allow(spec: &str) -> Result, SandboxError> { Self::parse_spec(spec, "--net-allow", true) } - /// Parse a `--net-deny` spec into a rule. Identical grammar to + /// Parse a `--net-deny` spec into rules. 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 { + pub fn parse_deny(spec: &str) -> Result, SandboxError> { 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("://") { + fn parse_spec(spec: &str, label: &str, allow_hosts: bool) -> Result, SandboxError> { + let (scheme, rest) = match spec.split_once("://") { Some((scheme, body)) => { let proto = Protocol::parse(scheme).ok_or_else(|| { SandboxError::Invalid(format!( @@ -174,13 +176,13 @@ impl NetRule { label, scheme, spec )) })?; - (proto, body) + (Some(proto), body) } - None => (Protocol::Tcp, spec), + None => (None, spec), }; // ICMP carries no port: the whole body is the target. - if protocol == Protocol::Icmp { + if scheme == Some(Protocol::Icmp) { if rest.is_empty() { return Err(SandboxError::Invalid(format!( "{}: icmp rule needs a host/IP or `*`, got `{}`", @@ -195,67 +197,64 @@ impl NetRule { label, spec ))); } - return Ok(NetRule { - protocol, + return Ok(vec![NetRule { + protocol: Protocol::Icmp, target: parse_target(rest, label, allow_hosts)?, ports: Vec::new(), all_ports: true, - }); + }]); } // 1. Bracketed IPv6 with a port: `[addr]:ports`. - if let Some(stripped) = rest.strip_prefix('[') { + let (target, ports, all_ports) = if let Some(stripped) = rest.strip_prefix('[') { let (inside, port_part) = stripped.rsplit_once("]:").ok_or_else(|| { SandboxError::Invalid(format!("{}: malformed bracketed address in `{}`", label, spec)) })?; let (ports, all_ports) = parse_ports(port_part, label, spec)?; - return Ok(NetRule { - protocol, - target: NetTarget::Cidr(IpCidr::parse(inside)?), - ports, - all_ports, - }); - } - - // An empty body must not silently mean "everything"; require an - // explicit `*` for the any-IP target. - if rest.is_empty() { + (NetTarget::Cidr(IpCidr::parse(inside)?), ports, all_ports) + } else if rest.is_empty() { + // An empty body must not silently mean "everything"; require + // an explicit `*` for the any-IP target. return Err(SandboxError::Invalid(format!( "{}: empty rule in `{}` (use `*` for any host)", label, spec ))); - } - - // 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(NetRule { - protocol, - target: NetTarget::Cidr(cidr), - ports: Vec::new(), - all_ports: true, - }); - } - - // 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), + } else if let Ok(cidr) = IpCidr::parse(rest) { + // 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. + (NetTarget::Cidr(cidr), Vec::new(), true) + } else { + // 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. + let (host_part, port_part) = match rest.rsplit_once(':') { + Some((h, p)) => (h, Some(p)), + None => (rest, None), + }; + let target = parse_target(host_part, label, allow_hosts)?; + let (ports, all_ports) = match port_part { + Some(p) => parse_ports(p, label, spec)?, + None => (Vec::new(), true), + }; + (target, ports, all_ports) }; - let target = parse_target(host_part, label, allow_hosts)?; - let (ports, all_ports) = match port_part { - Some(p) => parse_ports(p, label, spec)?, - None => (Vec::new(), true), + + // A scheme pins one protocol; no scheme covers both port-carrying + // protocols, so the spec expands to a TCP rule plus a UDP rule. + let protocols = match scheme { + Some(p) => vec![p], + None => vec![Protocol::Tcp, Protocol::Udp], }; - Ok(NetRule { - protocol, - target, - ports, - all_ports, - }) + Ok(protocols + .into_iter() + .map(|protocol| NetRule { + protocol, + target: target.clone(), + ports: ports.clone(), + all_ports, + }) + .collect()) } } @@ -324,8 +323,9 @@ fn parse_ports(s: &str, label: &str, full: &str) -> Result<(Vec, bool), San /// L4 protocol that a `NetAllow` rule applies to. /// -/// `Tcp` is the default if a rule has no scheme (the bare `host:port` -/// form). `Udp` and `Icmp` require an explicit scheme. +/// A rule with no scheme (the bare `host:port` form) covers TCP and +/// UDP: it expands to one rule per protocol at parse time. `Icmp` +/// requires an explicit `icmp://` scheme. /// /// `Icmp` is the kernel's unprivileged ping socket /// (`SOCK_DGRAM + IPPROTO_ICMP{,V6}`), gated by `ping_group_range` — @@ -391,8 +391,9 @@ pub struct ResolvedNetAllowSet { pub tcp: ResolvedNetAllow, pub udp: ResolvedNetAllow, pub icmp: ResolvedNetAllow, - /// ` \n` lines from every concrete-host rule across - /// every protocol, in resolution order. Empty when no concrete-host + /// ` \n` lines for every unique concrete host across + /// every protocol, in first-appearance order (each host resolves + /// once, shared by all its rules). Empty when no concrete-host /// rules are present. Combined with the loopback base (or, in chroot /// mode, the image's `/etc/hosts`) by [`compose_virtual_etc_hosts`] /// to build the synthetic file served to the sandbox. @@ -411,13 +412,40 @@ pub async fn resolve_net_allow( rules: &[NetAllow], ) -> io::Result { use crate::seccomp::notif::PortAllow; - let per_proto = |target: Protocol| async move { + + // Resolve each unique hostname once, up front. A scheme-less spec + // expands to a TCP + UDP rule pair naming the same host; one lookup + // per protocol pass could disagree under DNS round-robin, leaving + // the two allowlists pinned to different IPs, and would emit the + // `/etc/hosts` line once per pass. + let mut host_ips: HashMap<&str, Vec> = HashMap::new(); + let mut concrete_host_entries = String::new(); + for rule in rules { + if let NetTarget::Host(host) = &rule.target { + if host_ips.contains_key(host.as_str()) { + continue; + } + let addr = format!("{}:0", host); + let resolved = tokio::net::lookup_host(addr.as_str()).await.map_err(|e| { + io::Error::new( + e.kind(), + format!("failed to resolve host '{}': {}", host, e), + ) + })?; + let ips: Vec = resolved.map(|sa| sa.ip()).collect(); + for ip in &ips { + concrete_host_entries.push_str(&format!("{} {}\n", ip, host)); + } + host_ips.insert(host.as_str(), ips); + } + } + + let per_proto = |target: Protocol| { 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.target { @@ -443,15 +471,7 @@ pub async fn resolve_net_allow( 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( - e.kind(), - format!("failed to resolve host '{}': {}", host, e), - ) - })?; - for socket_addr in resolved { - let ip = socket_addr.ip(); + for &ip in &host_ips[host.as_str()] { if rule.all_ports || target == Protocol::Icmp { per_ip_all_ports.insert(ip); per_ip.entry(ip).or_default(); @@ -461,37 +481,24 @@ pub async fn resolve_net_allow( entry.insert(p); } } - local_etc_hosts.push_str(&format!("{} {}\n", ip, host)); } } } } - Ok::<_, io::Error>(( - ResolvedNetAllow { - per_ip, - per_ip_all_ports, - cidrs, - any_ip_ports, - any_ip_all_ports, - }, - local_etc_hosts, - )) + ResolvedNetAllow { + per_ip, + per_ip_all_ports, + cidrs, + any_ip_ports, + any_ip_all_ports, + } }; - let (tcp, tcp_eh) = per_proto(Protocol::Tcp).await?; - let (udp, udp_eh) = per_proto(Protocol::Udp).await?; - let (icmp, icmp_eh) = per_proto(Protocol::Icmp).await?; - - let mut concrete_host_entries = String::new(); - for chunk in [tcp_eh, udp_eh, icmp_eh] { - concrete_host_entries.push_str(&chunk); - } - Ok(ResolvedNetAllowSet { - tcp, - udp, - icmp, + tcp: per_proto(Protocol::Tcp), + udp: per_proto(Protocol::Udp), + icmp: per_proto(Protocol::Icmp), concrete_host_entries, }) } @@ -621,11 +628,48 @@ pub fn compose_virtual_etc_hosts( mod tests { use super::*; + /// Parse an allow spec that carries an explicit scheme: exactly one + /// rule comes back. + fn allow_one(spec: &str) -> NetRule { + let mut v = NetRule::parse_allow(spec).unwrap(); + assert_eq!(v.len(), 1, "expected a single rule for `{spec}`"); + v.remove(0) + } + + /// Parse a scheme-less allow spec: it expands to a TCP + UDP pair + /// sharing target/ports. Returns the TCP rule after checking the + /// pair is consistent. + fn allow_pair(spec: &str) -> NetRule { + pair(NetRule::parse_allow(spec).unwrap(), spec) + } + + fn deny_one(spec: &str) -> NetRule { + let mut v = NetRule::parse_deny(spec).unwrap(); + assert_eq!(v.len(), 1, "expected a single rule for `{spec}`"); + v.remove(0) + } + + fn deny_pair(spec: &str) -> NetRule { + pair(NetRule::parse_deny(spec).unwrap(), spec) + } + + fn pair(mut v: Vec, spec: &str) -> NetRule { + assert_eq!(v.len(), 2, "expected a TCP+UDP pair for `{spec}`"); + let udp = v.pop().unwrap(); + let tcp = v.pop().unwrap(); + assert_eq!(tcp.protocol, Protocol::Tcp, "spec `{spec}`"); + assert_eq!(udp.protocol, Protocol::Udp, "spec `{spec}`"); + assert_eq!(tcp.target, udp.target, "spec `{spec}`"); + assert_eq!(tcp.ports, udp.ports, "spec `{spec}`"); + assert_eq!(tcp.all_ports, udp.all_ports, "spec `{spec}`"); + tcp + } + // --- NetAllow::parse tests --- #[test] fn netallow_parse_concrete_host_port() { - let r = NetRule::parse_allow("example.com:443").unwrap(); + let r = allow_pair("example.com:443"); assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com")); assert_eq!(r.ports, vec![443]); assert!(!r.all_ports); @@ -633,12 +677,12 @@ mod tests { #[test] fn netallow_parse_any_host_port() { - let r = NetRule::parse_allow(":8080").unwrap(); + let r = allow_pair(":8080"); assert_eq!(r.target, NetTarget::AnyIp); assert_eq!(r.ports, vec![8080]); assert!(!r.all_ports); - let r = NetRule::parse_allow("*:8080").unwrap(); + let r = allow_pair("*:8080"); assert_eq!(r.target, NetTarget::AnyIp); assert_eq!(r.ports, vec![8080]); assert!(!r.all_ports); @@ -646,7 +690,7 @@ mod tests { #[test] fn netallow_parse_multiple_ports() { - let r = NetRule::parse_allow("github.com:22,80,443").unwrap(); + let r = allow_pair("github.com:22,80,443"); assert!(matches!(&r.target, NetTarget::Host(h) if h == "github.com")); assert_eq!(r.ports, vec![22, 80, 443]); assert!(!r.all_ports); @@ -654,7 +698,7 @@ mod tests { #[test] fn netallow_parse_wildcard_any_host_any_port_colon() { - let r = NetRule::parse_allow(":*").unwrap(); + let r = allow_pair(":*"); assert_eq!(r.target, NetTarget::AnyIp); assert!(r.ports.is_empty()); assert!(r.all_ports); @@ -662,7 +706,7 @@ mod tests { #[test] fn netallow_parse_wildcard_any_host_any_port_star() { - let r = NetRule::parse_allow("*:*").unwrap(); + let r = allow_pair("*:*"); assert_eq!(r.target, NetTarget::AnyIp); assert!(r.ports.is_empty()); assert!(r.all_ports); @@ -670,7 +714,7 @@ mod tests { #[test] fn netallow_parse_wildcard_concrete_host_any_port() { - let r = NetRule::parse_allow("example.com:*").unwrap(); + let r = allow_pair("example.com:*"); assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com")); assert!(r.ports.is_empty()); assert!(r.all_ports); @@ -703,7 +747,7 @@ mod tests { fn netallow_bare_host_is_all_ports() { // No port suffix means "all ports" (port optional), symmetric // with the `host:*` form. - let r = NetRule::parse_allow("example.com").unwrap(); + let r = allow_pair("example.com"); assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com")); assert!(r.all_ports); assert!(r.ports.is_empty()); @@ -711,7 +755,7 @@ mod tests { #[test] fn netallow_bare_star_is_any_host_all_ports() { - let r = NetRule::parse_allow("*").unwrap(); + let r = allow_pair("*"); assert_eq!(r.target, NetTarget::AnyIp); assert!(r.all_ports); assert!(r.ports.is_empty()); @@ -727,7 +771,7 @@ mod tests { 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(); + let r = allow_pair("10.0.0.0/8:80"); assert!(matches!(&r.target, NetTarget::Cidr(c) if !c.is_single_host())); assert_eq!(r.ports, vec![80]); assert!(!r.all_ports); @@ -737,15 +781,15 @@ mod tests { 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(); + let r = allow_pair("::1"); 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(); + let r = allow_pair("[::1]:443"); 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(); + let r = allow_pair("fc00::/7"); assert!(matches!(&r.target, NetTarget::Cidr(c) if !c.is_single_host())); assert!(r.all_ports); } @@ -768,7 +812,7 @@ mod tests { 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 = NetRule::parse_allow(":*,*").unwrap(); + let r = allow_pair(":*,*"); assert!(r.all_ports); assert!(r.ports.is_empty()); } @@ -776,14 +820,16 @@ mod tests { // --- Protocol scheme prefix tests --- #[test] - fn netallow_bare_form_defaults_to_tcp() { - let r = NetRule::parse_allow("example.com:443").unwrap(); + fn netallow_schemeless_expands_to_tcp_and_udp() { + // Issue #132: a spec with no scheme covers both port-carrying + // protocols. `allow_pair` asserts the TCP+UDP pair shape. + let r = allow_pair("example.com:443"); assert_eq!(r.protocol, Protocol::Tcp); } #[test] - fn netallow_explicit_tcp_scheme() { - let r = NetRule::parse_allow("tcp://example.com:443").unwrap(); + fn netallow_explicit_tcp_scheme_is_tcp_only() { + let r = allow_one("tcp://example.com:443"); assert_eq!(r.protocol, Protocol::Tcp); assert!(matches!(&r.target, NetTarget::Host(h) if h == "example.com")); assert_eq!(r.ports, vec![443]); @@ -791,7 +837,7 @@ mod tests { #[test] fn netallow_udp_scheme_with_host_port() { - let r = NetRule::parse_allow("udp://1.1.1.1:53").unwrap(); + let r = allow_one("udp://1.1.1.1:53"); assert_eq!(r.protocol, Protocol::Udp); // An IP literal becomes a single-host CIDR target (no DNS). let one: std::net::IpAddr = "1.1.1.1".parse().unwrap(); @@ -802,7 +848,7 @@ mod tests { #[test] fn netallow_udp_wildcard_any_anywhere() { // The "any UDP" gate, equivalent to the old `allow_udp = true`. - let r = NetRule::parse_allow("udp://*:*").unwrap(); + let r = allow_one("udp://*:*"); assert_eq!(r.protocol, Protocol::Udp); assert_eq!(r.target, NetTarget::AnyIp); assert!(r.all_ports); @@ -810,7 +856,7 @@ mod tests { #[test] fn netallow_icmp_scheme_with_host() { - let r = NetRule::parse_allow("icmp://github.com").unwrap(); + let r = allow_one("icmp://github.com"); assert_eq!(r.protocol, Protocol::Icmp); assert!(matches!(&r.target, NetTarget::Host(h) if h == "github.com")); assert!(r.ports.is_empty()); @@ -822,7 +868,7 @@ mod tests { fn netallow_icmp_wildcard() { // The "any ICMP echo" gate, equivalent to the old // `allow_icmp = true` for the SOCK_DGRAM path. - let r = NetRule::parse_allow("icmp://*").unwrap(); + let r = allow_one("icmp://*"); assert_eq!(r.protocol, Protocol::Icmp); assert_eq!(r.target, NetTarget::AnyIp); } @@ -1193,16 +1239,17 @@ mod tests { // --- NetDeny::parse tests --- #[test] - fn netdeny_bare_cidr_is_all_ports_tcp() { - let rule = NetRule::parse_deny("10.0.0.0/8").unwrap(); - assert_eq!(rule.protocol, Protocol::Tcp); + fn netdeny_bare_cidr_is_all_ports_tcp_and_udp() { + // A scheme-less deny covers both protocols (issue #132): a + // blocked CIDR must not stay reachable over UDP. + let rule = deny_pair("10.0.0.0/8"); assert!(matches!(rule.target, NetTarget::Cidr(_))); assert!(rule.all_ports); } #[test] fn netdeny_bare_ip_is_host_route_all_ports() { - let rule = NetRule::parse_deny("169.254.169.254").unwrap(); + let rule = deny_pair("169.254.169.254"); match &rule.target { NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 32), _ => panic!("expected cidr"), @@ -1212,28 +1259,28 @@ mod tests { #[test] fn netdeny_cidr_with_port() { - let rule = NetRule::parse_deny("10.0.0.0/8:443").unwrap(); + let rule = deny_pair("10.0.0.0/8:443"); assert_eq!(rule.ports, vec![443]); assert!(!rule.all_ports); } #[test] fn netdeny_any_ip_port() { - let rule = NetRule::parse_deny(":25").unwrap(); + let rule = deny_pair(":25"); assert!(matches!(rule.target, NetTarget::AnyIp)); assert_eq!(rule.ports, vec![25]); } #[test] fn netdeny_udp_scheme() { - let rule = NetRule::parse_deny("udp://192.168.0.0/16:53").unwrap(); + let rule = deny_one("udp://192.168.0.0/16:53"); assert_eq!(rule.protocol, Protocol::Udp); assert_eq!(rule.ports, vec![53]); } #[test] fn netdeny_ipv6_bracket_port() { - let rule = NetRule::parse_deny("[::1]:443").unwrap(); + let rule = deny_pair("[::1]:443"); assert_eq!(rule.ports, vec![443]); match &rule.target { NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), @@ -1249,7 +1296,7 @@ mod tests { #[test] fn netdeny_bare_ipv6_address_all_ports() { - let rule = NetRule::parse_deny("::1").unwrap(); + let rule = deny_pair("::1"); assert!(rule.all_ports); match &rule.target { NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), @@ -1259,7 +1306,7 @@ mod tests { #[test] fn netdeny_bare_ipv6_cidr_all_ports() { - let rule = NetRule::parse_deny("fc00::/7").unwrap(); + let rule = deny_pair("fc00::/7"); assert!(rule.all_ports); let ula: std::net::IpAddr = "fd00::1".parse().unwrap(); assert!(matches!(&rule.target, NetTarget::Cidr(c) if c.contains(ula))); @@ -1273,9 +1320,8 @@ mod tests { #[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 = NetRule::parse_deny("*").unwrap(); - assert_eq!(rule.protocol, Protocol::Tcp); + // symmetric with a bare IP/CIDR), covering TCP and UDP. + let rule = deny_pair("*"); assert!(matches!(rule.target, NetTarget::AnyIp)); assert!(rule.all_ports); assert!(rule.ports.is_empty()); @@ -1283,7 +1329,7 @@ mod tests { #[test] fn netdeny_udp_bare_star_all_ports() { - let rule = NetRule::parse_deny("udp://*").unwrap(); + let rule = deny_one("udp://*"); assert_eq!(rule.protocol, Protocol::Udp); assert!(matches!(rule.target, NetTarget::AnyIp)); assert!(rule.all_ports); @@ -1298,20 +1344,82 @@ mod tests { // --- resolve_net_deny tests --- + #[test] + fn resolve_net_deny_schemeless_covers_tcp_and_udp() { + let rules = NetRule::parse_deny("10.0.0.0/8").unwrap(); + let set = resolve_net_deny(&rules); + // A scheme-less deny closes both L4 paths to the CIDR; ICMP has + // no rule and stays allow-all. + assert!(!set.tcp.allows("10.0.0.1".parse().unwrap(), 443)); + assert!(!set.udp.allows("10.0.0.1".parse().unwrap(), 443)); + assert!(set.icmp.allows("10.0.0.1".parse().unwrap(), 0)); + } + #[test] fn resolve_net_deny_groups_per_protocol() { - 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). + let rules = NetRule::parse_deny("tcp://10.0.0.0/8").unwrap(); + let set = resolve_net_deny(&rules); + // An explicit tcp:// deny leaves UDP/ICMP unaffected (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_any_ip_port() { - 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)); + let rules = NetRule::parse_deny(":25").unwrap(); + let set = resolve_net_deny(&rules); + for policy in [&set.tcp, &set.udp] { + assert!(!policy.allows("8.8.8.8".parse().unwrap(), 25)); + assert!(policy.allows("8.8.8.8".parse().unwrap(), 80)); + } + } + + #[tokio::test] + async fn resolve_net_allow_schemeless_star_unrestricts_tcp_and_udp() { + // Issue #132: `--net-allow '*'` alone now grants full TCP and + // UDP egress; ICMP still requires an explicit `icmp://` rule. + let rules = NetRule::parse_allow("*").unwrap(); + let resolved = resolve_net_allow(&rules).await.unwrap(); + assert!(resolved.tcp.any_ip_all_ports); + assert!(resolved.udp.any_ip_all_ports); + assert!(!resolved.icmp.any_ip_all_ports); + } + + #[tokio::test] + async fn resolve_net_allow_explicit_tcp_scheme_leaves_udp_closed() { + let rules = NetRule::parse_allow("tcp://*").unwrap(); + let resolved = resolve_net_allow(&rules).await.unwrap(); + assert!(resolved.tcp.any_ip_all_ports); + assert!(!resolved.udp.any_ip_all_ports); + assert!(resolved.udp.any_ip_ports.is_empty()); + assert!(resolved.udp.cidrs.is_empty()); + } + + #[tokio::test] + async fn resolve_net_allow_schemeless_port_populates_both_sets() { + // `:53` grants port 53 over both TCP and UDP, so plain DNS + // works without a separate `udp://` rule. + let rules = NetRule::parse_allow(":53").unwrap(); + let resolved = resolve_net_allow(&rules).await.unwrap(); + assert!(resolved.tcp.any_ip_ports.contains(&53)); + assert!(resolved.udp.any_ip_ports.contains(&53)); + assert!(!resolved.tcp.any_ip_all_ports); + assert!(!resolved.udp.any_ip_all_ports); + } + + #[tokio::test] + async fn resolve_net_allow_schemeless_host_resolves_once() { + // A scheme-less host spec expands to a TCP + UDP rule pair; the + // host must resolve once and share the IP set (independent + // lookups could disagree under DNS round-robin) and each + // /etc/hosts line must appear exactly once. + let rules = NetRule::parse_allow("localhost:443").unwrap(); + let resolved = resolve_net_allow(&rules).await.unwrap(); + let tcp_ips: HashSet<&IpAddr> = resolved.tcp.per_ip.keys().collect(); + let udp_ips: HashSet<&IpAddr> = resolved.udp.per_ip.keys().collect(); + assert_eq!(tcp_ips, udp_ips); + let lines: Vec<&str> = resolved.concrete_host_entries.lines().collect(); + let unique: HashSet<&str> = lines.iter().copied().collect(); + assert_eq!(lines.len(), unique.len(), "duplicate hosts lines: {lines:?}"); } } diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 7773f4b0..84542904 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -341,8 +341,10 @@ pub struct Sandbox { /// /// **Protocol gating falls out of rule presence.** Sandlock denies /// UDP and ICMP socket creation by default; opting in is "list at - /// least one rule for that protocol" (e.g. `udp://*:*` for any UDP, - /// `icmp://*` for any ICMP echo). TCP is always permitted. + /// least one rule for that protocol". Scheme-less specs expand to a + /// TCP + UDP rule pair at parse time, so any of them opts UDP in; + /// ICMP always needs an explicit rule (`icmp://*` for any ICMP + /// echo). TCP is always permitted. /// /// Empty `net_allow` and empty `http_allow`/`http_deny` together /// mean "deny all outbound" (Landlock direct path denies, no diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index efe1f5fc..2c052c62 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -361,13 +361,16 @@ impl SandboxBuilder { } /// Add a network endpoint rule. Spec is `host:port[,port,...]`, - /// `:port`, or `*:port`. Validated at `build()` time so callers - /// receive parse errors via the standard `SandboxBuilder` flow. + /// `:port`, or `*:port`; a spec with no scheme covers both TCP and + /// UDP, while `tcp://`, `udp://`, or `icmp://` pins one protocol. + /// Validated at `build()` time so callers receive parse errors via + /// the standard `SandboxBuilder` flow. /// /// Examples: - /// - `.net_allow("api.openai.com:443")`: HTTPS to OpenAI only + /// - `.net_allow("api.openai.com:443")`: port 443 to OpenAI only /// - `.net_allow("github.com:22,443")`: SSH and HTTPS to GitHub /// - `.net_allow(":8080")`: any IP on port 8080 + /// - `.net_allow("tcp://10.0.0.5:22")`: TCP only, no UDP pub fn net_allow(mut self, spec: impl Into) -> Self { self.net_allow.push(spec.into()); self @@ -802,19 +805,18 @@ impl SandboxBuilder { self.http_ports }; - // Parse user-supplied --net-allow specs. - let mut net_allow: Vec = self - .net_allow - .into_iter() - .map(|s| NetRule::parse_allow(&s)) - .collect::>()?; + // Parse user-supplied --net-allow specs. A scheme-less spec + // covers TCP and UDP, so one spec can yield two rules. + let mut net_allow: Vec = Vec::new(); + for s in self.net_allow { + net_allow.extend(NetRule::parse_allow(&s)?); + } - // Parse --net-deny rules (one rule per spec). - let net_deny: Vec = self - .net_deny - .into_iter() - .map(|s| NetRule::parse_deny(&s)) - .collect::>()?; + // Parse --net-deny rules, expanded the same way. + let mut net_deny: Vec = Vec::new(); + for s in self.net_deny { + net_deny.extend(NetRule::parse_deny(&s)?); + } // --net-allow and --net-deny are mutually exclusive. Check the // user-supplied allow count (the original specs), not the post-HTTP diff --git a/crates/sandlock-core/src/sandbox/tests.rs b/crates/sandlock-core/src/sandbox/tests.rs index daa17a57..27f08f20 100644 --- a/crates/sandlock-core/src/sandbox/tests.rs +++ b/crates/sandlock-core/src/sandbox/tests.rs @@ -178,7 +178,8 @@ fn builder_parses_net_deny() { .net_deny("10.0.0.0/8") .build() .unwrap(); - assert_eq!(policy.net_deny.len(), 1); + // Scheme-less deny expands to a TCP rule plus a UDP rule. + assert_eq!(policy.net_deny.len(), 2); } #[test] diff --git a/crates/sandlock-core/src/seccomp/state.rs b/crates/sandlock-core/src/seccomp/state.rs index a409867f..afacfaf0 100644 --- a/crates/sandlock-core/src/seccomp/state.rs +++ b/crates/sandlock-core/src/seccomp/state.rs @@ -300,9 +300,10 @@ impl CowState { /// `NetworkPolicy` per L4 protocol — the on-behalf handler picks the /// matching one based on the dup'd fd's `SO_PROTOCOL`. pub struct NetworkState { - /// Allowlist for TCP destinations (`tcp://...` and bare-form rules). + /// Allowlist for TCP destinations (`tcp://...` and bare-form rules; + /// bare specs expand to a TCP + UDP pair at parse time). pub tcp_policy: crate::seccomp::notif::NetworkPolicy, - /// Allowlist for UDP destinations (`udp://...` rules). + /// Allowlist for UDP destinations (`udp://...` and bare-form rules). pub udp_policy: crate::seccomp::notif::NetworkPolicy, /// Allowlist for ICMP destinations (`icmp://...` rules). ICMP rules /// carry no ports, so every entry uses `PortAllow::Any` and the diff --git a/crates/sandlock-core/tests/integration/test_policy.rs b/crates/sandlock-core/tests/integration/test_policy.rs index 9fde2669..c2b0e9b4 100644 --- a/crates/sandlock-core/tests/integration/test_policy.rs +++ b/crates/sandlock-core/tests/integration/test_policy.rs @@ -37,10 +37,12 @@ fn test_builder_network() { policy.net_allow_bind, sandlock_core::BindPorts::Ports(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")); - assert_eq!(rule.ports, vec![443, 80]); + // A scheme-less spec expands to a TCP rule plus a UDP rule. + assert_eq!(policy.net_allow.len(), 2); + for rule in &policy.net_allow { + assert!(matches!(&rule.target, sandlock_core::sandbox::NetTarget::Host(h) if h == "api.example.com")); + assert_eq!(rule.ports, vec![443, 80]); + } } #[test] @@ -51,7 +53,7 @@ fn test_net_allow_parse_grammar() { 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("foo.com").unwrap().iter().all(|r| r.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 diff --git a/docs/sandbox-reference.md b/docs/sandbox-reference.md index 3a197651..b60eeb37 100644 --- a/docs/sandbox-reference.md +++ b/docs/sandbox-reference.md @@ -282,15 +282,16 @@ entry of `net_allow` is a single rule of the form **protocol, host, port**. Rules are OR'd. An empty `net_allow` denies all outbound traffic. Protocol gating falls out of rule presence: without a UDP rule, UDP socket creation is denied at the seccomp layer; without an -ICMP rule, kernel ping socket creation is denied. Raw ICMP (`SOCK_RAW +ICMP rule, kernel ping socket creation is denied. A scheme-less rule +counts for both TCP and UDP; ICMP always needs `icmp://`. Raw ICMP (`SOCK_RAW + IPPROTO_ICMP`) is never exposed. See the project README's "Network Model" section for the full grammar. Rule shapes: -* `host:port[,port,...]`: TCP, default scheme (no prefix). -* `tcp://host:port`: TCP, explicit scheme. -* `udp://host:port`: UDP. `udp://*:*` opens any UDP destination. +* `host:port[,port,...]`: no scheme prefix, covers TCP and UDP. +* `tcp://host:port`: TCP only. +* `udp://host:port`: UDP only. `udp://*:*` opens any UDP destination. * `icmp://host`: kernel ping socket (`SOCK_DGRAM + IPPROTO_ICMP`). `icmp://*` opens any echo destination. diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index 9ed89f03..00736c27 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -1102,8 +1102,8 @@ def _build_from_policy(policy: PolicyDataclass): arr = (ctypes.c_uint32 * len(policy.cpu_cores))(*policy.cpu_cores) b = _b_cpu_cores(b, arr, len(policy.cpu_cores)) - # net_allow: list of endpoint specs. Bare `host:port` means TCP; - # `tcp://`/`udp://`/`icmp://` schemes opt other protocols in. + # net_allow: list of endpoint specs. Bare `host:port` means TCP + # and UDP; `tcp://`/`udp://`/`icmp://` schemes pin one protocol. # 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(). diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index b4750de0..5a4370d5 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -173,14 +173,14 @@ class Sandbox: # Network — endpoint allowlist (protocol × IP × port via seccomp on-behalf path) net_allow: Sequence[str] = field(default_factory=list) - """Outbound endpoint rules. Each entry is a string. The bare form is - TCP; other protocols use a scheme prefix: - - * ``"host:port"`` — TCP to one host on one port (e.g. ``"api.openai.com:443"``) - * ``"host:port,port,..."`` — TCP, multiple ports (e.g. ``"github.com:22,443"``) - * ``":port"`` / ``"*:port"`` — TCP on any IP (e.g. ``":53"``) - * ``"tcp://host:port"`` — explicit TCP (same suffix grammar as bare form) - * ``"udp://host:port"`` — UDP to a host + """Outbound endpoint rules. Each entry is a string. The bare form + covers both TCP and UDP; a scheme prefix pins one protocol: + + * ``"host:port"`` — TCP and UDP to one host on one port (e.g. ``"api.openai.com:443"``) + * ``"host:port,port,..."`` — TCP and UDP, multiple ports (e.g. ``"github.com:22,443"``) + * ``":port"`` / ``"*:port"`` — TCP and UDP on any IP (e.g. ``":53"``) + * ``"tcp://host:port"`` — TCP only (same suffix grammar as bare form) + * ``"udp://host:port"`` — UDP only * ``"udp://*:*"`` — any UDP (matches the previous ``allow_udp=True`` behavior) * ``"icmp://host"`` — kernel ping socket (SOCK_DGRAM + IPPROTO_ICMP) to a host * ``"icmp://*"`` — any ICMP echo destination @@ -190,7 +190,9 @@ class Sandbox: use the dgram path above. Protocol gating falls out of rule presence: with no UDP/ICMP rules, - UDP and ICMP socket creation are denied at the seccomp layer. + UDP and ICMP socket creation are denied at the seccomp layer. Bare + (scheme-less) rules count for both TCP and UDP; ICMP is never + implied and always needs ``icmp://``. 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