diff --git a/README.md b/README.md index 00cd06b..ad9809e 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,17 @@ 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://*:* \ +# 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://*' \ -r /usr -r /lib -r /etc -- ./client # UDP — scheme prefix gates the protocol and scopes the destination @@ -126,6 +131,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 10.0.0.0/8 \ + -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 \ @@ -156,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-bind, separate from --net-allow) -sandlock run --net-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 \ @@ -167,11 +178,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 @@ -575,17 +586,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) - 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). @@ -606,20 +625,39 @@ 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 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) +--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 +``` -**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:*`, `:*`, `*:*`, -`udp://*:*`). Mixing `*` with concrete ports (`host:80,*`) is -rejected. When any TCP rule uses the all-ports wildcard, Landlock no +*.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 +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 @@ -627,12 +665,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 @@ -655,9 +696,16 @@ 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 -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. +`--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 b053347..60ce90d 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -341,23 +341,13 @@ 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_rule(rule)); + } + 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 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); @@ -416,7 +406,9 @@ 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 p in &pb.net_bind { builder = builder.net_bind_port(*p); } + 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); } @@ -590,7 +582,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(), @@ -664,7 +660,9 @@ 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_bind.is_empty() { bad.push("--net-bind"); } + 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"); } @@ -722,7 +720,9 @@ 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_bind.is_empty() { bad.push("[network].bind"); } + if !profile.net_deny.is_empty() { bad.push("[network].deny"); } + 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"); } @@ -763,17 +763,48 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> Ok(()) } -/// 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 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 { + 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!("[{}]", c) + } else { + c.to_string() + } + } + }; + 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); + format!("{}{}:{}", scheme, target, ports) + } + } } } +/// 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))?; @@ -788,3 +819,52 @@ 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::NetRule; + + #[test] + fn render_allow_drops_redundant_all_ports_star() { + 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 = NetRule::parse_allow(":*").unwrap(); + assert_eq!(format_net_rule(&r), "*"); + } + + #[test] + fn render_allow_host_ports() { + 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_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-cli/tests/cli_test.rs b/crates/sandlock-cli/tests/cli_test.rs index 511bb3d..b91c977 100644 --- a/crates/sandlock-cli/tests/cli_test.rs +++ b/crates/sandlock-cli/tests/cli_test.rs @@ -162,6 +162,30 @@ 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_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() diff --git a/crates/sandlock-core/src/context.rs b/crates/sandlock-core/src/context.rs index 2fa442b..86fd165 100644 --- a/crates/sandlock-core/src/context.rs +++ b/crates/sandlock-core/src/context.rs @@ -373,6 +373,8 @@ 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() @@ -584,9 +586,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 +1177,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/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/landlock.rs b/crates/sandlock-core/src/landlock.rs index aa756ce..2d47e7e 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,15 +307,24 @@ 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 mask = if net_wildcard { + let net_wildcard = !sandbox.net_deny.is_empty() + || sandbox + .net_allow + .iter() + .any(|r| r.protocol == Protocol::Tcp && r.all_ports); + 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) } @@ -477,7 +491,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)) })?; @@ -731,4 +745,49 @@ 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"); + } + + #[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 2310895..052a35b 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -20,179 +20,347 @@ 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; -/// 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. -/// -/// `Icmp` is the kernel's unprivileged ping socket -/// (`SOCK_DGRAM + IPPROTO_ICMP{,V6}`), gated by `ping_group_range` — -/// destinations are filterable per host. Sandlock does not expose raw -/// ICMP (`SOCK_RAW + IPPROTO_ICMP`): destination filtering at `sendto` -/// would lie because raw sockets let the agent craft the IP header, -/// and packet-crafting capabilities aren't part of the XOA threat -/// model. Workloads that genuinely need raw ICMP should run outside -/// sandlock or rely on the host's `ping_group_range` for the dgram -/// path instead. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Protocol { - Tcp, - Udp, - Icmp, +/// 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, Serialize, Deserialize)] +pub struct IpCidr { + pub addr: IpAddr, + pub prefix_len: u8, } -impl Protocol { - fn parse(s: &str) -> Option { - match s { - "tcp" => Some(Protocol::Tcp), - "udp" => Some(Protocol::Udp), - "icmp" => Some(Protocol::Icmp), - _ => None, +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!("invalid prefix length in `{}`", s)) + })?; + (a, Some(len)) + } + None => (s, None), + }; + let addr: IpAddr = addr_str.parse().map_err(|_| { + SandboxError::Invalid(format!("`{}` is not a valid IP address", 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!( + "prefix /{} too large for {} in `{}`", + prefix_len, + if max == 32 { "IPv4" } else { "IPv6" }, + s + ))); + } + 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 { + 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, } } } -/// 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. +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. /// -/// 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 { +/// `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 NetTarget { + /// Any destination IP (the `:port` / `*:port` / `*` form). + AnyIp, + /// 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-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 NetRule { /// 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`. + /// 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" 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`). + /// "Any port" (bare target with no `:port`, or the `*` port token). #[serde(default)] pub all_ports: bool, } +/// `--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 NetAllow { - /// Parse a rule spec. Forms: +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`): /// - /// - `host:port[,port,...]`, `:port`, `*:port`, `host:*`, `:*`, `*:*` - /// — TCP (the default scheme). - /// - `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("://") { + /// - `host` / `` / `` / `*` -- all ports (port optional; `*` + /// targets any IP). TCP is the default scheme. + /// - `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 { + 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-allow: unknown scheme `{}://` in `{}` (expected tcp, udp, icmp)", - scheme, s + "{}: unknown scheme `{}://` in `{}` (expected tcp, udp, icmp)", + label, scheme, spec )) })?; (proto, body) } - None => (Protocol::Tcp, s), + None => (Protocol::Tcp, spec), }; + // ICMP carries no port: the whole body is the target. if protocol == Protocol::Icmp { - 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 `{}`", - s - )) - })?; - let host = match host_part { - "" | "*" => None, - h => Some(h.to_string()), - }; - - // 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; + if rest.is_empty() { + return Err(SandboxError::Invalid(format!( + "{}: icmp rule needs a host/IP or `*`, got `{}`", + label, spec + ))); } - let n: u16 = p.parse().map_err(|_| { - SandboxError::Invalid(format!("--net-allow: invalid port `{}` in `{}`", p, s)) - })?; - if n == 0 { + // 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!( - "--net-allow: port 0 is not valid in `{}`", - s + "{}: icmp rule takes no port, got `{}`", + label, spec ))); } - ports.push(n); + return Ok(NetRule { + protocol, + target: parse_target(rest, label, allow_hosts)?, + ports: Vec::new(), + all_ports: true, + }); } - if saw_wildcard && !ports.is_empty() { - return Err(SandboxError::Invalid(format!( - "--net-allow: cannot mix `*` with concrete ports in `{}`", - s - ))); + + // 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!("{}: 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, + }); } - if !saw_wildcard && ports.is_empty() { + + // 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-allow: at least one port required in `{}`", - s + "{}: empty rule in `{}` (use `*` for any host)", + label, spec ))); } - Ok(NetAllow { + + // 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), + }; + 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), + }; + Ok(NetRule { protocol, - host, + target, ports, - all_ports: saw_wildcard, + all_ports, }) } +} + +/// 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(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 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 - ))); +/// 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(',') { + let p = p.trim(); + if p == "*" { + saw_wildcard = true; + continue; } - if body.is_empty() { + let n: u16 = p.parse().map_err(|_| { + SandboxError::Invalid(format!("{}: invalid port `{}` in `{}`", label, p, full)) + })?; + if n == 0 { return Err(SandboxError::Invalid(format!( - "--net-allow: icmp rule needs a host or `*`, got `{}`", - full + "{}: port 0 is not valid in `{}`", + label, full ))); } - let host = match body { - "*" => None, - h => Some(h.to_string()), - }; - Ok(NetAllow { - protocol: Protocol::Icmp, - host, - ports: Vec::new(), - all_ports: false, - }) + ports.push(n); + } + if saw_wildcard && !ports.is_empty() { + return Err(SandboxError::Invalid(format!( + "{}: cannot mix `*` with concrete ports in `{}`", + label, full + ))); + } + if !saw_wildcard && ports.is_empty() { + return Err(SandboxError::Invalid(format!( + "{}: at least one port required in `{}`", + label, 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` +/// form). `Udp` and `Icmp` require an explicit scheme. +/// +/// `Icmp` is the kernel's unprivileged ping socket +/// (`SOCK_DGRAM + IPPROTO_ICMP{,V6}`), gated by `ping_group_range` — +/// destinations are filterable per host. Sandlock does not expose raw +/// ICMP (`SOCK_RAW + IPPROTO_ICMP`): destination filtering at `sendto` +/// would lie because raw sockets let the agent craft the IP header, +/// and packet-crafting capabilities aren't part of the XOA threat +/// model. Workloads that genuinely need raw ICMP should run outside +/// sandlock or rely on the host's `ping_group_range` for the dgram +/// path instead. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Protocol { + Tcp, + Udp, + Icmp, +} + +impl Protocol { + fn parse(s: &str) -> Option { + match s { + "tcp" => Some(Protocol::Tcp), + "udp" => Some(Protocol::Udp), + "icmp" => Some(Protocol::Icmp), + _ => None, + } } } @@ -265,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 { @@ -1020,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 @@ -1056,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." @@ -1076,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( @@ -1105,6 +1289,7 @@ pub async fn resolve_net_allow( ResolvedNetAllow { per_ip, per_ip_all_ports, + cidrs, any_ip_ports, any_ip_all_ports, }, @@ -1129,6 +1314,68 @@ 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 { + NetTarget::AnyIp => { + if rule.all_ports || target == Protocol::Icmp { + deny_all = true; + } else { + for &p in &rule.ports { + any_ip_ports.insert(p); + } + } + } + NetTarget::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)); + } + // `--net-deny` rejects hostnames at parse time, so a deny + // rule never carries a `Host` target. + NetTarget::Host(_) => unreachable!("net-deny rejects hostnames"), + } + } + + 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 @@ -1200,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); } @@ -1256,35 +1503,94 @@ 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")); } #[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 = 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 = 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!(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()); } @@ -1293,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] @@ -1345,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] @@ -1360,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); } } @@ -1380,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, }]; @@ -1402,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, }]; @@ -1419,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, }]; @@ -1438,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, }]; @@ -1463,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, }, @@ -1490,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, }, @@ -1524,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, }]; @@ -1548,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, }]; @@ -1660,4 +1969,171 @@ 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()); + } + + // --- 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); + 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(); + match &rule.target { + NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 32), + _ => panic!("expected cidr"), + } + assert!(rule.all_ports); + } + + #[test] + fn netdeny_cidr_with_port() { + 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 = NetRule::parse_deny(":25").unwrap(); + 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(); + 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(); + assert_eq!(rule.ports, vec![443]); + match &rule.target { + NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), + _ => panic!("expected cidr"), + } + } + + #[test] + fn netdeny_rejects_hostname() { + 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 = NetRule::parse_deny("::1").unwrap(); + assert!(rule.all_ports); + match &rule.target { + NetTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), + _ => panic!("expected cidr"), + } + } + + #[test] + fn netdeny_bare_ipv6_cidr_all_ports() { + 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, NetTarget::Cidr(c) if c.contains(ula))); + } + + #[test] + fn netdeny_empty_icmp_body_is_rejected() { + 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 = NetRule::parse_deny("*").unwrap(); + assert_eq!(rule.protocol, Protocol::Tcp); + 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 = NetRule::parse_deny("udp://*").unwrap(); + assert_eq!(rule.protocol, Protocol::Udp); + 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!(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 = 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)); + 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)); + } } 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 23b9c91..cd434ad 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -77,11 +77,24 @@ 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 bind: Vec, + pub allow_bind: Vec, + pub deny_bind: Vec, pub allow: Vec, + pub deny: Vec, pub port_remap: bool, } @@ -167,8 +180,20 @@ 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 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 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); } // [http] @@ -428,7 +453,7 @@ mod tests { on_error = "abort" [network] - bind = [8080] + allow_bind = [8080, "9000-9002"] allow = ["tcp://cache.internal:6379"] port_remap = true @@ -457,6 +482,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); } @@ -509,6 +536,28 @@ 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", "192.168.0.0/16"] + "#; + let (policy, _spec) = parse_profile(toml).unwrap(); + 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 1de5a9b..19083ad 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::{IpCidr, NetAllow, NetDeny, NetRule, NetTarget, Protocol}; use crate::protection::{Protection, ProtectionPolicy, ProtectionState, ProtectionStatus}; /// A byte size value. @@ -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_bind.is_empty() { unsupported.push("net_bind"); } + 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"); } @@ -252,7 +254,16 @@ pub struct Sandbox { /// remain reachable. HTTP rules with wildcard hosts auto-add /// `(Tcp, None, [80])` instead. pub net_allow: Vec, - pub net_bind: Vec, + /// 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, @@ -375,7 +386,9 @@ 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_bind: self.net_bind.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(), @@ -1390,9 +1403,11 @@ 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(), + 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() @@ -1425,36 +1440,45 @@ 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, + cidrs: resolved.cidrs.clone(), + 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()); + 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); } @@ -1482,8 +1506,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 { @@ -1805,8 +1836,26 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(long = "net-allow", value_name = "SPEC"))] pub net_allow: Vec, - #[cfg_attr(feature = "cli", arg(long = "net-bind"))] - pub net_bind: Vec, + /// `--net-deny`: default-allow networking, block these IPs/CIDRs/ports. + /// 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, + + /// `--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, + + /// `--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, @@ -1975,7 +2024,9 @@ 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_bind: self.net_bind.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(), @@ -2091,8 +2142,39 @@ impl SandboxBuilder { self } - pub fn net_bind_port(mut self, port: u16) -> Self { - self.net_bind.push(port); + /// 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 + } + + /// 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.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 + } + + /// 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 } @@ -2369,9 +2451,35 @@ 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| NetRule::parse_deny(&s)) + .collect::>()?; + + // --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(), + )); + } + + // 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, @@ -2387,7 +2495,9 @@ impl SandboxBuilder { extra_allow_syscalls: self.extra_allow_syscalls, protection_policy: self.protection_policy, net_allow, - net_bind: self.net_bind, + net_deny, + net_allow_bind, + net_deny_bind, http_allow, http_deny, http_ports, @@ -2438,6 +2548,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], 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!( + "{}: empty port in `{}`", + label, spec + ))); + } + match part.split_once('-') { + Some((lo, hi)) => { + let lo: u16 = lo.trim().parse().map_err(|_| { + SandboxError::Invalid(format!("{}: invalid port range `{}`", label, part)) + })?; + let hi: u16 = hi.trim().parse().map_err(|_| { + SandboxError::Invalid(format!("{}: invalid port range `{}`", label, part)) + })?; + if lo > hi { + return Err(SandboxError::Invalid(format!( + "{}: reversed port range `{}` (lo > hi)", + label, part + ))); + } + ports.extend(lo..=hi); + } + None => { + let p: u16 = part.parse().map_err(|_| { + SandboxError::Invalid(format!("{}: invalid port `{}`", label, 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 @@ -2615,4 +2767,78 @@ 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_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() + .net_allow("github.com:443") + .net_deny("10.0.0.0/8") + .build(); + 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(); + 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)); + } + } 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 4d0a41c..f3c9c29 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -244,10 +244,26 @@ 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, }, + /// 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 { @@ -255,15 +271,49 @@ 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 { + 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 } } } @@ -355,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 @@ -1842,4 +1896,70 @@ 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 + } + + #[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..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(), @@ -354,6 +359,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_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..c19d4b7 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(); @@ -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-core/tests/integration/test_policy.rs b/crates/sandlock-core/tests/integration/test_policy.rs index 5ab1d9f..1839cd8 100644 --- a/crates/sandlock-core/tests/integration/test_policy.rs +++ b/crates/sandlock-core/tests/integration/test_policy.rs @@ -29,28 +29,31 @@ 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_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_err()); // missing port - 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] 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/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 5d9bffc..c6ebf2d 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -392,11 +392,25 @@ 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_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_bind_port(sandlock_builder_t *b, uint16_t port); +sandlock_builder_t *sandlock_sandbox_builder_net_deny_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 bca1e9f..2a79185 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -326,15 +326,38 @@ 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] +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_allow_bind_port(port))) +} + /// # 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_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_bind_port(port))) + Box::into_raw(Box::new(builder.net_deny_bind_port(port))) } /// # Safety diff --git a/docs/sandbox-reference.md b/docs/sandbox-reference.md index 4ff762c..a52781a 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,8 @@ 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). 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 7bbf1de..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`, `NetBind`, `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` | @@ -78,10 +78,15 @@ 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"`). `NetDenyBind` is the inverse +(default-allow bind, deny these TCP ports; same syntax, mutually exclusive +with `NetAllowBind`). ### 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..1cca045 100644 --- a/go/sandbox.go +++ b/go/sandbox.go @@ -71,14 +71,26 @@ 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"). + // Mutually exclusive with NetDenyBind. + NetAllowBind []string + // 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 37ed41b..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.NetBind, + 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, @@ -125,14 +125,29 @@ 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_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_bind_port(b, C.uint16_t(p)) + b = C.sandlock_sandbox_builder_net_deny_bind_port(b, C.uint16_t(p)) } } if s.PortRemap { 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..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_bind (bind), net_allow (allow), 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), @@ -83,8 +83,10 @@ "on_error": ("on_error", str), }, "network": { - "bind": ("net_bind", list), + "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), }, "http": { @@ -240,7 +242,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..13d87f8 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -88,7 +88,9 @@ 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_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) @@ -951,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_bind", + "net_allow", "net_deny", "net_allow_bind", "net_deny_bind", "port_remap", "http_allow", "http_deny", "http_ports", "http_ca", "http_key", "uid", @@ -1031,12 +1033,17 @@ 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 port in parse_ports(policy.net_bind) if policy.net_bind else []: - b = _b_net_bind_port(b, port) + 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) + 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/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..c22b1d9 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) @@ -164,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 @@ -175,10 +190,17 @@ 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) - """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.""" + net_allow_bind: Sequence[int | str] = field(default_factory=list) + """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) @@ -404,8 +426,12 @@ 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 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.""" 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..db8a059 100644 --- a/python/tests/test_profile.py +++ b/python/tests/test_profile.py @@ -85,15 +85,27 @@ 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 + 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_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 16c7867..836844f 100644 --- a/python/tests/test_sandbox.py +++ b/python/tests/test_sandbox.py @@ -89,6 +89,42 @@ 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 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. @@ -288,7 +324,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 +394,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..ce3eb8a 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 @@ -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] @@ -168,7 +178,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): @@ -238,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://*"] +