diff --git a/README.md b/README.md index 81071b9..b36d849 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,19 @@ sandlock run -i -r /usr -r /lib -r /lib64 -r /bin -r /etc -w /tmp -- /bin/sh # Resource limits + timeout sandlock run -m 512M -P 20 -t 30 -- ./compute.sh -# Domain-based network isolation -sandlock run --net-allow-host api.openai.com -r /usr -r /lib -r /etc -- python3 agent.py +# Outbound allowlist — restrict to one host on one port +sandlock run --net-allow api.openai.com:443 -r /usr -r /lib -r /etc -- python3 agent.py + +# Multiple ports for one host, plus a separate any-IP port +sandlock run --net-allow github.com:22,443 --net-allow :8080 \ + -r /usr -r /lib -r /etc -- python3 agent.py + +# UDP — opt in to UDP and allowlist the destination (e.g. DNS) +sandlock run --allow-udp --net-allow 1.1.1.1:53 --net-allow :443 \ + -r /usr -r /lib -r /etc -- ./client # 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 \ --http-allow "GET docs.python.org/*" \ --http-allow "POST api.openai.com/v1/chat/completions" \ @@ -111,13 +120,15 @@ sandlock run \ -r /usr -r /lib -r /etc -- python3 agent.py # HTTPS MITM with user-provided CA (enables ACL on port 443) +# Generate a CA, add the cert to the sandbox's trust store +# (e.g. /etc/ssl/certs/), then pass both files here. sandlock run \ --http-allow "POST api.openai.com/v1/*" \ --https-ca ca.pem --https-key ca-key.pem \ -r /usr -r /lib -r /etc -- python3 agent.py -# TCP port restrictions (Landlock) -sandlock run --net-bind 8080 --net-connect 443 -r /usr -r /lib -r /etc -- python3 server.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 # Clean environment sandlock run --clean-env --env CC=gcc \ @@ -273,7 +284,7 @@ def on_event(event, ctx): policy = Policy( fs_readable=["/usr", "/lib", "/etc"], - net_allow_hosts=["api.example.com"], + net_allow=["api.example.com:443"], ) result = Sandbox(policy, policy_fn=on_event).run(["python3", "agent.py"]) ``` @@ -493,6 +504,75 @@ Map and reduce run in separate sandboxes with independent policies — the mapper has data access, the reducer doesn't. Each clone inherits Landlock + seccomp confinement. `CLONE_ID=0..N-1` is set automatically. +### Network Model + +Outbound traffic is gated by a single endpoint allowlist. Each +`--net-allow` rule names a `(host, ports)` pair, multiple rules are +OR'd, and a destination is permitted iff `(IP, port)` matches at least +one rule. The same allowlist applies to TCP `connect()` and to UDP +`sendto` / `sendmsg` destinations — the latter only relevant when +`--allow-udp` is set, since UDP socket creation is denied by default. + +``` +--net-allow repeatable; no rules = deny all outbound + = host:port[,port,...] (IP-restricted) + | :port | *:port (any IP) +``` + +**Defaults.** With no `--net-allow` and no HTTP ACL flags, Landlock +denies every TCP `connect()`, UDP and raw socket creation are denied +at the seccomp layer, and there is no on-behalf path active. There is +no "allow-all networking" mode — opt in with explicit endpoints. + +**Resolution.** Concrete hostnames are resolved once at sandbox start +and pinned in a synthetic `/etc/hosts`. The synthetic file replaces +the real one only when `--net-allow` includes at least one concrete +host; pure `:port` 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 `*` form is only valid as the host part of a `*:port` rule (alias +for `:port`). + +**Implementation.** Two enforcement paths: + + * **Direct path** — pure `:port` policies (no concrete host) and no + HTTP ACL. Landlock enforces the TCP port allowlist at the kernel + level; no per-syscall overhead. UDP is not covered by Landlock and + therefore always uses the on-behalf path when allowed. + * **On-behalf path** — any concrete host, any HTTP ACL rule, or + `--allow-udp`. Seccomp traps `connect()`, `sendto()`, and + `sendmsg()`; the supervisor checks the `(ip, port)` against the + resolved allowlist and performs the syscall. The HTTP/HTTPS proxy + redirect (when configured) happens here too. + +**HTTP / HTTPS interception.** `--http-allow` / `--http-deny` route +matching ports through a transparent proxy. Each rule with a concrete +host auto-extends `--net-allow` with `host:80` (and `host:443` when +`--https-ca` is set) so the proxy's intercept ports are reachable; +wildcard hosts auto-add `:80` / `:443` (any IP). HTTPS MITM is opt-in: +pass `--https-ca ` and `--https-key ` for a CA *you generate* +and trust inside the sandbox (typically install the cert into the +workload's `/etc/ssl/certs/`). Without `--https-ca`, 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; `--port-remap` adds +on-behalf virtualization for binding. + +**UDP, ICMP, unix.** Default-deny, opt in via dedicated flags: + + * `--allow-udp` enables UDP socket creation. Outbound UDP + destinations are then gated by the same `--net-allow` allowlist + used for TCP — the seccomp on-behalf path also covers `sendto` / + `sendmsg`. Example: `--allow-udp --net-allow 1.1.1.1:53` for DNS. + * `--allow-icmp` narrowly permits `socket(AF_INET, SOCK_RAW, + IPPROTO_ICMP)` and the IPv6 equivalent only — enough for `ping`. + Other raw socket types stay denied. + * AF_UNIX sockets are governed by Landlock's + `LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET`. + ### Port Virtualization Each sandbox gets a full virtual port space. Multiple sandboxes can bind @@ -553,10 +633,11 @@ Policy( deny_syscalls=None, # None = default blocklist allow_syscalls=None, # Allowlist mode (stricter) - # Network - net_allow_hosts=["api.example.com"], # Domain allowlist - net_bind=[8080], # TCP bind ports (Landlock ABI v4+) - net_connect=[443], # TCP connect ports + # Network — see "Network Model" above. Each entry is `host:port[,port,...]`, + # `:port`, or `*:port`. Empty list = deny all outbound. Same allowlist + # gates UDP destinations when allow_udp=True (e.g. `:53` for DNS). + net_allow=["api.example.com:443", "github.com:22,443", ":8080"], + net_bind=[8080], # TCP bind ports (Landlock; ABI v4+) # HTTP ACL (transparent proxy) http_allow=["POST api.openai.com/v1/*"], # Allow rules (METHOD host/path) @@ -565,9 +646,9 @@ Policy( https_ca="ca.pem", # CA cert for HTTPS MITM (adds port 443) https_key="ca-key.pem", # CA key for HTTPS MITM - # Socket restrictions - no_raw_sockets=True, # Block SOCK_RAW (default) - no_udp=False, # Block SOCK_DGRAM + # Socket restrictions (raw sockets and UDP denied by default) + allow_udp=False, # CLI: --allow-udp; outbound UDP still gated by net_allow + allow_icmp=False, # CLI: --allow-icmp; permits ICMP raw only (AF_INET/AF_INET6 + SOCK_RAW + IPPROTO_ICMP[V6]) # Resources max_memory="512M", # Memory limit diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index f31937b..7e32c9e 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -28,12 +28,16 @@ enum Command { max_processes: Option, #[arg(short = 't', long)] timeout: Option, - #[arg(long = "net-allow-host")] - net_allow_host: Vec, + /// Outbound endpoint allow rule (TCP, plus UDP when + /// `--allow-udp` is set). Repeatable. Each value is + /// `host:port[,port,...]` (IP-restricted), `:port` or `*:port` + /// (any IP). Examples: `api.openai.com:443`, + /// `github.com:22,443`, `:8080`, `1.1.1.1:53`. + /// See README "Network Model". + #[arg(long = "net-allow", value_name = "SPEC")] + net_allow: Vec, #[arg(long = "net-bind")] net_bind: Vec, - #[arg(long = "net-connect")] - net_connect: Vec, #[arg(long)] time_start: Option, #[arg(long)] @@ -65,10 +69,18 @@ enum Command { fs_storage: Option, #[arg(long = "max-disk")] max_disk: Option, - #[arg(long = "net-allow", value_name = "PROTO")] - net_allow: Vec, - #[arg(long = "net-deny", value_name = "PROTO")] - net_deny: Vec, + /// Allow UDP socket creation. UDP is denied by default; this + /// turns it back on. Outbound UDP destinations are still + /// gated by `--net-allow` (the same endpoint allowlist used + /// for TCP). + #[arg(long = "allow-udp")] + allow_udp: bool, + /// Allow ICMP raw sockets only — `socket(AF_INET, SOCK_RAW, + /// IPPROTO_ICMP)` and the IPv6 equivalent. Other `SOCK_RAW` + /// types stay denied. Useful for `ping` without granting full + /// packet-crafting capability. + #[arg(long = "allow-icmp")] + allow_icmp: bool, #[arg(long = "http-allow", value_name = "RULE")] http_allow: Vec, #[arg(long = "http-deny", value_name = "RULE")] @@ -163,10 +175,10 @@ async fn main() -> Result<()> { match cli.command { Command::Run { fs_read, fs_write, max_memory, max_processes, timeout, - net_allow_host, net_bind, net_connect, time_start, random_seed, + net_allow, net_bind, time_start, random_seed, clean_env, num_cpus, profile: profile_name, status_fd, max_cpu, max_open_files, chroot, uid, workdir, cwd, - fs_isolation, fs_storage, max_disk, net_allow, net_deny, + fs_isolation, fs_storage, max_disk, allow_udp, allow_icmp, http_allow, http_deny, http_ports, https_ca, https_key, port_remap, no_randomize_memory, no_huge_pages, deterministic_dirs, name, no_coredump, env_vars, exec_shell, interactive: _, fs_deny, fs_mount, cpu_cores, gpu_devices, image, dry_run, no_supervisor, cmd } => @@ -174,8 +186,8 @@ async fn main() -> Result<()> { if no_supervisor { validate_no_supervisor( &max_memory, &max_processes, &max_cpu, &max_open_files, - &timeout, &net_allow_host, &net_bind, &net_connect, - &net_allow, &net_deny, &http_allow, &http_deny, &http_ports, + &timeout, &net_allow, &net_bind, + allow_udp, allow_icmp, &http_allow, &http_deny, &http_ports, &num_cpus, &random_seed, &time_start, no_randomize_memory, no_huge_pages, deterministic_dirs, &name, &chroot, &image, &uid, &workdir, &cwd, &fs_isolation, &fs_storage, @@ -235,12 +247,13 @@ async fn main() -> Result<()> { for p in &base.fs_readable { b = b.fs_read(p); } for p in &base.fs_writable { b = b.fs_write(p); } for p in &base.fs_denied { b = b.fs_deny(p); } - if let Some(hosts) = &base.net_allow_hosts { - b = b.net_restrict_hosts(); - for h in hosts { b = b.net_allow_host(h); } + for rule in &base.net_allow { + let port_csv: Vec = rule.ports.iter().map(|p| p.to_string()).collect(); + let host_part = rule.host.as_deref().unwrap_or(""); + let spec = format!("{}:{}", host_part, port_csv.join(",")); + b = b.net_allow(spec); } for p in &base.net_bind { b = b.net_bind_port(*p); } - for p in &base.net_connect { b = b.net_connect_port(*p); } for rule in &base.http_allow { let s = format!("{} {}{}", rule.method, rule.host, rule.path); b = b.http_allow(&s); @@ -257,8 +270,8 @@ async fn main() -> Result<()> { if let Some(cpu) = base.max_cpu { b = b.max_cpu(cpu); } if let Some(seed) = base.random_seed { b = b.random_seed(seed); } if let Some(n) = base.num_cpus { b = b.num_cpus(n); } - b = b.no_raw_sockets(base.no_raw_sockets); - b = b.no_udp(base.no_udp); + b = b.allow_udp(base.allow_udp); + b = b.allow_icmp(base.allow_icmp); b = b.clean_env(base.clean_env); if let Some(ref w) = base.workdir { b = b.workdir(w); } if let Some(ref c) = base.cwd { b = b.cwd(c); } @@ -272,9 +285,8 @@ async fn main() -> Result<()> { for p in &fs_write { builder = builder.fs_write(p); } if let Some(ref m) = max_memory { builder = builder.max_memory(ByteSize::parse(m)?); } if let Some(n) = max_processes { builder = builder.max_processes(n); } - for h in &net_allow_host { builder = builder.net_allow_host(h); } + for spec in &net_allow { builder = builder.net_allow(spec); } for p in &net_bind { builder = builder.net_bind_port(*p); } - for p in &net_connect { builder = builder.net_connect_port(*p); } if let Some(seed) = random_seed { builder = builder.random_seed(seed); } if clean_env { builder = builder.clean_env(true); } if let Some(n) = num_cpus { builder = builder.num_cpus(n); } @@ -306,19 +318,12 @@ async fn main() -> Result<()> { } if let Some(ref path) = fs_storage { builder = builder.fs_storage(path); } if let Some(ref s) = max_disk { builder = builder.max_disk(ByteSize::parse(s)?); } - for proto in &net_allow { - match proto.as_str() { - "icmp" => { builder = builder.no_raw_sockets(false); } - other => return Err(anyhow!("unknown --net-allow protocol: {}", other)), - } - } - for proto in &net_deny { - match proto.as_str() { - "raw" => { builder = builder.no_raw_sockets(true); } - "udp" => { builder = builder.no_udp(true); } - other => return Err(anyhow!("unknown --net-deny protocol: {}", other)), - } - } + if allow_udp { builder = builder.allow_udp(true); } + // --allow-icmp narrowly permits ICMP raw sockets; arbitrary + // raw sockets stay denied. The seccomp filter inspects the + // protocol arg of `socket()` so non-ICMP `SOCK_RAW` is + // still rejected. + if allow_icmp { builder = builder.allow_icmp(true); } for rule in &http_allow { builder = builder.http_allow(rule); } for rule in &http_deny { builder = builder.http_deny(rule); } for port in &http_ports { builder = builder.http_port(*port); } @@ -410,9 +415,14 @@ async fn main() -> Result<()> { sb.spawn(&cmd_strs).await?; let pid = sb.pid().unwrap_or(0); + let registered_hosts: Vec = policy + .net_allow + .iter() + .filter_map(|r| r.host.clone()) + .collect(); if let Err(e) = network_registry::register( &sandbox_name, pid, std::collections::HashMap::new(), - policy.net_allow_hosts.clone().unwrap_or_default(), + registered_hosts, None, // virtual_etc_hosts populated by core at runtime ) { eprintln!("sandlock: network registry: {}", e); @@ -581,11 +591,10 @@ fn validate_no_supervisor( max_cpu: &Option, max_open_files: &Option, timeout: &Option, - net_allow_host: &[String], - net_bind: &[u16], - net_connect: &[u16], net_allow: &[String], - net_deny: &[String], + net_bind: &[u16], + allow_udp: bool, + allow_icmp: bool, http_allow: &[String], http_deny: &[String], http_ports: &[u16], @@ -619,11 +628,10 @@ fn validate_no_supervisor( if max_cpu.is_some() { bad.push("--max-cpu"); } if max_open_files.is_some() { bad.push("--max-open-files"); } if timeout.is_some() { bad.push("--timeout"); } - if !net_allow_host.is_empty() { bad.push("--net-allow-host"); } - if !net_bind.is_empty() { bad.push("--net-bind"); } - if !net_connect.is_empty() { bad.push("--net-connect"); } if !net_allow.is_empty() { bad.push("--net-allow"); } - if !net_deny.is_empty() { bad.push("--net-deny"); } + if !net_bind.is_empty() { bad.push("--net-bind"); } + if allow_udp { bad.push("--allow-udp"); } + if allow_icmp { bad.push("--allow-icmp"); } if !http_allow.is_empty() { bad.push("--http-allow"); } if !http_deny.is_empty() { bad.push("--http-deny"); } if !http_ports.is_empty() { bad.push("--http-port"); } diff --git a/crates/sandlock-core/src/context.rs b/crates/sandlock-core/src/context.rs index 389ab28..1865666 100644 --- a/crates/sandlock-core/src/context.rs +++ b/crates/sandlock-core/src/context.rs @@ -15,7 +15,7 @@ use crate::sys::structs::{ SECCOMP_RET_ALLOW, SECCOMP_RET_ERRNO, SIOCETHTOOL, SIOCGIFADDR, SIOCGIFBRDADDR, SIOCGIFCONF, SIOCGIFDSTADDR, SIOCGIFFLAGS, SIOCGIFHWADDR, SIOCGIFINDEX, SIOCGIFNAME, SIOCGIFNETMASK, - SOCK_DGRAM, SOCK_RAW, SOCK_TYPE_MASK, TIOCLINUX, TIOCSTI, + SOCK_DGRAM, SOCK_RAW, SOCK_TYPE_MASK, IPPROTO_ICMP, IPPROTO_ICMPV6, TIOCLINUX, TIOCSTI, PR_SET_DUMPABLE, PR_SET_SECUREBITS, PR_SET_PTRACER, OFFSET_ARGS0_LO, OFFSET_ARGS1_LO, OFFSET_ARGS2_LO, OFFSET_ARGS3_LO, OFFSET_NR, SockFilter, @@ -249,7 +249,7 @@ pub fn notif_syscalls(policy: &Policy) -> Vec { nrs.push(libc::SYS_shmget as u32); } - if policy.net_allow_hosts.is_some() + if !policy.net_allow.is_empty() || policy.policy_fn.is_some() || !policy.http_allow.is_empty() || !policy.http_deny.is_empty() @@ -516,11 +516,18 @@ pub fn arg_filters(policy: &Policy) -> Vec { } // --- socket: block SOCK_RAW and/or SOCK_DGRAM on AF_INET/AF_INET6 --- + // + // Raw sockets are always denied by default. The narrow `allow_icmp` + // carve-out permits only `socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)` + // and the IPv6 equivalent — handled by a separate `socket()` filter + // further down. When `allow_icmp` is set, SOCK_RAW is excluded from + // the simple blocked_types list so the carve-out can decide. + let raw_narrow = policy.allow_icmp; let mut blocked_types: Vec = Vec::new(); - if policy.no_raw_sockets { + if !policy.allow_icmp { blocked_types.push(SOCK_RAW); } - if policy.no_udp { + if !policy.allow_udp { blocked_types.push(SOCK_DGRAM); } @@ -555,6 +562,44 @@ pub fn arg_filters(policy: &Policy) -> Vec { insns.push(stmt(BPF_RET | BPF_K, ret_errno)); } + // --- socket: ICMP-only carve-out for SOCK_RAW --- + // Active when raw sockets are otherwise denied AND --allow-icmp is set. + // Permits `socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)` and + // `socket(AF_INET6, SOCK_RAW, IPPROTO_ICMPV6)`; denies every other + // SOCK_RAW. The block has 14 instructions; offsets reference the + // post-block instruction count (skip-to-end). + if raw_narrow { + // INST 0: LD NR + insns.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_NR)); + // INST 1: JEQ socket → fall through (jt=0); not socket → skip 12 + insns.push(jump(BPF_JMP | BPF_JEQ | BPF_K, nr_socket, 0, 12)); + // INST 2-3: LD type, AND TYPE_MASK + insns.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_ARGS1_LO)); + insns.push(stmt(BPF_ALU | BPF_AND | BPF_K, SOCK_TYPE_MASK)); + // INST 4: JEQ SOCK_RAW → fall through; not raw → skip 9 (allow) + insns.push(jump(BPF_JMP | BPF_JEQ | BPF_K, SOCK_RAW, 0, 9)); + // INST 5: LD domain + insns.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_ARGS0_LO)); + // INST 6: JEQ AF_INET → fall to v4 proto check; else skip 3 to v6 check at INST 10 + insns.push(jump(BPF_JMP | BPF_JEQ | BPF_K, AF_INET, 0, 3)); + // INST 7: LD proto (arg2) + insns.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_ARGS2_LO)); + // INST 8: JEQ IPPROTO_ICMP → skip 5 to end (allow); else fall to RET errno + insns.push(jump(BPF_JMP | BPF_JEQ | BPF_K, IPPROTO_ICMP, 5, 0)); + // INST 9: RET errno (v4 SOCK_RAW with non-ICMP proto) + insns.push(stmt(BPF_RET | BPF_K, ret_errno)); + // INST 10: JEQ AF_INET6 → fall to v6 proto check; else skip 2 to RET errno + // (other AF + SOCK_RAW, e.g. AF_PACKET/AF_NETLINK, must be denied) + insns.push(jump(BPF_JMP | BPF_JEQ | BPF_K, AF_INET6, 0, 2)); + // INST 11: LD proto + insns.push(stmt(BPF_LD | BPF_W | BPF_ABS, OFFSET_ARGS2_LO)); + // INST 12: JEQ IPPROTO_ICMPV6 → skip 1 past RET (allow); else fall to RET errno + insns.push(jump(BPF_JMP | BPF_JEQ | BPF_K, IPPROTO_ICMPV6, 1, 0)); + // INST 13: RET errno (v6 SOCK_RAW with non-ICMPv6 proto) + insns.push(stmt(BPF_RET | BPF_K, ret_errno)); + // (post-block — fall through to wait4 block below) + } + // --- wait4: skip notification for WNOHANG/WNOWAIT (non-blocking) --- // wait4(pid, status, options, rusage) — options is arg2 // 5 instructions: @@ -1027,7 +1072,7 @@ mod tests { #[test] fn test_notif_syscalls_net() { let policy = Policy::builder() - .net_allow_host("example.com") + .net_allow("example.com:443") .build() .unwrap(); let nrs = notif_syscalls(&policy); @@ -1133,7 +1178,8 @@ mod tests { #[test] fn test_arg_filters_raw_sockets() { use crate::sys::structs::{BPF_ALU, BPF_AND, BPF_JEQ, BPF_JMP, BPF_K}; - let policy = Policy::builder().no_raw_sockets(true).build().unwrap(); + // Raw sockets are blocked by default; allow_icmp is false. + let policy = Policy::builder().build().unwrap(); let filters = arg_filters(&policy); // Should have AF_INET check assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K) @@ -1150,9 +1196,10 @@ mod tests { } #[test] - fn test_arg_filters_no_udp() { + fn test_arg_filters_udp_denied_by_default() { use crate::sys::structs::{BPF_JEQ, BPF_JMP, BPF_K}; - let policy = Policy::builder().no_udp(true).build().unwrap(); + // UDP is denied by default; allow_udp(false) is the default state. + let policy = Policy::builder().build().unwrap(); let filters = arg_filters(&policy); // Should have JEQ SOCK_DGRAM assert!(filters.iter().any(|f| f.code == (BPF_JMP | BPF_JEQ | BPF_K) diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index 5df0c2f..61a937a 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -280,7 +280,22 @@ pub fn confine(policy: &Policy) -> Result<(), SandlockError> { SandlockError::Sandbox(crate::error::SandboxError::Confinement(e)) })?; } - for &port in &policy.net_connect { + // For TCP connect, Landlock is the only enforcer on the direct path. + // The on-behalf path (when enabled) re-checks (ip, port) against the + // resolved allowlist, but Landlock must already permit the port or + // the kernel rejects before seccomp gets a chance to dispatch. Allow + // every port that any --net-allow rule mentions, plus every HTTP + // intercept port; the on-behalf check ensures the IP also matches. + let mut connect_ports: std::collections::HashSet = std::collections::HashSet::new(); + for rule in &policy.net_allow { + for &p in &rule.ports { + connect_ports.insert(p); + } + } + for &p in &policy.http_ports { + connect_ports.insert(p); + } + for port in connect_ports { add_net_rule(&ruleset_fd, port, LANDLOCK_ACCESS_NET_CONNECT_TCP).map_err(|e| { SandlockError::Sandbox(crate::error::SandboxError::Confinement(e)) })?; diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index 5274152..e0d3533 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -70,10 +70,10 @@ pub fn confine_current_process(policy: &Policy) -> Result<(), SandlockError> { // Build a stripped policy with only Landlock-native fields that // confine_current_process supports: filesystem + IPC + signals. - // Network port rules are excluded — they require the full sandbox. + // Network rules are excluded — they require the full sandbox. let mut stripped = policy.clone(); stripped.net_bind.clear(); - stripped.net_connect.clear(); + stripped.net_allow.clear(); // Apply Landlock rules landlock::confine(&stripped) diff --git a/crates/sandlock-core/src/network.rs b/crates/sandlock-core/src/network.rs index d8d99f4..cf3dc6e 100644 --- a/crates/sandlock-core/src/network.rs +++ b/crates/sandlock-core/src/network.rs @@ -3,7 +3,7 @@ // Intercepts connect/sendto/sendmsg syscalls, extracts the destination IP from // the child's memory, and checks it against an allowlist of resolved IPs. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::io; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::os::unix::io::{AsRawFd, RawFd}; @@ -96,22 +96,33 @@ async fn connect_on_behalf( Err(_) => return NotifAction::Errno(libc::EIO), }; - // 2. Check IP against allowlist + // 2. Check destination (ip, port) against the endpoint allowlist. + // The on-behalf supervisor performs the connect outside Landlock, + // so this check is the only port enforcement on this path. if let Some(ip) = parse_ip_from_sockaddr(&addr_bytes) { + let dest_port = parse_port_from_sockaddr(&addr_bytes); let ns = ctx.network.lock().await; let live_policy = { let pfs = ctx.policy_fn.lock().await; pfs.live_policy.clone() }; - if let crate::seccomp::notif::NetworkPolicy::AllowList(ref allowed) = - ns.effective_network_policy(notif.pid, live_policy.as_ref()) - { - if !allowed.contains(&ip) { + let effective = ns.effective_network_policy(notif.pid, live_policy.as_ref()); + match (effective, dest_port) { + (crate::seccomp::notif::NetworkPolicy::Unrestricted, _) => { + // No allowlist active — Landlock direct path enforces ports. + // (Reachable when on-behalf is enabled solely by HTTP ACL.) + } + (policy, Some(p)) => { + if !policy.allows(ip, p) { + return NotifAction::Errno(ECONNREFUSED); + } + } + (_, None) => { + // Couldn't parse port from sockaddr — fail closed. return NotifAction::Errno(ECONNREFUSED); } } // Check for HTTP ACL redirect - let dest_port = parse_port_from_sockaddr(&addr_bytes); let http_acl_addr = ns.http_acl_addr; let http_acl_intercept = dest_port.map_or(false, |p| ns.http_acl_ports.contains(&p)); let http_acl_orig_dest = ns.http_acl_orig_dest.clone(); @@ -327,18 +338,22 @@ async fn sendto_on_behalf( Err(_) => return NotifAction::Errno(libc::EIO), }; - // 2. Check IP against allowlist + // 2. Check (ip, port) against the endpoint allowlist. if let Some(ip) = parse_ip_from_sockaddr(&addr_bytes) { + let dest_port = parse_port_from_sockaddr(&addr_bytes); let ns = ctx.network.lock().await; let live_policy = { let pfs = ctx.policy_fn.lock().await; pfs.live_policy.clone() }; - if let crate::seccomp::notif::NetworkPolicy::AllowList(ref allowed) = - ns.effective_network_policy(notif.pid, live_policy.as_ref()) - { - if !allowed.contains(&ip) { - return NotifAction::Errno(ECONNREFUSED); + let effective = ns.effective_network_policy(notif.pid, live_policy.as_ref()); + if !matches!(effective, crate::seccomp::notif::NetworkPolicy::Unrestricted) { + match dest_port { + Some(p) if !effective.allows(ip, p) => { + return NotifAction::Errno(ECONNREFUSED); + } + None => return NotifAction::Errno(ECONNREFUSED), + Some(_) => {} } } drop(ns); @@ -434,22 +449,26 @@ async fn sendmsg_on_behalf( Err(_) => return NotifAction::Errno(libc::EIO), }; - // 3. Check IP against allowlist + // 3. Check (ip, port) against the endpoint allowlist. let ip = match parse_ip_from_sockaddr(&addr_bytes) { Some(ip) => ip, None => return NotifAction::Continue, // Non-IP family — allow through }; + let dest_port = parse_port_from_sockaddr(&addr_bytes); let ns = ctx.network.lock().await; let live_policy = { let pfs = ctx.policy_fn.lock().await; pfs.live_policy.clone() }; - if let crate::seccomp::notif::NetworkPolicy::AllowList(ref allowed) = - ns.effective_network_policy(notif.pid, live_policy.as_ref()) - { - if !allowed.contains(&ip) { - return NotifAction::Errno(ECONNREFUSED); + let effective = ns.effective_network_policy(notif.pid, live_policy.as_ref()); + if !matches!(effective, crate::seccomp::notif::NetworkPolicy::Unrestricted) { + match dest_port { + Some(p) if !effective.allows(ip, p) => { + return NotifAction::Errno(ECONNREFUSED); + } + None => return NotifAction::Errno(ECONNREFUSED), + Some(_) => {} } } drop(ns); @@ -578,59 +597,64 @@ pub(crate) async fn handle_net( } // ============================================================ -// resolve_hosts — resolve domain names to IPs +// resolve_net_allow — resolve --net-allow rules to runtime allowlist // ============================================================ -/// Result of resolving domain names: the IP allowlist and the `/etc/hosts` -/// content to inject into the sandbox so that sandboxed processes can -/// resolve allowed hostnames without contacting a DNS server. -pub struct ResolvedHosts { - /// Set of allowed IPs (for the network allowlist). - pub ips: HashSet, - /// Synthetic `/etc/hosts` content mapping allowed hostnames to their IPs. - pub etc_hosts: String, +/// Resolved form of `Policy::net_allow`, ready for the on-behalf path. +pub struct ResolvedNetAllow { + /// Per-IP port rules (each concrete-host entry resolves to one or + /// more IPs). + pub per_ip: HashMap>, + /// Ports permitted to any IP (the `:port` form). + pub any_ip_ports: HashSet, + /// Synthetic `/etc/hosts` content for any concrete hostnames. + /// `None` when no concrete hostnames are present (real `/etc/hosts` + /// stays visible). + pub etc_hosts: Option, } -/// Resolve a list of domain names to IP addresses. -/// -/// Always includes loopback addresses (127.0.0.1 and ::1). -/// Uses tokio's async DNS resolver. -/// -/// Returns both the IP allowlist and a synthetic `/etc/hosts` file so -/// sandboxed processes can resolve allowed hostnames without DNS access. -pub async fn resolve_hosts(hosts: &[String]) -> io::Result { - let mut ips = HashSet::new(); - - // Always allow loopback - ips.insert(IpAddr::V4(Ipv4Addr::LOCALHOST)); - ips.insert(IpAddr::V6(Ipv6Addr::LOCALHOST)); - - // Build /etc/hosts content: start with loopback entries +/// Resolve `--net-allow` rules into the runtime allowlist. +pub async fn resolve_net_allow( + rules: &[crate::policy::NetAllow], +) -> io::Result { + let mut per_ip: HashMap> = HashMap::new(); + let mut any_ip_ports: HashSet = HashSet::new(); let mut etc_hosts = String::from("127.0.0.1 localhost\n::1 localhost\n"); + let mut has_concrete_host = false; - for host in hosts { - // Append a dummy port for lookup_host - let addr = format!("{}:0", host); - let result = tokio::net::lookup_host(addr.as_str()).await; - match result { - Ok(resolved) => { + for rule in rules { + match &rule.host { + None => { + for &p in &rule.ports { + any_ip_ports.insert(p); + } + } + Some(host) => { + has_concrete_host = true; + let addr = format!("{}:0", host); + let resolved = tokio::net::lookup_host(addr.as_str()).await.map_err(|e| { + io::Error::new( + e.kind(), + format!("failed to resolve host '{}': {}", host, e), + ) + })?; for socket_addr in resolved { let ip = socket_addr.ip(); - ips.insert(ip); + let entry = per_ip.entry(ip).or_default(); + for &p in &rule.ports { + entry.insert(p); + } etc_hosts.push_str(&format!("{} {}\n", ip, host)); } } - Err(e) => { - // Return error on DNS failure to avoid silently skipping hosts - return Err(io::Error::new( - e.kind(), - format!("failed to resolve host '{}': {}", host, e), - )); - } } } - Ok(ResolvedHosts { ips, etc_hosts }) + Ok(ResolvedNetAllow { + per_ip, + any_ip_ports, + etc_hosts: if has_concrete_host { Some(etc_hosts) } else { None }, + }) } // ============================================================ @@ -640,24 +664,38 @@ pub async fn resolve_hosts(hosts: &[String]) -> io::Result { #[cfg(test)] mod tests { use super::*; + use crate::policy::NetAllow; #[tokio::test] - async fn test_resolve_hosts_loopback() { - let resolved = resolve_hosts(&[]).await.unwrap(); - assert!(resolved.ips.contains(&IpAddr::V4(Ipv4Addr::LOCALHOST))); - assert!(resolved.ips.contains(&IpAddr::V6(Ipv6Addr::LOCALHOST))); + async fn test_resolve_net_allow_empty() { + let resolved = resolve_net_allow(&[]).await.unwrap(); + assert!(resolved.per_ip.is_empty()); + assert!(resolved.any_ip_ports.is_empty()); + assert!(resolved.etc_hosts.is_none()); + } + + #[tokio::test] + async fn test_resolve_net_allow_concrete_host() { + let rules = vec![NetAllow { + host: Some("localhost".to_string()), + ports: vec![80, 443], + }]; + let resolved = resolve_net_allow(&rules).await.unwrap(); + // localhost should resolve to at least one loopback addr. + assert!(!resolved.per_ip.is_empty()); + for ports in resolved.per_ip.values() { + assert!(ports.contains(&80)); + assert!(ports.contains(&443)); + } + assert!(resolved.etc_hosts.as_deref().unwrap_or("").contains("localhost")); } #[tokio::test] - async fn test_resolve_hosts_with_domain() { - let hosts = vec!["localhost".to_string()]; - let resolved = resolve_hosts(&hosts).await.unwrap(); - // localhost should resolve to loopback - assert!( - resolved.ips.contains(&IpAddr::V4(Ipv4Addr::LOCALHOST)) - || resolved.ips.contains(&IpAddr::V6(Ipv6Addr::LOCALHOST)) - ); - // etc_hosts should contain localhost entry - assert!(resolved.etc_hosts.contains("localhost")); + async fn test_resolve_net_allow_any_ip() { + let rules = vec![NetAllow { host: None, ports: vec![8080] }]; + let resolved = resolve_net_allow(&rules).await.unwrap(); + assert!(resolved.per_ip.is_empty()); + assert!(resolved.any_ip_ports.contains(&8080)); + assert!(resolved.etc_hosts.is_none()); } } diff --git a/crates/sandlock-core/src/policy.rs b/crates/sandlock-core/src/policy.rs index d920b01..87de4b4 100644 --- a/crates/sandlock-core/src/policy.rs +++ b/crates/sandlock-core/src/policy.rs @@ -74,6 +74,57 @@ pub enum BranchAction { Keep, } +/// A network endpoint allow rule. +/// +/// Each rule permits TCP `connect()` to one host (or any IP, for the +/// `:port` form) on a specific set of ports. Multiple rules are OR'd: +/// a connection is permitted if any rule matches both the destination +/// IP and the destination port. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct NetAllow { + /// Hostname; `None` means "any IP" (the `:port` form). + pub host: Option, + /// Permitted ports. Must be non-empty. + pub ports: Vec, +} + +impl NetAllow { + /// Parse a `host:port[,port,...]` / `:port` / `*:port` spec. + pub fn parse(s: &str) -> Result { + let (host_part, port_part) = s.rsplit_once(':').ok_or_else(|| { + PolicyError::Invalid(format!( + "--net-allow: expected `host:port` or `:port`, got `{}`", + s + )) + })?; + let host = match host_part { + "" | "*" => None, + h => Some(h.to_string()), + }; + let mut ports = Vec::new(); + for p in port_part.split(',') { + let p = p.trim(); + let n: u16 = p.parse().map_err(|_| { + PolicyError::Invalid(format!("--net-allow: invalid port `{}` in `{}`", p, s)) + })?; + if n == 0 { + return Err(PolicyError::Invalid(format!( + "--net-allow: port 0 is not valid in `{}`", + s + ))); + } + ports.push(n); + } + if ports.is_empty() { + return Err(PolicyError::Invalid(format!( + "--net-allow: at least one port required in `{}`", + s + ))); + } + Ok(NetAllow { host, ports }) + } +} + /// An HTTP access control rule. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct HttpRule { @@ -257,19 +308,32 @@ pub struct Policy { pub allow_syscalls: Option>, // Network - /// Allowed domain names. + /// Outbound endpoint allowlist as a list of `(host?, ports)` rules. + /// Applies to TCP `connect()` and to UDP `sendto`/`sendmsg` + /// destinations when `allow_udp` is set. /// - /// * `None` — unrestricted: the real `/etc/hosts` is visible and DNS is - /// not virtualized. - /// * `Some(empty)` — deny all: `/etc/hosts` is virtualized to an empty - /// map and the IP allowlist is empty (no hosts resolvable). - /// * `Some(nonempty)` — allowlist: only these domains are resolved and - /// their IPs placed in the allowlist. - pub net_allow_hosts: Option>, + /// Empty `net_allow` and empty `http_allow`/`http_deny` together + /// mean "deny all outbound" (Landlock direct path denies, no + /// on-behalf path is enabled). Otherwise, the on-behalf path + /// enforces these rules: a destination is permitted iff any rule + /// matches both the destination IP (or has `host: None` = any IP) + /// and the destination port — same check for TCP and UDP. + /// + /// HTTP rules with concrete hosts auto-add a matching `(host, [80])` + /// (and `(host, [443])` when `--https-ca` is set) entry at build + /// time so the proxy's intercept ports remain reachable. HTTP rules + /// with wildcard hosts auto-add `(None, [80])` instead. + pub net_allow: Vec, pub net_bind: Vec, - pub net_connect: Vec, - pub no_raw_sockets: bool, - pub no_udp: bool, + /// Permit UDP socket creation (`socket(_, SOCK_DGRAM, _)`). UDP is + /// denied by default; outbound destinations remain gated by the + /// `net_allow` endpoint allowlist when set. + pub allow_udp: bool, + /// Narrow ICMP carve-out: permit `socket(AF_INET, SOCK_RAW, + /// IPPROTO_ICMP)` and the IPv6 equivalent. All other raw socket + /// types remain denied. Useful for `ping` without granting full + /// packet-crafting capability. + pub allow_icmp: bool, // HTTP ACL pub http_allow: Vec, @@ -359,11 +423,11 @@ pub struct PolicyBuilder { deny_syscalls: Option>, allow_syscalls: Option>, - net_allow_hosts: Option>, + /// Raw `--net-allow` specs; parsed in `build()` to surface errors. + net_allow: Vec, net_bind: Vec, - net_connect: Vec, - no_raw_sockets: Option, - no_udp: bool, + allow_udp: bool, + allow_icmp: bool, http_allow: Vec, http_deny: Vec, @@ -442,20 +506,16 @@ impl PolicyBuilder { self } - /// Add a host to the domain allowlist. Implicitly enables host - /// restriction (switches `net_allow_hosts` from `None` to `Some`). - pub fn net_allow_host(mut self, host: impl Into) -> Self { - self.net_allow_hosts - .get_or_insert_with(Vec::new) - .push(host.into()); - self - } - - /// Enable host restriction without adding any hosts. The resulting - /// sandbox has an empty `/etc/hosts` and no resolvable domains — - /// equivalent to "deny all hosts". - pub fn net_restrict_hosts(mut self) -> Self { - self.net_allow_hosts.get_or_insert_with(Vec::new); + /// Add a network endpoint rule. Spec is `host:port[,port,...]`, + /// `:port`, or `*:port`. Validated at `build()` time so callers + /// receive parse errors via the standard `PolicyBuilder` flow. + /// + /// Examples: + /// - `.net_allow("api.openai.com:443")` — HTTPS to OpenAI only + /// - `.net_allow("github.com:22,443")` — SSH and HTTPS to GitHub + /// - `.net_allow(":8080")` — any IP on port 8080 + pub fn net_allow(mut self, spec: impl Into) -> Self { + self.net_allow.push(spec.into()); self } @@ -464,18 +524,17 @@ impl PolicyBuilder { self } - pub fn net_connect_port(mut self, port: u16) -> Self { - self.net_connect.push(port); - self - } - - pub fn no_raw_sockets(mut self, v: bool) -> Self { - self.no_raw_sockets = Some(v); + /// Permit UDP socket creation. UDP is denied by default; + /// outbound destinations remain gated by `net_allow` if set. + pub fn allow_udp(mut self, v: bool) -> Self { + self.allow_udp = v; self } - pub fn no_udp(mut self, v: bool) -> Self { - self.no_udp = v; + /// Permit `socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)` and the IPv6 + /// equivalent only. Other raw socket types stay denied. + pub fn allow_icmp(mut self, v: bool) -> Self { + self.allow_icmp = v; self } @@ -491,11 +550,6 @@ impl PolicyBuilder { pub fn http_port(mut self, port: u16) -> Self { self.http_ports.push(port); - // HTTP ACL intercepts TCP connections on this port, so it must be - // in the Landlock net_connect allowlist too. - if !self.net_connect.contains(&port) { - self.net_connect.push(port); - } self } @@ -696,6 +750,44 @@ impl PolicyBuilder { self.http_ports }; + // Parse user-supplied --net-allow specs. + let mut net_allow: Vec = self + .net_allow + .iter() + .map(|s| NetAllow::parse(s)) + .collect::>()?; + + // Auto-merge HTTP rules into the network allowlist so the proxy's + // intercept ports remain reachable. A rule with a concrete host + // tightens the IP allowlist (only that host on http_ports); + // wildcard hosts add a `:port` (any IP) rule. This mirrors the + // intent of the old `http_port → net_connect` merge but at the + // endpoint level so HTTP and net_allow stay aligned. + if !http_ports.is_empty() { + let mut wildcard_seen = false; + let mut concrete_hosts: Vec = Vec::new(); + for rule in http_allow.iter().chain(http_deny.iter()) { + if rule.host == "*" { + wildcard_seen = true; + } else if !concrete_hosts.iter().any(|h| h.eq_ignore_ascii_case(&rule.host)) { + concrete_hosts.push(rule.host.clone()); + } + } + if wildcard_seen || (http_allow.is_empty() && http_deny.is_empty()) { + // Fallback: explicit --http-port without rules, or wildcard rules. + net_allow.push(NetAllow { + host: None, + ports: http_ports.clone(), + }); + } + for h in concrete_hosts { + net_allow.push(NetAllow { + host: Some(h), + ports: http_ports.clone(), + }); + } + } + // Validate: fs_isolation != None requires workdir let fs_isolation = self.fs_isolation.unwrap_or_default(); if fs_isolation != FsIsolation::None && self.workdir.is_none() { @@ -708,11 +800,10 @@ impl PolicyBuilder { fs_denied: self.fs_denied, deny_syscalls: self.deny_syscalls, allow_syscalls: self.allow_syscalls, - net_allow_hosts: self.net_allow_hosts, + net_allow, net_bind: self.net_bind, - net_connect: self.net_connect, - no_raw_sockets: self.no_raw_sockets.unwrap_or(true), - no_udp: self.no_udp, + allow_udp: self.allow_udp, + allow_icmp: self.allow_icmp, http_allow, http_deny, http_ports, diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index d7a2d18..b9b1d3c 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -51,11 +51,8 @@ pub fn parse_profile(content: &str) -> Result { if let Some(paths) = sandbox.get("fs_denied").and_then(|v| v.as_array()) { for p in paths { if let Some(s) = p.as_str() { builder = builder.fs_deny(s); } } } - if let Some(hosts) = sandbox.get("net_allow_hosts").and_then(|v| v.as_array()) { - // Presence of the key enables host restriction, even if the array is - // empty (empty array = deny all, matching net_bind/net_connect semantics). - builder = builder.net_restrict_hosts(); - for h in hosts { if let Some(s) = h.as_str() { builder = builder.net_allow_host(s); } } + if let Some(specs) = sandbox.get("net_allow").and_then(|v| v.as_array()) { + for s in specs { if let Some(spec) = s.as_str() { builder = builder.net_allow(spec); } } } if let Some(rules) = sandbox.get("http_allow").and_then(|v| v.as_array()) { for r in rules { if let Some(s) = r.as_str() { builder = builder.http_allow(s); } } @@ -87,11 +84,11 @@ pub fn parse_profile(content: &str) -> Result { } // Parse booleans - if let Some(v) = sandbox.get("no_raw_sockets").and_then(|v| v.as_bool()) { - builder = builder.no_raw_sockets(v); + if let Some(v) = sandbox.get("allow_udp").and_then(|v| v.as_bool()) { + builder = builder.allow_udp(v); } - if let Some(v) = sandbox.get("no_udp").and_then(|v| v.as_bool()) { - builder = builder.no_udp(v); + if let Some(v) = sandbox.get("allow_icmp").and_then(|v| v.as_bool()) { + builder = builder.allow_icmp(v); } if let Some(v) = sandbox.get("clean_env").and_then(|v| v.as_bool()) { builder = builder.clean_env(v); @@ -112,9 +109,6 @@ if let Some(v) = sandbox.get("clean_env").and_then(|v| v.as_bool()) { if let Some(ports) = sandbox.get("net_bind").and_then(|v| v.as_array()) { for p in ports { if let Some(n) = p.as_integer() { builder = builder.net_bind_port(n as u16); } } } - if let Some(ports) = sandbox.get("net_connect").and_then(|v| v.as_array()) { - for p in ports { if let Some(n) = p.as_integer() { builder = builder.net_connect_port(n as u16); } } - } // Parse syscall lists if let Some(syscalls) = sandbox.get("deny_syscalls").and_then(|v| v.as_array()) { diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 4592fc7..c050b2d 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -701,25 +701,17 @@ impl Sandbox { // 4. Create synchronization pipes let pipes = PipePair::new().map_err(SandboxError::Io)?; - // 4. Resolve net_allow_hosts to IPs + build virtual /etc/hosts - // - // Semantics: - // None -> unrestricted (no virtualization, no IP allowlist) - // Some(empty) -> deny all (empty virtual /etc/hosts, empty allowlist) - // Some(nonempty) -> resolve and allowlist - let (resolved_ips, virtual_etc_hosts) = match self.policy.net_allow_hosts.as_deref() { - None => (std::collections::HashSet::new(), None), - Some([]) => ( - std::collections::HashSet::new(), - Some(String::new()), - ), - Some(hosts) => { - let resolved = network::resolve_hosts(hosts) - .await - .map_err(SandboxError::Io)?; - (resolved.ips, Some(resolved.etc_hosts)) - } - }; + // 4. Resolve --net-allow rules into the runtime endpoint allowlist. + // The resolved form contains: + // - per_ip: HashMap> (concrete-host rules) + // - any_ip_ports: HashSet (`:port` rules) + // - all_ports: HashSet (union — for Landlock) + // - etc_hosts: Option (synthetic when any + // concrete host present) + let resolved_net_allow = network::resolve_net_allow(&self.policy.net_allow) + .await + .map_err(SandboxError::Io)?; + let virtual_etc_hosts = resolved_net_allow.etc_hosts.clone(); // 5. Spawn HTTP ACL proxy if rules are configured if !self.policy.http_allow.is_empty() || !self.policy.http_deny.is_empty() { @@ -913,7 +905,7 @@ impl Sandbox { max_memory_bytes: self.policy.max_memory.map(|m| m.0).unwrap_or(0), max_processes: self.policy.max_processes, has_memory_limit: self.policy.max_memory.is_some(), - has_net_allowlist: self.policy.net_allow_hosts.is_some() + has_net_allowlist: !self.policy.net_allow.is_empty() || self.policy.policy_fn.is_some() || !self.policy.http_allow.is_empty() || !self.policy.http_deny.is_empty(), @@ -948,10 +940,19 @@ impl Sandbox { // NetworkState let mut net_state = NetworkState::new(); - net_state.network_policy = if self.policy.net_allow_hosts.is_some() { - crate::seccomp::notif::NetworkPolicy::AllowList(resolved_ips) - } else { + net_state.network_policy = if self.policy.net_allow.is_empty() { crate::seccomp::notif::NetworkPolicy::Unrestricted + } else { + use crate::seccomp::notif::PortAllow; + let per_ip = resolved_net_allow + .per_ip + .iter() + .map(|(ip, ports)| (*ip, PortAllow::Specific(ports.clone()))) + .collect(); + crate::seccomp::notif::NetworkPolicy::AllowList { + per_ip, + any_ip_ports: resolved_net_allow.any_ip_ports.clone(), + } }; net_state.http_acl_addr = self.http_acl_handle.as_ref().map(|h| h.addr); net_state.http_acl_ports = self.policy.http_ports.iter().copied().collect(); @@ -992,11 +993,20 @@ impl Sandbox { } if let Some(ref callback) = self.policy.policy_fn { + // The dynamic-policy "live" view is IP-only — derive it + // from per_ip keys (each represents an IP that some + // endpoint rule mentions). The any_ip case has no IPs to + // expose to the callback. + let allowed_ips = match &net_state.network_policy { + crate::seccomp::notif::NetworkPolicy::AllowList { per_ip, .. } => { + per_ip.keys().copied().collect() + } + crate::seccomp::notif::NetworkPolicy::Unrestricted => { + std::collections::HashSet::new() + } + }; let live = crate::policy_fn::LivePolicy { - allowed_ips: match &net_state.network_policy { - crate::seccomp::notif::NetworkPolicy::AllowList(ips) => ips.clone(), - crate::seccomp::notif::NetworkPolicy::Unrestricted => std::collections::HashSet::new(), - }, + allowed_ips, max_memory_bytes: notif_policy.max_memory_bytes, max_processes: notif_policy.max_processes, }; diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index b99bbd2..e28d822 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -2,7 +2,7 @@ // notifications from the kernel, dispatches them to handler functions, and // sends responses. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::io; use std::net::IpAddr; use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd, RawFd}; @@ -78,13 +78,52 @@ pub enum NotifAction { // NetworkPolicy — network access policy enum // ============================================================ +/// Per-IP port allowlist. `Any` is used by `policy_fn` IP-only +/// overrides (legacy `restrict_network(ips)` API where the user +/// restricts the destination IP set but not ports). +#[derive(Debug, Clone)] +pub enum PortAllow { + /// Any port permitted to this IP. + Any, + /// Only these ports permitted to this IP. + Specific(HashSet), +} + /// Global network policy for the sandbox. #[derive(Debug, Clone)] pub enum NetworkPolicy { - /// All IPs allowed (no net_allow_hosts configured). + /// No IP-level restriction (no `--net-allow` configured and no + /// `policy_fn` override). The Landlock direct path enforces ports. Unrestricted, - /// Only these IPs are allowed (from resolved net_allow_hosts). - AllowList(HashSet), + /// Endpoint-level allowlist: a connection is permitted iff the + /// destination IP and port match at least one entry below. + AllowList { + /// Per-IP port rules. From `--net-allow host:ports` after + /// hostname resolution, or from `policy_fn` overrides. + per_ip: HashMap, + /// Ports permitted for any IP (from `--net-allow :port` / + /// `*:port`). + any_ip_ports: HashSet, + }, +} + +impl NetworkPolicy { + /// True iff a connection to (ip, port) should be permitted. + pub fn allows(&self, ip: IpAddr, port: u16) -> bool { + match self { + NetworkPolicy::Unrestricted => true, + NetworkPolicy::AllowList { per_ip, 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, + } + } + } + } } /// Check if a path-bearing notification targets a denied path. diff --git a/crates/sandlock-core/src/seccomp/state.rs b/crates/sandlock-core/src/seccomp/state.rs index d81f207..199e7ec 100644 --- a/crates/sandlock-core/src/seccomp/state.rs +++ b/crates/sandlock-core/src/seccomp/state.rs @@ -291,11 +291,12 @@ impl CowState { /// Network policy and port-remapping state. pub struct NetworkState { - /// Global network policy: unrestricted or limited to a set of IPs. + /// Global network policy: endpoint-level allowlist or unrestricted. pub network_policy: crate::seccomp::notif::NetworkPolicy, /// Port binding and remapping tracker. pub port_map: crate::port_remap::PortMap, - /// Per-PID network overrides from policy_fn. + /// 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>>>, /// HTTP ACL proxy address (None if HTTP ACL not active). pub http_acl_addr: Option, @@ -320,22 +321,30 @@ impl NetworkState { /// Get the effective network policy for a PID. /// /// Priority: per-PID override > live policy (from PolicyFnState) > global network_policy. - /// The `live_policy` parameter allows checking the live policy without needing - /// to lock the PolicyFnState mutex. + /// PID/live overrides are IP-only — any port is permitted to listed + /// IPs (legacy `policy_fn` semantics). pub fn effective_network_policy( &self, pid: u32, live_policy: Option<&std::sync::Arc>>, ) -> crate::seccomp::notif::NetworkPolicy { + use crate::seccomp::notif::{NetworkPolicy, PortAllow}; + let ip_only_allow = |ips: &HashSet| { + let per_ip = ips.iter().map(|&ip| (ip, PortAllow::Any)).collect(); + NetworkPolicy::AllowList { + per_ip, + any_ip_ports: HashSet::new(), + } + }; if let Ok(overrides) = self.pid_ip_overrides.read() { if let Some(ips) = overrides.get(&pid) { - return crate::seccomp::notif::NetworkPolicy::AllowList(ips.clone()); + return ip_only_allow(ips); } } if let Some(lp) = live_policy { if let Ok(live) = lp.read() { if !live.allowed_ips.is_empty() { - return crate::seccomp::notif::NetworkPolicy::AllowList(live.allowed_ips.clone()); + return ip_only_allow(&live.allowed_ips); } } } diff --git a/crates/sandlock-core/src/sys/structs.rs b/crates/sandlock-core/src/sys/structs.rs index 1f68ee8..e759c6f 100644 --- a/crates/sandlock-core/src/sys/structs.rs +++ b/crates/sandlock-core/src/sys/structs.rs @@ -249,6 +249,8 @@ pub const AF_INET6: u32 = 10; pub const SOCK_RAW: u32 = 3; pub const SOCK_DGRAM: u32 = 2; pub const SOCK_TYPE_MASK: u32 = 0xFF; +pub const IPPROTO_ICMP: u32 = 1; +pub const IPPROTO_ICMPV6: u32 = 58; // ============================================================ // Errno values diff --git a/crates/sandlock-core/tests/integration/test_http_acl.rs b/crates/sandlock-core/tests/integration/test_http_acl.rs index 4542f66..0f88997 100644 --- a/crates/sandlock-core/tests/integration/test_http_acl.rs +++ b/crates/sandlock-core/tests/integration/test_http_acl.rs @@ -216,7 +216,10 @@ async fn test_http_no_acl_unrestricted() { let out = temp_file("no-acl"); let (port, srv) = spawn_http_server(1); - let policy = base_policy().net_connect_port(port).build().unwrap(); + let policy = base_policy() + .net_allow(format!(":{}", port)) + .build() + .unwrap(); let script = http_script(&format!("http://127.0.0.1:{}/get", port), &out); let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) @@ -341,46 +344,50 @@ async fn test_http_wildcard_host() { } /// Non-intercepted port traffic should NOT go through the proxy. +/// The port must be in `net_connect` (per AND semantics — see Network +/// Model in README); the proxy still leaves it alone because it is not +/// in `http_ports`. #[tokio::test] async fn test_http_non_intercepted_port() { let out = temp_file("non-intercept"); - // ACL intercepts port 80 by default, not random ports + // Bind the listener in the test process so we know the port up + // front and can plumb it through `--net-allow`. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let srv = std::thread::spawn(move || { + if let Ok((mut conn, _)) = listener.accept() { + let _ = std::io::Write::write_all(&mut conn, b"HELLO"); + } + }); + let policy = base_policy() .http_allow("GET example.com/get") + .net_allow(format!(":{}", port)) .build() .unwrap(); let script = format!( concat!( - "import socket, threading\n", + "import socket\n", "try:\n", - " srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", - " srv.bind(('127.0.0.1', 0))\n", - " port = srv.getsockname()[1]\n", - " srv.listen(1)\n", - " def accept_one():\n", - " conn, _ = srv.accept()\n", - " conn.send(b'HELLO')\n", - " conn.close()\n", - " t = threading.Thread(target=accept_one, daemon=True)\n", - " t.start()\n", " c = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", " c.settimeout(2)\n", - " c.connect(('127.0.0.1', port))\n", + " c.connect(('127.0.0.1', {port}))\n", " data = c.recv(10)\n", " c.close()\n", - " srv.close()\n", " open('{out}', 'w').write('OK:' + data.decode())\n", "except Exception as e:\n", " open('{out}', 'w').write('ERR:' + str(e))\n", ), out = out.display(), + port = port, ); let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) .await .unwrap(); + srv.join().unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); assert!(content.starts_with("OK:HELLO"), "expected OK:HELLO, got: {}", content); @@ -440,46 +447,46 @@ async fn test_http_acl_ipv6_deny() { } /// IPv6 non-intercepted port should pass through without proxy interference. +/// (Same AND-semantics requirement as the IPv4 sibling test.) #[tokio::test] async fn test_http_ipv6_non_intercepted_port() { let out = temp_file("ipv6-non-intercept"); + let listener = std::net::TcpListener::bind("[::1]:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let srv = std::thread::spawn(move || { + if let Ok((mut conn, _)) = listener.accept() { + let _ = std::io::Write::write_all(&mut conn, b"HELLO6"); + } + }); + let policy = base_policy() .http_allow("GET example.com/get") + .net_allow(format!(":{}", port)) .build() .unwrap(); let script = format!( concat!( - "import socket, threading\n", + "import socket\n", "try:\n", - " srv = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)\n", - " srv.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)\n", - " srv.bind(('::1', 0))\n", - " port = srv.getsockname()[1]\n", - " srv.listen(1)\n", - " def accept_one():\n", - " conn, _ = srv.accept()\n", - " conn.send(b'HELLO6')\n", - " conn.close()\n", - " t = threading.Thread(target=accept_one, daemon=True)\n", - " t.start()\n", " c = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)\n", " c.settimeout(2)\n", - " c.connect(('::1', port))\n", + " c.connect(('::1', {port}))\n", " data = c.recv(10)\n", " c.close()\n", - " srv.close()\n", " open('{out}', 'w').write('OK:' + data.decode())\n", "except Exception as e:\n", " open('{out}', 'w').write('ERR:' + str(e))\n", ), out = out.display(), + port = port, ); let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) .await .unwrap(); + srv.join().unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); assert!(content.starts_with("OK:HELLO6"), "expected OK:HELLO6, got: {}", content); diff --git a/crates/sandlock-core/tests/integration/test_network.rs b/crates/sandlock-core/tests/integration/test_network.rs index 29e43f9..6fd3dc5 100644 --- a/crates/sandlock-core/tests/integration/test_network.rs +++ b/crates/sandlock-core/tests/integration/test_network.rs @@ -13,13 +13,13 @@ fn base_policy() -> sandlock_core::PolicyBuilder { .fs_write("/tmp") } -/// Test that net_allow_host blocks connections to non-allowed hosts. +/// Test that --net-allow blocks connections to non-allowed hosts. #[tokio::test] -async fn test_net_allow_host_blocks_disallowed() { +async fn test_net_allow_blocks_disallowed_host() { let out = temp_file("block"); let policy = base_policy() - .net_allow_host("127.0.0.1") // only localhost allowed + .net_allow("127.0.0.1:80") // only localhost:80 .build() .unwrap(); @@ -44,17 +44,15 @@ async fn test_net_allow_host_blocks_disallowed() { let _ = std::fs::remove_file(&out); } -/// Test that net_allow_host permits connections to allowed hosts. +/// Test that --net-allow permits connections to the listed (host, port). #[tokio::test] -async fn test_net_allow_host_permits_allowed() { +async fn test_net_allow_permits_listed_endpoint() { let out = temp_file("allow"); - // Use a fixed port so we can add it to net_connect. let test_port: u16 = 19753; let policy = base_policy() - .net_allow_host("127.0.0.1") + .net_allow(format!("127.0.0.1:{}", test_port)) .net_bind_port(test_port) - .net_connect_port(test_port) .port_remap(true) .build() .unwrap(); @@ -88,18 +86,18 @@ async fn test_net_allow_host_permits_allowed() { let _ = std::fs::remove_file(&out); } -/// Test that without net_allow_host, connections are unrestricted -/// (provided the port is in net_connect). +/// `--net-allow :port` (any IP, specific port) permits the kernel-level +/// connect — Landlock allows the port and the on-behalf path's any-IP +/// match accepts. Connecting to a port without a listener still returns +/// ECONNREFUSED from the kernel (not EACCES from sandlock). #[tokio::test] -async fn test_no_net_allow_host_unrestricted() { - let out = temp_file("unrestricted"); +async fn test_net_allow_any_ip_port() { + let out = temp_file("any-ip"); - // No net_allow_host — connections allowed on permitted ports - let policy = base_policy().net_connect_port(1).build().unwrap(); + let policy = base_policy().net_allow(":1").build().unwrap(); - // Connect to localhost on a port that doesn't exist — should get ECONNREFUSED (not EPERM) let script = format!(concat!( - "import socket, errno\n", + "import socket\n", "s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", "s.settimeout(1)\n", "try:\n", @@ -116,8 +114,69 @@ async fn test_no_net_allow_host_unrestricted() { let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]).await.unwrap(); assert!(result.success(), "exit={:?}", result.code()); let content = std::fs::read_to_string(&out).unwrap_or_default(); - // Without allowlist, should get REFUSED (not BLOCKED) - assert_eq!(content, "REFUSED", "without net_allow_host, connect should not be blocked by seccomp"); + assert_eq!(content, "REFUSED", "connect to permitted port should reach kernel; got: {}", content); + + let _ = std::fs::remove_file(&out); +} + +/// `--net-allow host:portA` rejects connections to (host, portB) — the +/// (ip, port) pair must match an endpoint rule. A real server bound on +/// the blocked port distinguishes sandbox-rejection (ECONNREFUSED from +/// sandlock) from kernel-refused (also ECONNREFUSED) — it ensures the +/// connect would have succeeded if sandlock allowed it. +#[tokio::test] +async fn test_net_allow_endpoint_rejects_other_ports() { + let out = temp_file("port-blocked"); + + let blocked_listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let blocked_port = blocked_listener.local_addr().unwrap().port(); + let blocked_listener = std::sync::Arc::new(blocked_listener); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop_clone = stop.clone(); + let l_clone = blocked_listener.clone(); + let acceptor = std::thread::spawn(move || { + l_clone.set_nonblocking(true).unwrap(); + while !stop_clone.load(std::sync::atomic::Ordering::SeqCst) { + match l_clone.accept() { + Ok((mut conn, _)) => { let _ = std::io::Write::write_all(&mut conn, b"hi"); } + Err(_) => std::thread::sleep(std::time::Duration::from_millis(50)), + } + } + }); + + let allowed_port: u16 = if blocked_port == u16::MAX { 1024 } else { blocked_port + 1 }; + + let policy = base_policy() + .net_allow(format!("127.0.0.1:{}", allowed_port)) + .build() + .unwrap(); + + let script = format!(concat!( + "import socket\n", + "s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", + "s.settimeout(2)\n", + "try:\n", + " s.connect(('127.0.0.1', {port}))\n", + " open('{out}', 'w').write('CONNECTED')\n", + "except ConnectionRefusedError:\n", + " open('{out}', 'w').write('REFUSED')\n", + "except (OSError, socket.timeout) as e:\n", + " open('{out}', 'w').write('OTHER:' + e.__class__.__name__)\n", + "finally:\n", + " s.close()\n", + ), out = out.display(), port = blocked_port); + + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]).await.unwrap(); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + let _ = acceptor.join(); + + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert_eq!( + content, "REFUSED", + "port {} not in net_allow must be rejected even when listener is bound (got: {})", + blocked_port, content + ); let _ = std::fs::remove_file(&out); } @@ -139,8 +198,7 @@ async fn test_grandchild_network_connect() { }); let policy = base_policy() - .net_allow_host("127.0.0.1") - .net_connect_port(port) + .net_allow(format!("127.0.0.1:{}", port)) .build() .unwrap(); diff --git a/crates/sandlock-core/tests/integration/test_policy.rs b/crates/sandlock-core/tests/integration/test_policy.rs index 3e6a282..b2c6d0f 100644 --- a/crates/sandlock-core/tests/integration/test_policy.rs +++ b/crates/sandlock-core/tests/integration/test_policy.rs @@ -4,7 +4,8 @@ use sandlock_core::policy::{ByteSize, FsIsolation, BranchAction, Policy}; fn test_default_policy() { let policy = Policy::builder().build().unwrap(); assert_eq!(policy.max_processes, 64); - assert!(policy.no_raw_sockets); + assert!(!policy.allow_udp, "UDP is denied by default"); + assert!(!policy.allow_icmp, "ICMP raw is denied by default"); assert!(policy.uid.is_none()); assert!(policy.fs_writable.is_empty()); assert!(policy.fs_readable.is_empty()); @@ -26,12 +27,27 @@ fn test_builder_fs_paths() { fn test_builder_network() { let policy = Policy::builder() .net_bind_port(8080) - .net_connect_port(443) - .net_connect_port(80) + .net_allow("api.example.com:443,80") .build() .unwrap(); assert_eq!(policy.net_bind, vec![8080]); - assert_eq!(policy.net_connect, vec![443, 80]); + assert_eq!(policy.net_allow.len(), 1); + let rule = &policy.net_allow[0]; + assert_eq!(rule.host.as_deref(), Some("api.example.com")); + assert_eq!(rule.ports, vec![443, 80]); +} + +#[test] +fn test_net_allow_parse_grammar() { + use sandlock_core::policy::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 } #[test] @@ -112,15 +128,15 @@ fn test_env_var() { } #[test] -fn test_no_udp_default_false() { +fn test_allow_udp_default_false() { let p = Policy::builder().build().unwrap(); - assert!(!p.no_udp, "no_udp should default to false"); + assert!(!p.allow_udp, "UDP is denied by default; opt in via .allow_udp(true)"); } #[test] -fn test_no_raw_sockets_default_true() { +fn test_allow_icmp_default_false() { let p = Policy::builder().build().unwrap(); - assert!(p.no_raw_sockets, "no_raw_sockets should default to true"); + assert!(!p.allow_icmp, "ICMP raw is denied by default; opt in via .allow_icmp(true)"); } #[test] diff --git a/crates/sandlock-core/tests/integration/test_policy_fn.rs b/crates/sandlock-core/tests/integration/test_policy_fn.rs index b8a168b..4bbd673 100644 --- a/crates/sandlock-core/tests/integration/test_policy_fn.rs +++ b/crates/sandlock-core/tests/integration/test_policy_fn.rs @@ -49,7 +49,7 @@ async fn test_policy_fn_deny_connect() { let out = temp_file("deny-connect"); let policy = base_policy() - .net_allow_host("127.0.0.1") + .net_allow("127.0.0.1:443") .policy_fn(move |event, _ctx| { // Deny all connect attempts if event.syscall == "connect" { @@ -87,7 +87,7 @@ async fn test_policy_fn_restrict_network_takes_effect() { let out = temp_file("restrict-net-effect"); let policy = base_policy() - .net_allow_host("127.0.0.1") + .net_allow("127.0.0.1:443") .policy_fn(move |event, ctx| { if event.syscall == "execve" { ctx.restrict_network(&[]); // block all @@ -238,7 +238,7 @@ async fn test_policy_fn_connect_metadata() { let events_clone = events.clone(); let policy = base_policy() - .net_allow_host("127.0.0.1") + .net_allow("127.0.0.1:443") .policy_fn(move |event, _ctx| { if event.syscall == "connect" { events_clone.lock().unwrap().push((event.host, event.port)); @@ -274,7 +274,7 @@ async fn test_policy_fn_event_categories() { let cats_clone = categories.clone(); let policy = base_policy() - .net_allow_host("127.0.0.1") + .net_allow("127.0.0.1:443") .policy_fn(move |event, _ctx| { cats_clone.lock().unwrap().push((event.syscall.clone(), event.category)); Verdict::Allow @@ -338,7 +338,7 @@ async fn test_policy_fn_deny_with_eacces() { let out = temp_file("deny-eacces"); let policy = base_policy() - .net_allow_host("127.0.0.1") + .net_allow("127.0.0.1:443") .policy_fn(move |event, _ctx| { if event.syscall == "connect" { return Verdict::DenyWith(libc::EACCES); diff --git a/crates/sandlock-core/tests/integration/test_port_remap.rs b/crates/sandlock-core/tests/integration/test_port_remap.rs index c09403d..b69ef62 100644 --- a/crates/sandlock-core/tests/integration/test_port_remap.rs +++ b/crates/sandlock-core/tests/integration/test_port_remap.rs @@ -56,8 +56,7 @@ async fn test_port_remap_loopback() { let policy = base_policy() .net_bind_port(port) - .net_connect_port(port) - .net_allow_host("127.0.0.1") + .net_allow(format!("127.0.0.1:{}", port)) .port_remap(true) .build() .unwrap(); diff --git a/crates/sandlock-core/tests/integration/test_seccomp_enforce.rs b/crates/sandlock-core/tests/integration/test_seccomp_enforce.rs index 108ac58..70e61c3 100644 --- a/crates/sandlock-core/tests/integration/test_seccomp_enforce.rs +++ b/crates/sandlock-core/tests/integration/test_seccomp_enforce.rs @@ -108,7 +108,7 @@ async fn test_personality_blocked() { } // ------------------------------------------------------------------ -// 4. Raw sockets blocked by default (no_raw_sockets defaults to true) +// 4. Raw sockets blocked by default (allow_icmp defaults to false) // ------------------------------------------------------------------ #[tokio::test] async fn test_raw_socket_blocked() { @@ -143,11 +143,12 @@ async fn test_raw_socket_blocked() { } // ------------------------------------------------------------------ -// 4. Raw sockets allowed when no_raw_sockets(false) +// 4b. allow_icmp(true) permits AF_INET + SOCK_RAW + IPPROTO_ICMP +// while other raw socket types remain denied. // ------------------------------------------------------------------ #[tokio::test] -async fn test_raw_socket_allowed_when_permitted() { - let out = temp_out("raw-socket-allowed"); +async fn test_allow_icmp_permits_icmp_raw() { + let out = temp_out("allow-icmp-permits-icmp"); let script = format!(concat!( "import socket\n", "try:\n", @@ -162,7 +163,7 @@ async fn test_raw_socket_allowed_when_permitted() { ), out = out.display()); let policy = base_policy() - .no_raw_sockets(false) + .allow_icmp(true) .build() .unwrap(); let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) @@ -171,23 +172,66 @@ async fn test_raw_socket_allowed_when_permitted() { let contents = std::fs::read_to_string(&out).unwrap_or_default(); let _ = std::fs::remove_file(&out); - // When seccomp allows it, the OS may still deny if not running as root. - // Accept ALLOWED (root) or BLOCKED/ERROR (non-root OS-level denial). + // seccomp must permit it; the kernel may still deny without CAP_NET_RAW + // (errno 1 = EPERM). Accept ALLOWED (root) or BLOCKED/ERROR:1 (non-root + // capability denial). let trimmed = contents.trim(); assert!( - trimmed == "ALLOWED" || trimmed == "BLOCKED" || trimmed.starts_with("ERROR:"), - "unexpected result when raw sockets permitted: {}", + trimmed == "ALLOWED" || trimmed == "BLOCKED" || trimmed == "ERROR:1", + "ICMP raw socket should be permitted by seccomp under allow_icmp; got: {}", trimmed ); assert!(result.success()); } // ------------------------------------------------------------------ -// 5. UDP blocked when no_udp(true) +// 4c. allow_icmp(true) still blocks SOCK_RAW with non-ICMP protocol +// (verifies the BPF arg2 protocol check) // ------------------------------------------------------------------ #[tokio::test] -async fn test_udp_blocked_when_enabled() { - let out = temp_out("udp-blocked"); +async fn test_allow_icmp_still_blocks_other_raw() { + let out = temp_out("allow-icmp-blocks-tcp-raw"); + // AF_INET + SOCK_RAW + IPPROTO_TCP must still be denied by seccomp. + let script = format!(concat!( + "import socket\n", + "try:\n", + " s = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_TCP)\n", + " s.close()\n", + " result = 'ALLOWED'\n", + "except PermissionError:\n", + " result = 'BLOCKED'\n", + "except OSError as e:\n", + " result = f'ERROR:{{e.errno}}'\n", + "open('{out}', 'w').write(result)\n", + ), out = out.display()); + + let policy = base_policy() + .allow_icmp(true) + .build() + .unwrap(); + let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) + .await + .unwrap(); + + let contents = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + let trimmed = contents.trim(); + // Must be denied — either via seccomp (BLOCKED) or the kernel (EPERM). + // Critically must NOT be ALLOWED. + assert_ne!( + trimmed, "ALLOWED", + "non-ICMP raw socket must remain denied under allow_icmp; got: {}", + trimmed + ); + assert!(result.success()); +} + +// ------------------------------------------------------------------ +// 5. UDP allowed when allow_udp(true) +// ------------------------------------------------------------------ +#[tokio::test] +async fn test_udp_allowed_when_opted_in() { + let out = temp_out("udp-allowed"); let script = format!(concat!( "import socket\n", "try:\n", @@ -202,7 +246,7 @@ async fn test_udp_blocked_when_enabled() { ), out = out.display()); let policy = base_policy() - .no_udp(true) + .allow_udp(true) .build() .unwrap(); let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) @@ -213,19 +257,19 @@ async fn test_udp_blocked_when_enabled() { let _ = std::fs::remove_file(&out); assert_eq!( contents.trim(), - "BLOCKED", - "UDP socket should be blocked with no_udp(true), got: {}", + "ALLOWED", + "UDP socket should be allowed with allow_udp(true), got: {}", contents.trim() ); assert!(result.success()); } // ------------------------------------------------------------------ -// 6. UDP allowed by default (no no_udp flag) +// 6. UDP denied by default // ------------------------------------------------------------------ #[tokio::test] -async fn test_udp_allowed_by_default() { - let out = temp_out("udp-allowed"); +async fn test_udp_denied_by_default() { + let out = temp_out("udp-denied"); let script = format!(concat!( "import socket\n", "try:\n", @@ -248,15 +292,15 @@ async fn test_udp_allowed_by_default() { let _ = std::fs::remove_file(&out); assert_eq!( contents.trim(), - "ALLOWED", - "UDP socket should be allowed by default, got: {}", + "BLOCKED", + "UDP should be denied by default; got: {}", contents.trim() ); assert!(result.success()); } // ------------------------------------------------------------------ -// 8. TCP always allowed even with no_raw_sockets + no_udp +// 8. TCP always allowed (default deny posture for raw + UDP) // ------------------------------------------------------------------ #[tokio::test] async fn test_tcp_always_allowed() { @@ -275,8 +319,6 @@ async fn test_tcp_always_allowed() { ), out = out.display()); let policy = base_policy() - .no_raw_sockets(true) - .no_udp(true) .build() .unwrap(); let result = Sandbox::run_interactive(&policy, &["python3", "-c", &script]) diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 091b5ca..b8eba35 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -41,12 +41,16 @@ sandlock_builder_t *sandlock_policy_builder_max_cpu(sandlock_builder_t *b, uint8 sandlock_builder_t *sandlock_policy_builder_num_cpus(sandlock_builder_t *b, uint32_t n); /* Network */ -sandlock_builder_t *sandlock_policy_builder_net_allow_host(sandlock_builder_t *b, const char *host); +/* `spec` is `host:port[,port,...]` (IP-restricted) or `:port` / `*:port` + * (any IP). Validated when the policy is built. */ +sandlock_builder_t *sandlock_policy_builder_net_allow(sandlock_builder_t *b, const char *spec); sandlock_builder_t *sandlock_policy_builder_net_bind_port(sandlock_builder_t *b, uint16_t port); -sandlock_builder_t *sandlock_policy_builder_net_connect_port(sandlock_builder_t *b, uint16_t port); sandlock_builder_t *sandlock_policy_builder_port_remap(sandlock_builder_t *b, bool v); -sandlock_builder_t *sandlock_policy_builder_no_raw_sockets(sandlock_builder_t *b, bool v); -sandlock_builder_t *sandlock_policy_builder_no_udp(sandlock_builder_t *b, bool v); +/* UDP socket creation. Denied by default; opt in with v=true. */ +sandlock_builder_t *sandlock_policy_builder_allow_udp(sandlock_builder_t *b, bool v); +/* Permit ICMP raw sockets only (AF_INET/AF_INET6 + SOCK_RAW + IPPROTO_ICMP[V6]). + * All other raw socket types remain denied. */ +sandlock_builder_t *sandlock_policy_builder_allow_icmp(sandlock_builder_t *b, bool v); /* Mode */ sandlock_builder_t *sandlock_policy_builder_privileged(sandlock_builder_t *b, bool v); diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index a93b856..df03117 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -306,37 +306,20 @@ pub unsafe extern "C" fn sandlock_policy_builder_cpu_cores( // Policy Builder — network // ---------------------------------------------------------------- -/// # Safety -/// `b` and `host` must be valid pointers. -#[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_net_allow_host( - b: *mut PolicyBuilder, host: *const c_char, -) -> *mut PolicyBuilder { - if b.is_null() || host.is_null() { return b; } - let host = CStr::from_ptr(host).to_str().unwrap_or(""); - let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.net_allow_host(host))) -} - -/// Enable `net_allow_hosts` restriction without adding any hosts. -/// -/// After this call, the sandbox is configured with an empty host allowlist -/// (deny all hosts — empty virtual `/etc/hosts`, empty IP allowlist). If -/// hosts are subsequently added via `sandlock_policy_builder_net_allow_host` -/// they extend the same allowlist. -/// -/// This is the "empty list = deny all" form for the host filter, matching -/// the semantics of `net_bind` / `net_connect`. +/// Append a `--net-allow` endpoint rule. `spec` is `host:port[,port,...]`, +/// `:port`, or `*:port`. Spec is validated when the policy is built; +/// invalid specs surface as a build error. /// /// # Safety -/// `b` must be a valid builder pointer. +/// `b` and `spec` must be valid pointers. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_net_restrict_hosts( - b: *mut PolicyBuilder, +pub unsafe extern "C" fn sandlock_policy_builder_net_allow( + b: *mut PolicyBuilder, spec: *const c_char, ) -> *mut PolicyBuilder { - if b.is_null() { return b; } + 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_restrict_hosts())) + Box::into_raw(Box::new(builder.net_allow(spec))) } /// # Safety @@ -350,17 +333,6 @@ pub unsafe extern "C" fn sandlock_policy_builder_net_bind_port( Box::into_raw(Box::new(builder.net_bind_port(port))) } -/// # Safety -/// `b` must be a valid builder pointer. -#[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_net_connect_port( - b: *mut PolicyBuilder, port: u16, -) -> *mut PolicyBuilder { - if b.is_null() { return b; } - let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.net_connect_port(port))) -} - /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] @@ -372,26 +344,32 @@ pub unsafe extern "C" fn sandlock_policy_builder_port_remap( Box::into_raw(Box::new(builder.port_remap(v))) } +/// Permit UDP socket creation. UDP is denied by default; outbound +/// destinations remain gated by `net_allow` if any rules are set. +/// /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_no_raw_sockets( +pub unsafe extern "C" fn sandlock_policy_builder_allow_udp( b: *mut PolicyBuilder, v: bool, ) -> *mut PolicyBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.no_raw_sockets(v))) + Box::into_raw(Box::new(builder.allow_udp(v))) } +/// Permit `socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)` and the IPv6 +/// equivalent only. All other raw socket types remain denied. +/// /// # Safety /// `b` must be a valid builder pointer. #[no_mangle] -pub unsafe extern "C" fn sandlock_policy_builder_no_udp( +pub unsafe extern "C" fn sandlock_policy_builder_allow_icmp( b: *mut PolicyBuilder, v: bool, ) -> *mut PolicyBuilder { if b.is_null() { return b; } let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.no_udp(v))) + Box::into_raw(Box::new(builder.allow_icmp(v))) } /// # Safety diff --git a/python/README.md b/python/README.md index 485423e..d3353a4 100644 --- a/python/README.md +++ b/python/README.md @@ -62,12 +62,11 @@ Unset fields mean "no restriction" unless noted otherwise. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `net_allow_hosts` | `list[str] \| None` | `None` | Allowed domains. `None` = unrestricted; `[]` = deny all; `["host", ...]` = allowlist | -| `net_bind` | `list[int \| str]` | `[]` | TCP ports the sandbox may bind (empty = unrestricted) | -| `net_connect` | `list[int \| str]` | `[]` | TCP ports the sandbox may connect to (empty = unrestricted) | +| `net_allow` | `list[str]` | `[]` | Outbound endpoint rules (TCP; UDP too when `allow_udp=True`). Each entry is `"host:port[,port,...]"`, `":port"`, or `"*:port"`. Empty = deny all. | +| `net_bind` | `list[int \| str]` | `[]` | TCP ports the sandbox may bind (empty = deny all) | | `port_remap` | `bool` | `False` | Transparent TCP port virtualization | -| `no_raw_sockets` | `bool` | `True` | Block raw IP sockets | -| `no_udp` | `bool` | `False` | Block UDP sockets | +| `allow_udp` | `bool` | `False` | Permit UDP sockets (outbound destinations still gated by `net_allow`) | +| `allow_icmp` | `bool` | `False` | Permit `socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)` and IPv6 equivalent only — useful for `ping`. Other raw socket types stay denied. | #### HTTP ACL @@ -509,8 +508,7 @@ permissions explicitly: | Capability | Example | Description | |------------|---------|-------------| | `fs_writable` | `["/tmp/agent"]` | Paths the tool can write to | -| `net_connect` | `[443]` | TCP ports the tool can connect to | -| `net_allow_hosts` | `["api.example.com"]` | Allowed domains (implies ports 80, 443) | +| `net_allow` | `["api.example.com:443"]` | Outbound endpoints (`host:port`, `:port`, or `*:port`) — TCP, plus UDP when `allow_udp=True` | | `env` | `{"KEY": "val"}` | Environment variables to pass | | `max_memory` | `"256M"` | Memory limit | diff --git a/python/examples/mcp_agent.py b/python/examples/mcp_agent.py index 556a3ff..8623249 100644 --- a/python/examples/mcp_agent.py +++ b/python/examples/mcp_agent.py @@ -156,7 +156,7 @@ async def run_agent(user_prompt: str, workspace: str): "web_fetch", web_fetch, description="Fetch a URL and return the response body. Only httpbin.org is allowed.", capabilities={ - "net_allow_hosts": ["httpbin.org"], # implies net_connect: [80, 443] + "net_allow": ["httpbin.org:80,443"], }, input_schema={ "type": "object", @@ -171,9 +171,8 @@ async def run_agent(user_prompt: str, workspace: str): for name in mcp.tools: p = mcp.get_policy(name) rw = "read-only" if not p.fs_writable else "read-write" - net = f"ports {list(p.net_connect)}" if p.net_connect else "none" - hosts = f" hosts={list(p.net_allow_hosts)}" if p.net_allow_hosts else "" - print(f" {name:15s} fs={rw:10s} net={net}{hosts}") + net = f"endpoints {list(p.net_allow)}" if p.net_allow else "none" + print(f" {name:15s} fs={rw:10s} net={net}") print() # -- OpenAI agent loop -- diff --git a/python/examples/prompt_injection_defense.py b/python/examples/prompt_injection_defense.py index 73ba456..8511346 100644 --- a/python/examples/prompt_injection_defense.py +++ b/python/examples/prompt_injection_defense.py @@ -235,7 +235,7 @@ def demo_xoa_sandboxed(client: OpenAI, csv_path: str, exfil_port: int): "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", "/dev", python_prefix, ] + python_paths)), - net_allow_hosts=["api.openai.com"], # only OpenAI API + net_allow=["api.openai.com:443"], # only OpenAI HTTPS clean_env=True, env={"OPENAI_API_KEY": os.environ["OPENAI_API_KEY"]}, # NO workspace in fs_readable — planner cannot see data files @@ -246,14 +246,14 @@ def demo_xoa_sandboxed(client: OpenAI, csv_path: str, exfil_port: int): workspace, "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", python_prefix, ] + python_paths)), - net_connect=[], # No network at all + net_allow=[], # No network at all clean_env=True, env={"DATA_FILE": csv_path}, ) print("[pipeline] Running XOA: planner | executor") - print(f" planner: fs=no workspace net=api.openai.com only") - print(f" executor: fs=read workspace net=BLOCKED (net_connect=[])") + print(f" planner: fs=no workspace net=api.openai.com:443 only") + print(f" executor: fs=read workspace net=BLOCKED (net_allow=[])") print() # The planner script runs inside the sandbox: calls the LLM, @@ -333,7 +333,7 @@ def demo_xoa_sandboxed(client: OpenAI, csv_path: str, exfil_port: int): print(" 1. The LLM (planner) never saw the CSV contents,") print(" so the injection payload never reached the LLM.") print(" 2. Even if the LLM *had* been tricked, the executor") - print(" sandbox has net_connect=[] — network is blocked") + print(" sandbox has net_allow=[] — network is blocked") print(" at the kernel level (Landlock + seccomp).") print(" 3. This is not a filter or prompt guard — it's an") print(" architectural constraint that cannot be bypassed") diff --git a/python/examples/web_search_injection_defense.py b/python/examples/web_search_injection_defense.py index 2e12af5..6b71c93 100644 --- a/python/examples/web_search_injection_defense.py +++ b/python/examples/web_search_injection_defense.py @@ -207,7 +207,7 @@ def demo_xoa_sandboxed(client: OpenAI, data_path: str): # NO filesystem access to the data file. planner_policy = Policy( fs_readable=base_readable, - net_allow_hosts=["api.openai.com"], + net_allow=["api.openai.com:443"], clean_env=True, env={"OPENAI_API_KEY": os.environ["OPENAI_API_KEY"]}, ) @@ -216,7 +216,7 @@ def demo_xoa_sandboxed(client: OpenAI, data_path: str): # query as a command-line arg from the orchestrator. searcher_policy = Policy( fs_readable=base_readable + [workspace], - net_connect=[], + net_allow=[], clean_env=True, env={"DATA_FILE": data_path}, ) @@ -225,7 +225,7 @@ def demo_xoa_sandboxed(client: OpenAI, data_path: str): # the code and the raw results via gather pipes. executor_policy = Policy( fs_readable=base_readable + ["/home"], # for sandlock imports - net_connect=[], + net_allow=[], clean_env=True, ) diff --git a/python/src/sandlock/_profile.py b/python/src/sandlock/_profile.py index 644061d..40c6acc 100644 --- a/python/src/sandlock/_profile.py +++ b/python/src/sandlock/_profile.py @@ -31,11 +31,10 @@ "deny_syscalls": list, "allow_syscalls": list, # Network - "net_allow_hosts": list, + "net_allow": list, "net_bind": list, - "net_connect": list, - "no_raw_sockets": bool, - "no_udp": bool, + "allow_udp": bool, + "allow_icmp": bool, # Resources "max_memory": str, "max_processes": int, @@ -158,7 +157,7 @@ def policy_from_dict(data: dict, source: str = "") -> Policy: ) # Coerce TOML integers in lists to strings for port specs - if key in ("net_bind", "net_connect") and isinstance(value, list): + if key in ("net_bind",) and isinstance(value, list): value = [str(v) if isinstance(v, int) else v for v in value] kwargs[key] = value diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index 8232839..c02a414 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -88,13 +88,11 @@ def _builder_fn(name, *extra_args): _b_max_processes = _builder_fn("sandlock_policy_builder_max_processes", ctypes.c_uint32) _b_max_cpu = _builder_fn("sandlock_policy_builder_max_cpu", ctypes.c_uint8) _b_num_cpus = _builder_fn("sandlock_policy_builder_num_cpus", ctypes.c_uint32) -_b_net_allow_host = _builder_fn("sandlock_policy_builder_net_allow_host", ctypes.c_char_p) -_b_net_restrict_hosts = _builder_fn("sandlock_policy_builder_net_restrict_hosts") +_b_net_allow = _builder_fn("sandlock_policy_builder_net_allow", ctypes.c_char_p) _b_net_bind_port = _builder_fn("sandlock_policy_builder_net_bind_port", ctypes.c_uint16) -_b_net_connect_port = _builder_fn("sandlock_policy_builder_net_connect_port", ctypes.c_uint16) _b_port_remap = _builder_fn("sandlock_policy_builder_port_remap", ctypes.c_bool) -_b_no_raw_sockets = _builder_fn("sandlock_policy_builder_no_raw_sockets", ctypes.c_bool) -_b_no_udp = _builder_fn("sandlock_policy_builder_no_udp", ctypes.c_bool) +_b_allow_udp = _builder_fn("sandlock_policy_builder_allow_udp", ctypes.c_bool) +_b_allow_icmp = _builder_fn("sandlock_policy_builder_allow_icmp", ctypes.c_bool) _b_http_allow = _builder_fn("sandlock_policy_builder_http_allow", ctypes.c_char_p) _b_http_deny = _builder_fn("sandlock_policy_builder_http_deny", ctypes.c_char_p) _b_http_port = _builder_fn("sandlock_policy_builder_http_port", ctypes.c_uint16) @@ -738,8 +736,8 @@ 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_hosts", "net_bind", "net_connect", - "port_remap", "no_raw_sockets", "no_udp", + "net_allow", "net_bind", + "port_remap", "allow_udp", "allow_icmp", "http_allow", "http_deny", "http_ports", "https_ca", "https_key", "uid", "random_seed", "time_start", "clean_env", "env", @@ -821,18 +819,14 @@ def _build_from_policy(policy: PolicyDataclass, override_hostname=None): arr = (ctypes.c_uint32 * len(policy.cpu_cores))(*policy.cpu_cores) b = _b_cpu_cores(b, arr, len(policy.cpu_cores)) - # net_allow_hosts: None = unrestricted (skip entirely); any sequence - # (even empty) opts into host restriction. An empty sequence means - # "deny all hosts" — we must still call restrict_hosts so the native - # builder flips the Option from None to Some(vec![]). - if policy.net_allow_hosts is not None: - b = _b_net_restrict_hosts(b) - for host in policy.net_allow_hosts: - b = _b_net_allow_host(b, _encode(str(host))) + # net_allow: list of endpoint specs (`host:port[,port,...]`, + # `:port`, `*:port`). Empty = deny all outbound. Applies to TCP + # and to UDP (when allow_udp is set). 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 port in parse_ports(policy.net_connect) if policy.net_connect else []: - b = _b_net_connect_port(b, port) for rule in (policy.http_allow or []): b = _b_http_allow(b, _encode(str(rule))) @@ -847,9 +841,10 @@ def _build_from_policy(policy: PolicyDataclass, override_hostname=None): if policy.port_remap: b = _b_port_remap(b, True) - b = _b_no_raw_sockets(b, policy.no_raw_sockets) - if policy.no_udp: - b = _b_no_udp(b, True) + if policy.allow_udp: + b = _b_allow_udp(b, True) + if policy.allow_icmp: + b = _b_allow_icmp(b, True) if policy.uid is not None: b = _b_uid(b, policy.uid) diff --git a/python/src/sandlock/mcp/_policy.py b/python/src/sandlock/mcp/_policy.py index 5404117..98f1e58 100644 --- a/python/src/sandlock/mcp/_policy.py +++ b/python/src/sandlock/mcp/_policy.py @@ -9,8 +9,7 @@ policy = policy_for_tool(workspace="/tmp/work", capabilities={ "fs_writable": ["/tmp/work"], - "net_connect": [443], - "net_allow_hosts": ["api.google.com"], + "net_allow": ["api.google.com:443"], }) """ @@ -53,7 +52,7 @@ def policy_for_tool( capabilities: Grants keyed by Policy field name. Common keys: - ``fs_writable: ["/tmp/workspace"]`` - - ``net_allow_hosts: ["api.example.com"]`` + - ``net_allow: ["api.example.com:443"]`` - ``env: {"KEY": "value"}`` - ``max_memory: "256M"`` @@ -70,9 +69,7 @@ def policy_for_tool( _PYTHON_PREFIX, ])), "net_bind": [], - "net_connect": [], - "no_raw_sockets": True, - "no_udp": True, + "net_allow": [], "clean_env": True, } @@ -81,10 +78,6 @@ def policy_for_tool( if key in _POLICY_FIELDS and key not in _ENFORCED: kwargs[key] = value - # net_allow_hosts implies net_connect: [80, 443] unless explicit - if "net_allow_hosts" in capabilities and "net_connect" not in capabilities: - kwargs["net_connect"] = [80, 443] - return Policy(**kwargs) diff --git a/python/src/sandlock/policy.py b/python/src/sandlock/policy.py index 843ae12..f9ac8e9 100644 --- a/python/src/sandlock/policy.py +++ b/python/src/sandlock/policy.py @@ -142,47 +142,49 @@ class Policy: """Syscall names to allow (allowlist mode). Everything else is blocked. Stricter than deny_syscalls — unknown/new syscalls are denied by default.""" - # Network — domain allowlist (seccomp notif /etc/hosts virtualization) - net_allow_hosts: Sequence[str] | None = None - """Allowed domain names. - - * ``None`` (default) — unrestricted: the real ``/etc/hosts`` is visible - and DNS is not virtualized. - * ``[]`` (empty sequence) — deny all: ``/etc/hosts`` is virtualized to - an empty map and no hosts are resolvable. Matches the empty-list = - deny-all convention of :attr:`net_bind` and :attr:`net_connect`. - * ``["example.com", ...]`` — allowlist: only these domains are - resolved (at sandbox creation time) and their IPs placed in the - allowlist. - - Note: this field only controls host/DNS virtualization. TCP-level - connectivity is still governed by :attr:`net_connect` / :attr:`net_bind` - (which default to empty = deny all).""" + # Network — endpoint allowlist (IP × port via seccomp on-behalf path) + net_allow: Sequence[str] = field(default_factory=list) + """Outbound endpoint rules. Applies to TCP and to UDP (when + :attr:`allow_udp` is set). Each entry is a string of the form: + + * ``"host:port"`` — restrict to one host on one port (e.g. ``"api.openai.com:443"``) + * ``"host:port,port,..."`` — multiple ports for one host (e.g. ``"github.com:22,443"``) + * ``":port"`` or ``"*:port"`` — any IP on this port (e.g. ``":53"`` for DNS) + + Hostnames are resolved at sandbox-creation time and pinned via a + synthetic ``/etc/hosts``. Empty = deny all outbound (Landlock + rejects TCP on the direct path; no on-behalf path is enabled, so + UDP `sendto`/`sendmsg` are also untrapped — but UDP socket creation + itself is denied unless :attr:`allow_udp` is set). HTTP rules with + concrete hosts auto-add a matching entry on :attr:`http_ports`. + See README "Network Model" for details.""" no_coredump: bool = False """Disable core dumps and restrict /proc/pid access from other processes. Applied via prctl(PR_SET_DUMPABLE, 0). Prevents leaking sandbox memory contents but breaks gdb/strace/perf.""" - # Network (Landlock ABI v4+, TCP only) + # 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.""" - - net_connect: Sequence[int | str] = field(default_factory=list) - """TCP ports the sandbox may connect to. Empty = deny all. - Each entry is a port number or a ``"lo-hi"`` range string.""" - - # Socket type restrictions (seccomp-enforced) - no_raw_sockets: bool = True - """Block raw IP sockets (SOCK_RAW on AF_INET/AF_INET6). Raw sockets - allow packet sniffing and ICMP crafting — almost never needed by - sandboxed programs. Enforced via seccomp BPF.""" - - no_udp: bool = False - """Block UDP sockets (SOCK_DGRAM on AF_INET/AF_INET6). Only affects - IP-family sockets — AF_UNIX datagrams are unaffected. Useful when - only TCP connectivity is desired. Enforced via seccomp BPF.""" + """TCP ports the sandbox may bind. Empty = deny all. Each entry is + a port number or a ``"lo-hi"`` range string. UDP bind is gated by + :attr:`allow_udp` rather than this list — Landlock's port hooks + are TCP-only.""" + + # Socket type restrictions (seccomp-enforced). + # Raw sockets and UDP are denied by default; opt in via the flags below. + allow_udp: bool = False + """Permit UDP sockets (SOCK_DGRAM on AF_INET/AF_INET6). UDP is denied + by default. When ``True``, outbound UDP destinations are still gated + by :attr:`net_allow` — same endpoint allowlist used for TCP. AF_UNIX + datagrams are unaffected. CLI: ``--allow-udp``. Enforced via seccomp BPF.""" + + allow_icmp: bool = False + """Narrow ICMP raw socket carve-out: permit + ``socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)`` and the IPv6 equivalent + only. All other raw socket types remain denied. Useful for ``ping`` + without granting full packet-crafting capability. + CLI: ``--allow-icmp``. Enforced via seccomp BPF.""" # HTTP ACL http_allow: Sequence[str] = field(default_factory=list) @@ -339,10 +341,6 @@ 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 [] - def connect_ports(self) -> list[int]: - """Return parsed connect port list, or empty if unrestricted.""" - return parse_ports(self.net_connect) if self.net_connect else [] - def memory_bytes(self) -> int | None: """Return max_memory as bytes, or None if unset.""" if self.max_memory is None: diff --git a/python/tests/test_mcp.py b/python/tests/test_mcp.py index 6e2652e..0c05b91 100644 --- a/python/tests/test_mcp.py +++ b/python/tests/test_mcp.py @@ -14,15 +14,15 @@ def test_no_capabilities(self): policy = policy_for_tool(workspace="/tmp/ws") assert policy.fs_writable == [] assert "/tmp/ws" in policy.fs_readable - assert policy.net_connect == [] + assert policy.net_allow == [] assert policy.net_bind == [] - assert policy.no_udp is True - assert policy.no_raw_sockets is True + assert policy.allow_udp is False + assert policy.allow_icmp is False def test_empty_capabilities(self): policy = policy_for_tool(workspace="/tmp/ws", capabilities={}) assert policy.fs_writable == [] - assert policy.net_connect == [] + assert policy.net_allow == [] class TestCapabilities: @@ -34,19 +34,12 @@ def test_fs_writable(self): ) assert "/tmp/ws" in policy.fs_writable - def test_net_connect(self): + def test_net_allow(self): policy = policy_for_tool( workspace="/tmp/ws", - capabilities={"net_connect": [443]}, + capabilities={"net_allow": ["api.google.com:443"]}, ) - assert 443 in policy.net_connect - - def test_net_allow_hosts(self): - policy = policy_for_tool( - workspace="/tmp/ws", - capabilities={"net_allow_hosts": ["api.google.com"]}, - ) - assert "api.google.com" in policy.net_allow_hosts + assert "api.google.com:443" in policy.net_allow def test_max_memory(self): policy = policy_for_tool( @@ -60,34 +53,15 @@ def test_multiple(self): workspace="/tmp/ws", capabilities={ "fs_writable": ["/data"], - "net_connect": [443, 8080], + "net_allow": ["api.example.com:443", ":8080"], "max_memory": "256M", }, ) assert policy.fs_writable == ["/data"] - assert 443 in policy.net_connect - assert 8080 in policy.net_connect + assert "api.example.com:443" in policy.net_allow + assert ":8080" in policy.net_allow assert policy.max_memory == "256M" - def test_net_allow_hosts_implies_net_connect(self): - policy = policy_for_tool( - workspace="/tmp/ws", - capabilities={"net_allow_hosts": ["example.com"]}, - ) - assert "example.com" in policy.net_allow_hosts - assert 80 in policy.net_connect - assert 443 in policy.net_connect - - def test_net_allow_hosts_with_explicit_net_connect(self): - policy = policy_for_tool( - workspace="/tmp/ws", - capabilities={ - "net_allow_hosts": ["example.com"], - "net_connect": [8443], - }, - ) - assert policy.net_connect == [8443] # explicit wins - def test_unknown_field_ignored(self): policy = policy_for_tool( workspace="/tmp/ws", @@ -105,9 +79,9 @@ def _tool(self, annotations=None, meta=None): return t def test_from_annotations(self): - tool = self._tool({"sandlock:net_connect": [443]}) + tool = self._tool({"sandlock:net_allow": ["api.example.com:443"]}) caps = capabilities_from_mcp_tool(tool) - assert caps == {"net_connect": [443]} + assert caps == {"net_allow": ["api.example.com:443"]} def test_from_meta(self): tool = self._tool(meta={"sandlock:max_memory": "128M"}) @@ -141,8 +115,8 @@ def test_none_annotations(self): def test_meta_overrides_annotations(self): """meta wins over annotations for same key.""" tool = self._tool( - {"sandlock:net_connect": [80]}, - {"sandlock:net_connect": [443]}, + {"sandlock:net_allow": ["foo.com:80"]}, + {"sandlock:net_allow": ["foo.com:443"]}, ) caps = capabilities_from_mcp_tool(tool) - assert caps == {"net_connect": [443]} + assert caps == {"net_allow": ["foo.com:443"]} diff --git a/python/tests/test_pipeline.py b/python/tests/test_pipeline.py index 43c1615..867bdfd 100644 --- a/python/tests/test_pipeline.py +++ b/python/tests/test_pipeline.py @@ -190,7 +190,7 @@ def test_xoa_data_flow(self): workspace, "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin", _PYTHON_PREFIX, ])), - net_connect=[], + net_allow=[], ) # Planner emits a script that reads the data file. @@ -216,7 +216,7 @@ def test_xoa_data_flow(self): def test_xoa_executor_no_network(self): """Executor cannot reach the network.""" - executor_policy = _policy(net_connect=[]) + executor_policy = _policy(net_allow=[]) result = ( Sandbox(_policy()).cmd( diff --git a/python/tests/test_policy.py b/python/tests/test_policy.py index b175da6..e47fbbd 100644 --- a/python/tests/test_policy.py +++ b/python/tests/test_policy.py @@ -52,7 +52,7 @@ def test_defaults(self): assert p.fs_denied == [] assert p.deny_syscalls is None assert p.net_bind == [] - assert p.net_connect == [] + assert p.net_allow == [] assert p.max_memory is None assert p.max_processes == 64 assert p.max_cpu is None @@ -144,14 +144,10 @@ def test_bind_ports(self): p = Policy(net_bind=[80, "443", "8000-8002"]) assert p.bind_ports() == [80, 443, 8000, 8001, 8002] - def test_connect_ports(self): - p = Policy(net_connect=["1-1024"]) - assert p.connect_ports() == list(range(1, 1025)) - def test_unrestricted_by_default(self): p = Policy() assert p.bind_ports() == [] - assert p.connect_ports() == [] + assert p.net_allow == [] class TestEnvControl: @@ -196,28 +192,24 @@ def test_specific_cores(self): assert p.cpu_cores == [0, 2, 3] -class TestNetAllowHosts: - """Option-A tri-state semantics for net_allow_hosts. +class TestNetAllow: + """Endpoint allowlist semantics for `net_allow`. - * None — unrestricted (default) - * [] (empty) — deny all hosts - * ["host", ...] — allowlist specific hosts + Each entry is a string spec parsed by the native build: + `host:port[,port,...]`, `:port`, or `*:port`. Empty list = deny all. """ - def test_default_is_none(self): + def test_default_is_empty(self): p = Policy() - assert p.net_allow_hosts is None - - def test_empty_list_means_deny_all(self): - # Explicit empty list is distinguishable from None — it opts into - # restriction with zero allowed hosts. - p = Policy(net_allow_hosts=[]) - assert p.net_allow_hosts == [] - assert p.net_allow_hosts is not None - - def test_populated_list_is_allowlist(self): - p = Policy(net_allow_hosts=["api.example.com", "example.org"]) - assert list(p.net_allow_hosts) == ["api.example.com", "example.org"] + assert p.net_allow == [] + + def test_specs_preserved_as_strings(self): + p = Policy(net_allow=["api.example.com:443", "github.com:22,443", ":8080"]) + assert list(p.net_allow) == [ + "api.example.com:443", + "github.com:22,443", + ":8080", + ] diff --git a/python/tests/test_policy_fn.py b/python/tests/test_policy_fn.py index c47097e..f60f73c 100644 --- a/python/tests/test_policy_fn.py +++ b/python/tests/test_policy_fn.py @@ -134,7 +134,7 @@ def on_event(event, ctx): ctx.restrict_network([]) result = Sandbox( - _policy(net_allow_hosts=["127.0.0.1"]), + _policy(net_allow=["127.0.0.1:443"]), policy_fn=on_event, ).run(["python3", "-c", "print('restricted')"]) assert result.success @@ -180,7 +180,7 @@ def on_event(event, ctx): return 0 result = Sandbox( - _policy(net_allow_hosts=["127.0.0.1"]), + _policy(net_allow=["127.0.0.1:443"]), policy_fn=on_event, ).run(["python3", "-c", f"import socket\n" @@ -218,7 +218,7 @@ def on_event(event, ctx): return False result = Sandbox( - _policy(net_allow_hosts=["127.0.0.1"]), + _policy(net_allow=["127.0.0.1:443"]), policy_fn=on_event, ).run(["python3", "-c", "import socket; s=socket.socket(); s.settimeout(0.5); " @@ -262,6 +262,6 @@ def on_event(event, ctx): ctx.restrict_pid_network(event.pid, ["127.0.0.1"]) result = Sandbox( - _policy(net_allow_hosts=["127.0.0.1"]), + _policy(net_allow=["127.0.0.1:443"]), policy_fn=on_event, ).run(["echo", "ok"]) diff --git a/python/tests/test_profile.py b/python/tests/test_profile.py index 08a7108..195f2da 100644 --- a/python/tests/test_profile.py +++ b/python/tests/test_profile.py @@ -47,11 +47,10 @@ def test_boolean_and_uid_fields(self): def test_net_ports(self): p = policy_from_dict({ "net_bind": ["8080"], - "net_connect": [80, 443], + "net_allow": ["api.example.com:443", ":8080"], }) assert p.net_bind == ["8080"] - # Integers in port lists get coerced to strings - assert p.net_connect == ["80", "443"] + assert list(p.net_allow) == ["api.example.com:443", ":8080"] def test_fs_isolation_enum(self): p = policy_from_dict({"fs_isolation": "branchfs"}) diff --git a/python/tests/test_sandbox.py b/python/tests/test_sandbox.py index b44652b..9d942dc 100644 --- a/python/tests/test_sandbox.py +++ b/python/tests/test_sandbox.py @@ -217,7 +217,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_connect=[7070]) + policy = _policy(port_remap=True, net_bind=[7070], net_allow=["127.0.0.1:7070"]) result = Sandbox(policy).run(["python3", "-c", code]) assert result.success, f"Sandbox failed: {result}"