diff --git a/README.md b/README.md index 00cd06b..542cc0e 100644 --- a/README.md +++ b/README.md @@ -110,12 +110,12 @@ sandlock run --net-allow api.openai.com:443 -r /usr -r /lib -r /etc -- python3 a sandlock run --net-allow github.com:22,443 --net-allow :8080 \ -r /usr -r /lib -r /etc -- python3 agent.py -# Wildcard port — `host:*` permits every port to the host -sandlock run --net-allow github.com:* -r /usr -r /lib -r /etc -- ssh user@github.com +# Wildcard port (optional): a bare `host` (or `host:*`) permits every port +sandlock run --net-allow github.com -r /usr -r /lib -r /etc -- ssh user@github.com -# Unrestricted outbound — `:*` opens any host and any TCP port. For full -# egress add a UDP wildcard via the `udp://*:*` scheme. -sandlock run --net-allow :* --net-allow udp://*:* \ +# Unrestricted outbound: `*` opens any host and any TCP port (`:*` / `*:*` +# are equivalent). For full egress add a UDP wildcard, `udp://*`. +sandlock run --net-allow '*' --net-allow 'udp://*' \ -r /usr -r /lib -r /etc -- ./client # UDP — scheme prefix gates the protocol and scopes the destination @@ -126,6 +126,11 @@ sandlock run --net-allow udp://1.1.1.1:53 --net-allow :443 \ # Ping — kernel ping socket (SOCK_DGRAM) gated by net.ipv4.ping_group_range sandlock run --net-allow icmp://github.com -r /usr -r /lib -r /etc -- ping github.com +# Denylist: default-allow networking, block specific IPs/CIDRs/ports +# (inverse of --net-allow; mutually exclusive with it). Port is optional. +sandlock run --net-deny 169.254.169.254 --net-deny private \ + -r /usr -r /lib -r /etc -- python3 agent.py + # HTTP-level ACL (method + host + path rules via transparent proxy) # HTTP rules with concrete hosts auto-extend --net-allow with host:80,443 sandlock run \ @@ -580,9 +585,10 @@ Outbound traffic is gated by a single endpoint allowlist that names ``` --net-allow repeatable; no rules = deny all outbound - bare form host:port[,port,...] / :port / *:port / host:* / :* / *:* (TCP) + bare form host[:port[,port,...]] / :port / *:port / host:* / :* / *:* (TCP) + the port is optional: `host` or `*` alone means all ports tcp:// same suffix grammar — explicit TCP - udp:// same suffix grammar — UDP (`udp://*:*` opens any UDP) + udp:// same suffix grammar — UDP (`udp://*` opens any UDP) icmp:// host or `*`, no port — kernel ping socket (SOCK_DGRAM) ``` @@ -606,20 +612,41 @@ and port (port is N/A for ICMP). denies every TCP `connect()`, UDP / ICMP / raw socket creation are denied at the seccomp layer, and there is no on-behalf path active. For unrestricted TCP egress, opt in explicitly with -`--net-allow :*`; for any UDP, add `--net-allow udp://*:*`. +`--net-allow '*'`; for any UDP, add `--net-allow 'udp://*'`. + +**Denylist (`--net-deny`).** The inverse model: networking is +default-allow and the listed targets are blocked. Mutually exclusive +with `--net-allow`. Targets are literal IPs or CIDRs (hostnames are +rejected; use `--http-deny` for domains); the port is optional and +`*` matches any IP. The same scheme grammar applies (`tcp://` default, +`udp://`, `icmp://`), and the `private` token expands to all internal +ranges across every protocol. + +``` +--net-deny 10.0.0.0/8 # all ports on a CIDR (all protocols) +--net-deny 169.254.169.254:80 # one IP, one port +--net-deny 169.254.169.254:80,443 # comma-separated ports in one rule +--net-deny '*' # any IP, all ports (TCP) +--net-deny 'udp://192.168.0.0/16' # any UDP to a CIDR +--net-deny private # all internal ranges +``` + +Repeat the flag for multiple rules. **Resolution.** Concrete hostnames are resolved once at sandbox start and pinned in a synthetic `/etc/hosts` (across all protocols). The synthetic file replaces the real one only when at least one rule has -a concrete host; pure `:port` / `udp://*:*` / `icmp://*` rules leave +a concrete host; pure `:port` / `udp://*` / `icmp://*` rules leave the real `/etc/hosts` and DNS visible. **Wildcards.** Hostnames are matched literally — `--net-allow *.example.com:443` is **not** supported, list each domain you need. The `*` token is allowed as the host (alias for empty: `*:port` ≡ -`:port`) and as the port for TCP/UDP rules (`host:*`, `:*`, `*:*`, -`udp://*:*`). Mixing `*` with concrete ports (`host:80,*`) is -rejected. When any TCP rule uses the all-ports wildcard, Landlock no +`:port`) and as the port for TCP/UDP rules (`host:*`, `:*`, `*:*`). +The port is optional: omitting it means all ports, so `host` ≡ +`host:*` and `*` ≡ `:*` ≡ `*:*` (and `udp://*` ≡ `udp://*:*`). Mixing +`*` with concrete ports (`host:80,*`) is rejected. When any TCP rule +uses the all-ports wildcard, Landlock no longer filters TCP connect at the kernel level (it cannot express "every port" without enumerating 65535 rules); the on-behalf path becomes the sole enforcer, and for `:*` it short-circuits to diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index b053347..8aaa33f 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -341,21 +341,10 @@ async fn run_command(args: RunArgs) -> Result { for p in &base.fs_writable { b = b.fs_write(p); } for p in &base.fs_denied { b = b.fs_deny(p); } for rule in &base.net_allow { - let host_part = rule.host.as_deref().unwrap_or("*"); - let spec = match rule.protocol { - sandlock_core::sandbox::Protocol::Tcp => { - let ports = format_ports(&rule.ports, rule.all_ports); - format!("tcp://{}:{}", host_part, ports) - } - sandlock_core::sandbox::Protocol::Udp => { - let ports = format_ports(&rule.ports, rule.all_ports); - format!("udp://{}:{}", host_part, ports) - } - sandlock_core::sandbox::Protocol::Icmp => { - format!("icmp://{}", host_part) - } - }; - b = b.net_allow(spec); + b = b.net_allow(format_net_allow(rule)); + } + for rule in &base.net_deny { + b = b.net_deny(format_net_deny(rule)); } for p in &base.net_bind { b = b.net_bind_port(*p); } for rule in &base.http_allow { @@ -416,6 +405,7 @@ async fn run_command(args: RunArgs) -> Result { for p in &pb.fs_writable { builder = builder.fs_write(p); } if let Some(n) = pb.max_processes { builder = builder.max_processes(n); } for spec in &pb.net_allow { builder = builder.net_allow(spec); } + for spec in &pb.net_deny { builder = builder.net_deny(spec); } for p in &pb.net_bind { builder = builder.net_bind_port(*p); } if let Some(seed) = pb.random_seed { builder = builder.random_seed(seed); } if pb.clean_env { builder = builder.clean_env(true); } @@ -664,6 +654,7 @@ fn validate_no_supervisor(args: &RunArgs) -> Result<()> { if pb.max_open_files.is_some() { bad.push("--max-open-files"); } if args.timeout.is_some() { bad.push("--timeout"); } if !pb.net_allow.is_empty() { bad.push("--net-allow"); } + if !pb.net_deny.is_empty() { bad.push("--net-deny"); } if !pb.net_bind.is_empty() { bad.push("--net-bind"); } if !pb.http_allow.is_empty() { bad.push("--http-allow"); } if !pb.http_deny.is_empty() { bad.push("--http-deny"); } @@ -722,6 +713,7 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> if !profile.fs_denied.is_empty() { bad.push("[filesystem].deny"); } if !profile.net_allow.is_empty() { bad.push("[network].allow"); } + if !profile.net_deny.is_empty() { bad.push("[network].deny"); } if !profile.net_bind.is_empty() { bad.push("[network].bind"); } if profile.port_remap { bad.push("[network].port_remap"); } if !profile.http_allow.is_empty() { bad.push("[http].allow"); } @@ -763,17 +755,66 @@ 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 NetAllow rule back into a --net-allow spec string, so a +/// profile loaded via --profile-file round-trips through the builder. Mirrors +/// `format_net_deny`: bare TCP, explicit `udp://`/`icmp://`, and the all-ports +/// case drops the redundant `:*`. +fn format_net_allow(rule: &sandlock_core::sandbox::NetAllow) -> String { + use sandlock_core::sandbox::Protocol; + let host = rule.host.as_deref().unwrap_or("*"); + match rule.protocol { + Protocol::Icmp => format!("icmp://{}", host), + proto => { + let scheme = if matches!(proto, Protocol::Udp) { "udp://" } else { "" }; + if rule.all_ports { + format!("{}{}", scheme, host) + } else { + let ports = format_ports(&rule.ports); + format!("{}{}:{}", scheme, host, ports) + } + } } } +/// Render a parsed NetDeny rule back into a --net-deny spec string, so a +/// profile loaded via --profile-file round-trips through the builder. +fn format_net_deny(rule: &sandlock_core::sandbox::NetDeny) -> String { + use sandlock_core::sandbox::{DenyTarget, Protocol}; + let target = match &rule.target { + DenyTarget::AnyIp => "*".to_string(), + DenyTarget::Cidr(c) => { + let base = format!("{}/{}", c.addr, c.prefix_len); + // Bracket IPv6 only when a port suffix will follow, because a + // bare addr:port is itself a valid IPv6 address. + if matches!(c.addr, std::net::IpAddr::V6(_)) && !rule.all_ports { + format!("[{}]", base) + } else { + base + } + } + }; + match rule.protocol { + Protocol::Icmp => format!("icmp://{}", target), + proto => { + let scheme = if matches!(proto, Protocol::Udp) { "udp://" } else { "" }; + if rule.all_ports { + format!("{}{}", scheme, target) + } else { + let ports = format_ports(&rule.ports); + 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 +829,40 @@ fn parse_branch_action(flag: &str, s: &str) -> Result { other => Err(anyhow!("invalid {} value '{}': expected commit | abort | keep", flag, other)), } } + +#[cfg(test)] +mod render_tests { + use super::*; + use sandlock_core::sandbox::NetAllow; + + #[test] + fn render_allow_drops_redundant_all_ports_star() { + let r = NetAllow::parse("udp://*:*").unwrap(); + assert_eq!(format_net_allow(&r), "udp://*"); + } + + #[test] + fn render_allow_any_ip_all_ports_tcp_is_bare_star() { + let r = NetAllow::parse(":*").unwrap(); + assert_eq!(format_net_allow(&r), "*"); + } + + #[test] + fn render_allow_host_ports() { + let r = NetAllow::parse("example.com:443").unwrap(); + assert_eq!(format_net_allow(&r), "example.com:443"); + } + + #[test] + fn render_allow_roundtrips_through_parse() { + for spec in ["example.com:443", "udp://1.1.1.1:53", "icmp://github.com", "*", "udp://*"] { + let r = NetAllow::parse(spec).unwrap(); + let rendered = format_net_allow(&r); + let r2 = NetAllow::parse(&rendered).unwrap(); + assert_eq!(r.host, r2.host, "host mismatch for {spec}"); + assert_eq!(r.ports, r2.ports, "ports mismatch for {spec}"); + assert_eq!(r.all_ports, r2.all_ports, "all_ports mismatch for {spec}"); + assert_eq!(r.protocol, r2.protocol, "protocol mismatch for {spec}"); + } + } +} diff --git a/crates/sandlock-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..fa1133a 100644 --- a/crates/sandlock-core/src/context.rs +++ b/crates/sandlock-core/src/context.rs @@ -373,6 +373,7 @@ const PORT_REMAP_SYSCALLS: &[i64] = &[ fn needs_network_supervision(policy: &Sandbox) -> bool { !policy.net_allow.is_empty() + || !policy.net_deny.is_empty() || policy.policy_fn.is_some() || !policy.http_allow.is_empty() || !policy.http_deny.is_empty() @@ -584,9 +585,14 @@ pub fn arg_filters(policy: &Sandbox) -> Vec { use crate::sandbox::Protocol; let any_udp_rule = policy.net_allow.iter().any(|r| r.protocol == Protocol::Udp); let any_icmp_rule = policy.net_allow.iter().any(|r| r.protocol == Protocol::Icmp); + // `--net-deny` is default-allow, so UDP and the kernel ping socket + // (both SOCK_DGRAM) must be creatable; without this the sandbox + // could not even do DNS over UDP. Per-destination UDP/ICMP denial + // is still enforced on the sendto on-behalf path via the DenyList. + let net_deny_active = !policy.net_deny.is_empty(); let mut blocked_types: Vec = Vec::new(); blocked_types.push(SOCK_RAW); - if !any_udp_rule && !any_icmp_rule { + if !any_udp_rule && !any_icmp_rule && !net_deny_active { blocked_types.push(SOCK_DGRAM); } @@ -1170,6 +1176,19 @@ mod tests { assert!(nrs.contains(&(libc::SYS_sendmmsg as u32))); } + #[test] + fn test_notif_syscalls_net_deny() { + // --net-deny is default-allow but still needs every connect/sendto + // routed to the on-behalf path so the DenyList can refuse matches. + let policy = Sandbox::builder() + .net_deny("10.0.0.0/8") + .build() + .unwrap(); + let nrs = notif_syscalls(&policy, None); + assert!(nrs.contains(&(libc::SYS_connect as u32))); + assert!(nrs.contains(&(libc::SYS_sendto as u32))); + } + #[test] fn test_notif_syscalls_sandbox_name_enables_hostname_virtualization() { let policy = Sandbox::builder().build().unwrap(); diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index aa756ce..07dc149 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -280,6 +280,11 @@ pub fn compute_fs_mask(abi: u32, pol: &ProtectionPolicy) -> u64 { /// covers every port we drop `CONNECT_TCP` from the handled set (the /// on-behalf path is then the sole enforcer). /// +/// `--net-deny` is default-allow: every TCP connect must reach the +/// on-behalf seccomp path (the DenyList enforcer), so Landlock must not +/// gate `CONNECT_TCP` at all. A non-empty `net_deny` therefore forces the +/// wildcard treatment, exactly like an all-ports `--net-allow` rule. +/// /// Returns `(0, false)` when `Protection::NetTcp` is not `Active` /// (either disabled by policy or degraded on a kernel that does not /// provide TCP network hooks). @@ -302,10 +307,11 @@ pub fn compute_net_mask( return (0, false); } use crate::sandbox::Protocol; - let net_wildcard = sandbox - .net_allow - .iter() - .any(|r| r.protocol == Protocol::Tcp && r.all_ports); + let net_wildcard = !sandbox.net_deny.is_empty() + || sandbox + .net_allow + .iter() + .any(|r| r.protocol == Protocol::Tcp && r.all_ports); let mask = if net_wildcard { LANDLOCK_ACCESS_NET_BIND_TCP } else { @@ -731,4 +737,25 @@ mod mask_contract_tests { assert_eq!(mask, 0); assert!(!wildcard); } + + #[test] + fn net_mask_net_deny_forces_wildcard_dropping_connect_tcp() { + // `--net-deny` is default-allow and enforced on the on-behalf + // seccomp path, so Landlock must not gate CONNECT_TCP: a non-empty + // net_deny forces the wildcard treatment (BIND_TCP only), exactly + // like an all-ports --net-allow rule. This pins the reconciliation + // of the net-deny runtime relaxation with compute_net_mask. + let pol = ProtectionPolicy::strict_all(); + let sb = Sandbox::builder() + .net_deny("10.0.0.0/8") + .build() + .expect("net_deny sandbox builds"); + let (mask, wildcard) = compute_net_mask(6, &pol, &sb, true); + assert_eq!( + mask, + LANDLOCK_ACCESS_NET_BIND_TCP, + "net_deny must drop CONNECT_TCP so all TCP connects reach the on-behalf path", + ); + assert!(wildcard, "net_deny must set the wildcard flag"); + } } diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index 2310895..03b4b77 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -20,6 +20,275 @@ use crate::sys::structs::{SeccompNotif, AF_INET, AF_INET6, ECONNREFUSED}; /// Prevents a sandboxed process from triggering OOM in the supervisor. const MAX_SEND_BUF: usize = 64 << 20; +/// An IPv4 or IPv6 address with a prefix length, used by `--net-deny` +/// to match destination IPs by exact address (`/32`, `/128`) or by range. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct IpCidr { + pub addr: IpAddr, + pub prefix_len: u8, +} + +impl IpCidr { + /// Parse `addr` or `addr/prefix`. A bare address becomes a host route + /// (`/32` for IPv4, `/128` for IPv6). Hostnames are rejected: the + /// address part must parse as a literal IP. + pub fn parse(s: &str) -> Result { + let (addr_str, prefix) = match s.split_once('/') { + Some((a, p)) => { + let len: u8 = p.parse().map_err(|_| { + SandboxError::Invalid(format!( + "--net-deny: invalid prefix length in `{}`", + s + )) + })?; + (a, Some(len)) + } + None => (s, None), + }; + let addr: IpAddr = addr_str.parse().map_err(|_| { + SandboxError::Invalid(format!( + "--net-deny: `{}` is not an IP or CIDR (hostnames are not \ + allowed; use --http-deny for domains)", + s + )) + })?; + let max = match addr { + IpAddr::V4(_) => 32u8, + IpAddr::V6(_) => 128u8, + }; + let prefix_len = prefix.unwrap_or(max); + if prefix_len > max { + return Err(SandboxError::Invalid(format!( + "--net-deny: prefix /{} too large for {} in `{}`", + prefix_len, + if max == 32 { "IPv4" } else { "IPv6" }, + s + ))); + } + Ok(IpCidr { addr, prefix_len }) + } + + /// True iff `ip` falls within this network. Different address + /// families never match. + pub fn contains(&self, ip: IpAddr) -> bool { + match (self.addr, ip) { + (IpAddr::V4(net), IpAddr::V4(ip)) => { + if self.prefix_len == 0 { + return true; + } + let mask = u32::MAX << (32 - self.prefix_len); + (u32::from(net) & mask) == (u32::from(ip) & mask) + } + (IpAddr::V6(net), IpAddr::V6(ip)) => { + if self.prefix_len == 0 { + return true; + } + let mask = u128::MAX << (128 - self.prefix_len); + (u128::from(net) & mask) == (u128::from(ip) & mask) + } + _ => false, + } + } +} + +/// What a deny rule targets at the IP layer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DenyTarget { + /// Any destination IP (the `:port` / `*:port` form). + AnyIp, + /// A specific IP or CIDR range. + Cidr(IpCidr), +} + +/// A single `--net-deny` rule. Unlike `NetAllow`, the target is always +/// a literal IP/CIDR (or any-IP) resolved at parse time, never a +/// hostname, so no DNS is involved and matching is rebinding-safe. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct NetDeny { + pub protocol: Protocol, + pub target: DenyTarget, + pub ports: Vec, + /// "Any port" (bare target with no `:port`, or the `*` port token). + pub all_ports: bool, +} + +/// Curated internal/private ranges expanded by the `private` token. +/// Covers loopback, RFC1918, link-local (incl. the cloud metadata +/// endpoint 169.254.169.254), IPv6 loopback, ULA, and IPv6 link-local. +pub const PRIVATE_CIDRS: &[&str] = &[ + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + "::1/128", + "fc00::/7", + "fe80::/10", +]; + +impl NetDeny { + /// Parse a `--net-deny` spec into one or more rules. Returns a `Vec` + /// because the `private` token expands to many rules. Forms: + /// + /// - `private` -- the curated internal-range set, all protocols. + /// - `` / `` / `*` -- all ports (the port is optional; `*` + /// targets any IP). TCP is the default scheme. + /// - `:` / `:` / `:*`. + /// - `[]:` -- bracketed IPv6 with a port (IPv6 + /// combined with a port MUST use this bracket form because a bare + /// `addr:port` string is itself a valid IPv6 address). + /// - `:` / `*:` -- any IP, that port. + /// - `tcp://...` / `udp://...` / `icmp://...` schemes (icmp: no port). + /// + /// Hostnames are rejected (use `--http-deny` for domain blocking). + pub fn parse(spec: &str) -> Result, SandboxError> { + if spec == "private" { + let mut out = Vec::new(); + for proto in [Protocol::Tcp, Protocol::Udp, Protocol::Icmp] { + for c in PRIVATE_CIDRS { + out.push(NetDeny { + protocol: proto, + target: DenyTarget::Cidr(IpCidr::parse(c)?), + ports: Vec::new(), + all_ports: true, + }); + } + } + return Ok(out); + } + + let (protocol, rest) = match spec.split_once("://") { + Some((scheme, body)) => { + let proto = Protocol::parse(scheme).ok_or_else(|| { + SandboxError::Invalid(format!( + "--net-deny: unknown scheme `{}://` in `{}` (expected tcp, udp, icmp)", + scheme, spec + )) + })?; + (proto, body) + } + None => (Protocol::Tcp, spec), + }; + + // ICMP carries no port: the whole body is the target. + if protocol == Protocol::Icmp { + if rest.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-deny: icmp rule needs an IP/CIDR or `*`, got `{}`", + spec + ))); + } + return Ok(vec![NetDeny { + protocol, + target: parse_deny_target(rest)?, + ports: Vec::new(), + all_ports: true, + }]); + } + + // 1. Bracketed IPv6 with a port: `[addr]:ports`. + if let Some(stripped) = rest.strip_prefix('[') { + let (inside, port_part) = stripped.rsplit_once("]:").ok_or_else(|| { + SandboxError::Invalid(format!( + "--net-deny: malformed bracketed address in `{}`", + spec + )) + })?; + let (ports, all_ports) = parse_deny_ports(port_part, spec)?; + return Ok(vec![NetDeny { + protocol, + target: DenyTarget::Cidr(IpCidr::parse(inside)?), + ports, + all_ports, + }]); + } + + // An empty body must not silently mean "deny everything"; require + // an explicit `*` for the any-IP target. + if rest.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-deny: empty rule in `{}` (use `*` for any IP)", + spec + ))); + } + + // 2. Whole body is an IP/CIDR with no port -> all ports. This + // is what makes bare `10.0.0.0/8` and IPv6 `fc00::/7` work. + if let Ok(cidr) = IpCidr::parse(rest) { + return Ok(vec![NetDeny { + protocol, + target: DenyTarget::Cidr(cidr), + ports: Vec::new(), + all_ports: true, + }]); + } + + // 3. `target[:ports]` where target is an IPv4/CIDR, `*`, or empty. + // The port suffix is optional: a bare `*` (or any target with no + // `:port`) covers all ports, mirroring the bare IP/CIDR form above. + let (host_part, port_part) = match rest.rsplit_once(':') { + Some((h, p)) => (h, Some(p)), + None => (rest, None), + }; + let target = parse_deny_target(host_part)?; + let (ports, all_ports) = match port_part { + Some(p) => parse_deny_ports(p, spec)?, + None => (Vec::new(), true), + }; + Ok(vec![NetDeny { + protocol, + target, + ports, + all_ports, + }]) + } +} + +/// Parse a deny target: `*` / empty -> any IP, otherwise an IP/CIDR. +/// Hostnames fail here via `IpCidr::parse`. +fn parse_deny_target(s: &str) -> Result { + match s { + "" | "*" => Ok(DenyTarget::AnyIp), + _ => Ok(DenyTarget::Cidr(IpCidr::parse(s)?)), + } +} + +/// Parse the port suffix of a deny spec. `*` means all ports. +fn parse_deny_ports(s: &str, full: &str) -> Result<(Vec, bool), SandboxError> { + let mut ports = Vec::new(); + let mut saw_wildcard = false; + for p in s.split(',') { + let p = p.trim(); + if p == "*" { + saw_wildcard = true; + continue; + } + let n: u16 = p.parse().map_err(|_| { + SandboxError::Invalid(format!("--net-deny: invalid port `{}` in `{}`", p, full)) + })?; + if n == 0 { + return Err(SandboxError::Invalid(format!( + "--net-deny: port 0 is not valid in `{}`", + full + ))); + } + ports.push(n); + } + if saw_wildcard && !ports.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-deny: cannot mix `*` with concrete ports in `{}`", + full + ))); + } + if !saw_wildcard && ports.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-deny: at least one port required in `{}`", + full + ))); + } + Ok((ports, saw_wildcard)) +} + /// L4 protocol that a `NetAllow` rule applies to. /// /// `Tcp` is the default if a rule has no scheme (the bare `host:port` @@ -88,6 +357,8 @@ impl NetAllow { /// /// - `host:port[,port,...]`, `:port`, `*:port`, `host:*`, `:*`, `*:*` /// — TCP (the default scheme). + /// - `host` / `*` — no port suffix means all ports (the port is + /// optional, `*` matches any host); equivalent to `host:*` / `*:*`. /// - `tcp://...` — explicit TCP, same suffix grammar as the bare form. /// - `udp://...` — UDP, same suffix grammar as the bare form. /// - `icmp://host` or `icmp://*` — ICMP echo (kernel ping socket). @@ -115,16 +386,34 @@ impl NetAllow { return Self::parse_icmp(rest, s); } - let (host_part, port_part) = rest.rsplit_once(':').ok_or_else(|| { - SandboxError::Invalid(format!( - "--net-allow: expected `host:port` or `:port`, got `{}`", + if rest.is_empty() { + return Err(SandboxError::Invalid(format!( + "--net-allow: empty rule in `{}` (use `*` for any host)", s - )) - })?; + ))); + } + + // The port suffix is optional: a host (or `*`) with no `:port` + // covers all ports, mirroring the `host:*` form. + let (host_part, port_part) = match rest.rsplit_once(':') { + Some((h, p)) => (h, Some(p)), + None => (rest, None), + }; let host = match host_part { "" | "*" => None, h => Some(h.to_string()), }; + let port_part = match port_part { + Some(p) => p, + None => { + return Ok(NetAllow { + protocol, + host, + ports: Vec::new(), + all_ports: true, + }); + } + }; // Detect the wildcard token. We split on ',' first so a // single `*` is a clean match — `*,80` is rejected explicitly @@ -1129,6 +1418,65 @@ pub async fn resolve_net_allow( }) } +/// Per-protocol resolved deny policies, ready for `NetworkState`. +pub struct ResolvedNetDenySet { + pub tcp: crate::seccomp::notif::NetworkPolicy, + pub udp: crate::seccomp::notif::NetworkPolicy, + pub icmp: crate::seccomp::notif::NetworkPolicy, +} + +/// Resolve `--net-deny` rules into per-protocol `DenyList` policies. +/// A protocol with no deny rules stays `Unrestricted` (allow-all). +pub fn resolve_net_deny(rules: &[NetDeny]) -> ResolvedNetDenySet { + use crate::seccomp::notif::{NetworkPolicy, PortAllow}; + + let per_proto = |target: Protocol| -> NetworkPolicy { + let mut cidrs: Vec<(IpCidr, PortAllow)> = Vec::new(); + let mut any_ip_ports: HashSet = HashSet::new(); + let mut deny_all = false; + let mut saw_rule = false; + + for rule in rules.iter().filter(|r| r.protocol == target) { + saw_rule = true; + match &rule.target { + DenyTarget::AnyIp => { + if rule.all_ports || target == Protocol::Icmp { + deny_all = true; + } else { + for &p in &rule.ports { + any_ip_ports.insert(p); + } + } + } + DenyTarget::Cidr(c) => { + let pa = if rule.all_ports || target == Protocol::Icmp { + PortAllow::Any + } else { + PortAllow::Specific(rule.ports.iter().copied().collect()) + }; + cidrs.push((*c, pa)); + } + } + } + + if !saw_rule { + NetworkPolicy::Unrestricted + } else { + NetworkPolicy::DenyList { + cidrs, + any_ip_ports, + deny_all, + } + } + }; + + ResolvedNetDenySet { + tcp: per_proto(Protocol::Tcp), + udp: per_proto(Protocol::Udp), + icmp: per_proto(Protocol::Icmp), + } +} + /// Compose the synthetic `/etc/hosts` served to the sandbox. /// /// - **No chroot**: emit the fixed loopback base @@ -1275,9 +1623,27 @@ mod tests { } #[test] - fn netallow_parse_rejects_no_colon() { - let err = NetAllow::parse("example.com").unwrap_err(); - assert!(format!("{}", err).contains("expected")); + fn netallow_bare_host_is_all_ports() { + // No port suffix means "all ports" (port optional), symmetric + // with the `host:*` form. + let r = NetAllow::parse("example.com").unwrap(); + assert_eq!(r.host.as_deref(), Some("example.com")); + assert!(r.all_ports); + assert!(r.ports.is_empty()); + } + + #[test] + fn netallow_bare_star_is_any_host_all_ports() { + let r = NetAllow::parse("*").unwrap(); + assert_eq!(r.host, None); + assert!(r.all_ports); + assert!(r.ports.is_empty()); + } + + #[test] + fn netallow_empty_spec_rejected() { + assert!(NetAllow::parse("").is_err()); + assert!(NetAllow::parse("tcp://").is_err()); } #[test] @@ -1660,4 +2026,199 @@ 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 rules = NetDeny::parse("10.0.0.0/8").unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].protocol, Protocol::Tcp); + assert!(matches!(rules[0].target, DenyTarget::Cidr(_))); + assert!(rules[0].all_ports); + } + + #[test] + fn netdeny_bare_ip_is_host_route_all_ports() { + let rules = NetDeny::parse("169.254.169.254").unwrap(); + assert_eq!(rules.len(), 1); + match &rules[0].target { + DenyTarget::Cidr(c) => assert_eq!(c.prefix_len, 32), + _ => panic!("expected cidr"), + } + assert!(rules[0].all_ports); + } + + #[test] + fn netdeny_cidr_with_port() { + let rules = NetDeny::parse("10.0.0.0/8:443").unwrap(); + assert_eq!(rules[0].ports, vec![443]); + assert!(!rules[0].all_ports); + } + + #[test] + fn netdeny_any_ip_port() { + let rules = NetDeny::parse(":25").unwrap(); + assert!(matches!(rules[0].target, DenyTarget::AnyIp)); + assert_eq!(rules[0].ports, vec![25]); + } + + #[test] + fn netdeny_udp_scheme() { + let rules = NetDeny::parse("udp://192.168.0.0/16:53").unwrap(); + assert_eq!(rules[0].protocol, Protocol::Udp); + assert_eq!(rules[0].ports, vec![53]); + } + + #[test] + fn netdeny_ipv6_bracket_port() { + let rules = NetDeny::parse("[::1]:443").unwrap(); + assert_eq!(rules[0].ports, vec![443]); + match &rules[0].target { + DenyTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), + _ => panic!("expected cidr"), + } + } + + #[test] + fn netdeny_rejects_hostname() { + assert!(NetDeny::parse("evil.com:443").is_err()); + assert!(NetDeny::parse("evil.com").is_err()); + } + + #[test] + fn netdeny_private_token_expands_v4_and_v6() { + let rules = NetDeny::parse("private").unwrap(); + // Each curated CIDR is emitted once per protocol (tcp, udp, icmp). + assert_eq!(rules.len(), PRIVATE_CIDRS.len() * 3); + // Metadata endpoint is covered. + let meta: IpAddr = "169.254.169.254".parse().unwrap(); + assert!(rules.iter().any(|r| matches!(&r.target, DenyTarget::Cidr(c) if c.contains(meta)))); + // IPv6 ULA is covered. + let ula: IpAddr = "fd00::1".parse().unwrap(); + assert!(rules.iter().any(|r| matches!(&r.target, DenyTarget::Cidr(c) if c.contains(ula)))); + } + + #[test] + fn netdeny_bare_ipv6_address_all_ports() { + let rules = NetDeny::parse("::1").unwrap(); + assert_eq!(rules.len(), 1); + assert!(rules[0].all_ports); + match &rules[0].target { + DenyTarget::Cidr(c) => assert_eq!(c.prefix_len, 128), + _ => panic!("expected cidr"), + } + } + + #[test] + fn netdeny_bare_ipv6_cidr_all_ports() { + let rules = NetDeny::parse("fc00::/7").unwrap(); + assert_eq!(rules.len(), 1); + assert!(rules[0].all_ports); + let ula: std::net::IpAddr = "fd00::1".parse().unwrap(); + assert!(matches!(&rules[0].target, DenyTarget::Cidr(c) if c.contains(ula))); + } + + #[test] + fn netdeny_empty_icmp_body_is_rejected() { + assert!(NetDeny::parse("icmp://").is_err()); + } + + #[test] + fn netdeny_bare_star_is_any_ip_all_ports() { + // `*` with no port is the any-IP, all-ports form (port optional, + // symmetric with a bare IP/CIDR). + let rules = NetDeny::parse("*").unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].protocol, Protocol::Tcp); + assert!(matches!(rules[0].target, DenyTarget::AnyIp)); + assert!(rules[0].all_ports); + assert!(rules[0].ports.is_empty()); + } + + #[test] + fn netdeny_udp_bare_star_all_ports() { + let rules = NetDeny::parse("udp://*").unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].protocol, Protocol::Udp); + assert!(matches!(rules[0].target, DenyTarget::AnyIp)); + assert!(rules[0].all_ports); + } + + #[test] + fn netdeny_empty_spec_rejected() { + // An empty body must not silently mean "deny everything". + assert!(NetDeny::parse("").is_err()); + assert!(NetDeny::parse("udp://").is_err()); + } + + // --- resolve_net_deny tests --- + + #[test] + fn resolve_net_deny_groups_per_protocol() { + let rules = NetDeny::parse("10.0.0.0/8").unwrap(); + let set = resolve_net_deny(&rules); + // TCP policy denies 10.x, UDP/ICMP unaffected (still allow-all). + assert!(!set.tcp.allows("10.0.0.1".parse().unwrap(), 443)); + assert!(set.udp.allows("10.0.0.1".parse().unwrap(), 443)); + } + + #[test] + fn resolve_net_deny_private_covers_metadata_all_protocols() { + let rules = NetDeny::parse("private").unwrap(); + let set = resolve_net_deny(&rules); + let meta: std::net::IpAddr = "169.254.169.254".parse().unwrap(); + assert!(!set.tcp.allows(meta, 80)); + assert!(!set.udp.allows(meta, 80)); + } + + #[test] + fn resolve_net_deny_any_ip_port() { + let rules = NetDeny::parse(":25").unwrap(); + let set = resolve_net_deny(&rules); + assert!(!set.tcp.allows("8.8.8.8".parse().unwrap(), 25)); + assert!(set.tcp.allows("8.8.8.8".parse().unwrap(), 80)); + } } diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 23b9c91..c18bcea 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -82,6 +82,7 @@ pub struct FilesystemSection { pub struct NetworkSection { pub bind: Vec, pub allow: Vec, + pub deny: Vec, pub port_remap: bool, } @@ -169,6 +170,7 @@ pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), Sandlo // [network] for p in input.network.bind.iter() { b = b.net_bind_port(*p); } for r in input.network.allow.iter() { b = b.net_allow(r.as_str()); } + for r in input.network.deny.iter() { b = b.net_deny(r.as_str()); } if input.network.port_remap { b = b.port_remap(true); } // [http] @@ -509,6 +511,16 @@ mod tests { assert!(msg.contains("time_start"), "got: {msg}"); } + #[test] + fn profile_network_deny_parses() { + let toml = r#" + [network] + deny = ["10.0.0.0/8", "private"] + "#; + let (policy, _spec) = parse_profile(toml).unwrap(); + assert!(policy.net_deny.len() > 1); + } + #[test] fn isolation_key_is_rejected() { let toml = r#" diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 1de5a9b..047401a 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::{DenyTarget, IpCidr, NetAllow, NetDeny, Protocol}; use crate::protection::{Protection, ProtectionPolicy, ProtectionState, ProtectionStatus}; /// A byte size value. @@ -252,6 +252,9 @@ pub struct Sandbox { /// remain reachable. HTTP rules with wildcard hosts auto-add /// `(Tcp, None, [80])` instead. pub net_allow: Vec, + /// Parsed `--net-deny` rules (default-allow, IP/CIDR/port denylist). + /// Mutually exclusive with `net_allow`. + pub net_deny: Vec, pub net_bind: Vec, // HTTP ACL pub http_allow: Vec, @@ -375,6 +378,7 @@ impl Clone for Sandbox { extra_allow_syscalls: self.extra_allow_syscalls.clone(), protection_policy: self.protection_policy.clone(), net_allow: self.net_allow.clone(), + net_deny: self.net_deny.clone(), net_bind: self.net_bind.clone(), http_allow: self.http_allow.clone(), http_deny: self.http_deny.clone(), @@ -1390,6 +1394,7 @@ impl Sandbox { max_processes: self.max_processes, has_memory_limit: self.max_memory.is_some(), has_net_allowlist: !self.net_allow.is_empty() + || !self.net_deny.is_empty() || self.policy_fn.is_some() || !self.http_allow.is_empty() || !self.http_deny.is_empty(), @@ -1425,33 +1430,40 @@ impl Sandbox { let time_random_state = TimeRandomState::new(time_offset, random_state); let mut net_state = NetworkState::new(); - let no_rules = self.net_allow.is_empty(); - let policy_from = |resolved: &network::ResolvedNetAllow| { - if no_rules || resolved.any_ip_all_ports { - crate::seccomp::notif::NetworkPolicy::Unrestricted - } else { - use crate::seccomp::notif::PortAllow; - let per_ip = resolved - .per_ip - .iter() - .map(|(ip, ports)| { - let allow = if resolved.per_ip_all_ports.contains(ip) { - PortAllow::Any - } else { - PortAllow::Specific(ports.clone()) - }; - (*ip, allow) - }) - .collect(); - crate::seccomp::notif::NetworkPolicy::AllowList { - per_ip, - any_ip_ports: resolved.any_ip_ports.clone(), + if !self.net_deny.is_empty() { + let resolved_deny = network::resolve_net_deny(&self.net_deny); + net_state.tcp_policy = resolved_deny.tcp; + net_state.udp_policy = resolved_deny.udp; + net_state.icmp_policy = resolved_deny.icmp; + } else { + let no_rules = self.net_allow.is_empty(); + let policy_from = |resolved: &network::ResolvedNetAllow| { + if no_rules || resolved.any_ip_all_ports { + crate::seccomp::notif::NetworkPolicy::Unrestricted + } else { + use crate::seccomp::notif::PortAllow; + let per_ip = resolved + .per_ip + .iter() + .map(|(ip, ports)| { + let allow = if resolved.per_ip_all_ports.contains(ip) { + PortAllow::Any + } else { + PortAllow::Specific(ports.clone()) + }; + (*ip, allow) + }) + .collect(); + crate::seccomp::notif::NetworkPolicy::AllowList { + per_ip, + any_ip_ports: resolved.any_ip_ports.clone(), + } } - } - }; - net_state.tcp_policy = policy_from(&resolved_net_allow.tcp); - net_state.udp_policy = policy_from(&resolved_net_allow.udp); - net_state.icmp_policy = policy_from(&resolved_net_allow.icmp); + }; + net_state.tcp_policy = policy_from(&resolved_net_allow.tcp); + net_state.udp_policy = policy_from(&resolved_net_allow.udp); + net_state.icmp_policy = policy_from(&resolved_net_allow.icmp); + } net_state.http_acl_addr = self.rt().http_acl_handle.as_ref().map(|h| h.addr); net_state.http_acl_ports = self.http_ports.iter().copied().collect(); net_state.http_acl_orig_dest = self.rt().http_acl_handle.as_ref().map(|h| h.orig_dest.clone()); @@ -1805,6 +1817,15 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(long = "net-allow", value_name = "SPEC"))] pub net_allow: Vec, + /// `--net-deny`: default-allow networking, block these IPs/CIDRs/ports. + /// Accepts ``, ``, `:`, `:`, `*`, + /// `[]:`, and the `private` token (all internal ranges). + /// The port is optional (no `:port` means all ports). Hostnames are + /// rejected; use `--http-deny` for domains. Repeat the flag for multiple + /// rules. Mutually exclusive with `--net-allow`. + #[cfg_attr(feature = "cli", arg(long = "net-deny", value_name = "SPEC"))] + pub net_deny: Vec, + #[cfg_attr(feature = "cli", arg(long = "net-bind"))] pub net_bind: Vec, @@ -1975,6 +1996,7 @@ impl Clone for SandboxBuilder { extra_deny_syscalls: self.extra_deny_syscalls.clone(), extra_allow_syscalls: self.extra_allow_syscalls.clone(), net_allow: self.net_allow.clone(), + net_deny: self.net_deny.clone(), net_bind: self.net_bind.clone(), http_allow: self.http_allow.clone(), http_deny: self.http_deny.clone(), @@ -2091,6 +2113,12 @@ impl SandboxBuilder { self } + /// Add a `--net-deny` rule. See the field docs for accepted forms. + pub fn net_deny(mut self, spec: impl Into) -> Self { + self.net_deny.push(spec.into()); + self + } + pub fn net_bind_port(mut self, port: u16) -> Self { self.net_bind.push(port); self @@ -2372,6 +2400,22 @@ impl SandboxBuilder { .map(|s| NetAllow::parse(&s)) .collect::>()?; + // Parse --net-deny rules (the `private` token expands here). + // NetDeny::parse returns a Vec per spec, so flatten. + let mut net_deny: Vec = Vec::new(); + for spec in self.net_deny { + net_deny.extend(NetDeny::parse(&spec)?); + } + + // --net-allow and --net-deny are mutually exclusive. Check the + // user-supplied allow count (the original specs), not the post-HTTP + // extension, so a coexisting --http-deny does not false-trigger. + if !net_allow.is_empty() && !net_deny.is_empty() { + return Err(SandboxError::Invalid( + "--net-allow and --net-deny are mutually exclusive".into(), + )); + } + crate::http::extend_net_allow_for_http( &mut net_allow, &http_allow, @@ -2387,6 +2431,7 @@ impl SandboxBuilder { extra_allow_syscalls: self.extra_allow_syscalls, protection_policy: self.protection_policy, net_allow, + net_deny, net_bind: self.net_bind, http_allow, http_deny, @@ -2615,4 +2660,45 @@ mod tests { assert!(!p3.allows_sysv_ipc()); } + #[test] + fn builder_parses_net_deny() { + let policy = Sandbox::builder() + .net_deny("10.0.0.0/8") + .build() + .unwrap(); + assert_eq!(policy.net_deny.len(), 1); + } + + #[test] + fn builder_net_deny_private_expands() { + let policy = Sandbox::builder() + .net_deny("private") + .build() + .unwrap(); + assert!(policy.net_deny.len() > 3); // expanded across CIDRs x protocols + } + + #[test] + fn builder_rejects_net_allow_and_net_deny_together() { + let err = Sandbox::builder() + .net_allow("github.com:443") + .net_deny("10.0.0.0/8") + .build(); + assert!(err.is_err()); + } + + #[test] + fn builder_net_deny_rejects_hostname() { + let err = Sandbox::builder().net_deny("evil.com:443").build(); + assert!(err.is_err()); + } + + #[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/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index 4d0a41c..248c782 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -248,6 +248,18 @@ pub enum NetworkPolicy { /// `*:port`). any_ip_ports: HashSet, }, + /// Default-allow denylist: a connection is permitted unless the + /// destination IP/port matches a deny rule. From `--net-deny`. + DenyList { + /// (network, denied-ports) rules. `PortAllow::Any` denies every + /// port to the network; `Specific` denies only those ports. + cidrs: Vec<(crate::network::IpCidr, PortAllow)>, + /// Ports denied for any IP (the `:port` form). + any_ip_ports: HashSet, + /// Deny everything (the `:*` / `*:*` form). Rare; here for + /// completeness so the form is not silently a no-op. + deny_all: bool, + }, } impl NetworkPolicy { @@ -265,6 +277,27 @@ impl NetworkPolicy { None => false, } } + NetworkPolicy::DenyList { cidrs, any_ip_ports, deny_all } => { + if *deny_all { + return false; + } + if any_ip_ports.contains(&port) { + return false; + } + for (net, denied) in cidrs { + if net.contains(ip) { + match denied { + PortAllow::Any => return false, + PortAllow::Specific(s) => { + if s.contains(&port) { + return false; + } + } + } + } + } + true + } } } } @@ -1842,4 +1875,43 @@ mod tests { assert!(result.is_ok()); assert_eq!(data, 0x1234567890ABCDEF); } + + #[test] + fn denylist_blocks_matching_cidr_allows_rest() { + use crate::network::IpCidr; + let policy = NetworkPolicy::DenyList { + cidrs: vec![(IpCidr::parse("10.0.0.0/8").unwrap(), PortAllow::Any)], + any_ip_ports: HashSet::new(), + deny_all: false, + }; + assert!(!policy.allows("10.1.2.3".parse().unwrap(), 443)); // denied + assert!(policy.allows("8.8.8.8".parse().unwrap(), 443)); // allowed + } + + #[test] + fn denylist_blocks_any_ip_port() { + let mut ports = HashSet::new(); + ports.insert(25u16); + let policy = NetworkPolicy::DenyList { + cidrs: Vec::new(), + any_ip_ports: ports, + deny_all: false, + }; + assert!(!policy.allows("8.8.8.8".parse().unwrap(), 25)); // denied + assert!(policy.allows("8.8.8.8".parse().unwrap(), 80)); // allowed + } + + #[test] + fn denylist_specific_ports_on_cidr() { + use crate::network::IpCidr; + let mut ports = HashSet::new(); + ports.insert(443u16); + let policy = NetworkPolicy::DenyList { + cidrs: vec![(IpCidr::parse("1.2.3.4/32").unwrap(), PortAllow::Specific(ports))], + any_ip_ports: HashSet::new(), + deny_all: false, + }; + assert!(!policy.allows("1.2.3.4".parse().unwrap(), 443)); // denied + assert!(policy.allows("1.2.3.4".parse().unwrap(), 80)); // allowed + } } diff --git a/crates/sandlock-core/tests/integration/test_policy.rs b/crates/sandlock-core/tests/integration/test_policy.rs index 5ab1d9f..09dbbbd 100644 --- a/crates/sandlock-core/tests/integration/test_policy.rs +++ b/crates/sandlock-core/tests/integration/test_policy.rs @@ -47,7 +47,10 @@ fn test_net_allow_parse_grammar() { assert!(NetAllow::parse("foo.com:22,443").is_ok()); assert!(NetAllow::parse(":8080").is_ok()); assert!(NetAllow::parse("*:8080").is_ok()); - assert!(NetAllow::parse("foo.com").is_err()); // missing port + assert!(NetAllow::parse("foo.com").is_ok()); // no port -> all ports + assert!(NetAllow::parse("foo.com").unwrap().all_ports); + assert!(NetAllow::parse("*").is_ok()); // any host, all ports + assert!(NetAllow::parse("").is_err()); // empty rule assert!(NetAllow::parse("foo.com:abc").is_err()); // bad port assert!(NetAllow::parse("foo.com:0").is_err()); // port 0 reserved assert!(NetAllow::parse("foo.com:").is_err()); // empty port list