From 68a50a30db3fac02af6302aedc168325efbb4e8b Mon Sep 17 00:00:00 2001 From: Cong Wang Date: Fri, 10 Jul 2026 12:18:01 -0700 Subject: [PATCH] feat(net): support the '*' wildcard in --net-allow-bind Signed-off-by: Cong Wang --- README.md | 7 +- crates/sandlock-cli/src/main.rs | 9 +- crates/sandlock-core/src/landlock.rs | 50 ++++++- crates/sandlock-core/src/lib.rs | 4 +- crates/sandlock-core/src/profile.rs | 7 +- crates/sandlock-core/src/sandbox.rs | 59 +++++++- crates/sandlock-core/src/sandbox/builder.rs | 12 +- crates/sandlock-core/src/sandbox/tests.rs | 53 ++++++- .../tests/integration/test_network.rs | 130 ++++++++++++++++++ .../tests/integration/test_policy.rs | 5 +- crates/sandlock-ffi/include/sandlock.h | 21 ++- crates/sandlock-ffi/src/lib.rs | 32 +++-- docs/sandbox-reference.md | 2 +- go/internal/policy/spec.go | 45 +----- go/internal/policy/spec_test.go | 37 ----- go/sandbox.go | 2 + go/sandlock_linux.go | 26 ++-- go/sandlock_linux_test.go | 56 ++++++++ python/README.md | 2 +- python/src/sandlock/_sdk.py | 14 +- python/src/sandlock/sandbox.py | 13 +- python/tests/test_profile.py | 8 +- python/tests/test_sandbox.py | 54 +++++++- python/tests/test_sandbox_config.py | 6 +- 24 files changed, 489 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index 237cfb9e..3e5f56ee 100644 --- a/README.md +++ b/README.md @@ -716,8 +716,11 @@ with no content inspection. **Bind.** `--net-allow-bind ` is independent from `--net-allow` and governs server-side `bind()` as a default-deny allowlist. Each value is a comma-separated list of single ports or inclusive `lo-hi` ranges (e.g. -`--net-allow-bind 8080,9000-9005`), and the flag repeats. Landlock enforces -it (TCP only); `--port-remap` adds on-behalf virtualization for binding. +`--net-allow-bind 8080,9000-9005`), and the flag repeats. The `'*'` wildcard +allows binding any port, including an ephemeral `bind(0)`; it cannot be +mixed with port lists (repeating the bare wildcard is fine). Landlock enforces the +allowlist (TCP only; the wildcard simply leaves Landlock's `BIND_TCP` hook +unhandled); `--port-remap` adds on-behalf virtualization for binding. `--net-deny-bind ` is the inverse: default-allow binding, deny the listed TCP ports (same port syntax, mutually exclusive with `--net-allow-bind`). Because Landlock is allowlist-only, a deny-bind relaxes diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index ad973bf3..9a0669fe 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -377,7 +377,12 @@ async fn run_command(args: RunArgs) -> Result { for rule in &base.net_deny { b = b.net_deny(format_net_rule(rule)); } - for p in &base.net_allow_bind { b = b.net_allow_bind_port(*p); } + match &base.net_allow_bind { + sandlock_core::BindPorts::All => b = b.net_allow_bind("*"), + sandlock_core::BindPorts::Ports(ports) => { + for p in ports { b = b.net_allow_bind_port(*p); } + } + } for p in &base.net_deny_bind { b = b.net_deny_bind_port(*p); } for rule in &base.http_allow { let s = format!("{} {}{}", rule.method, rule.host, rule.path); @@ -767,7 +772,7 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()> if !profile.fs_denied.is_empty() { bad.push("[filesystem].deny"); } if !profile.net_allow.is_empty() { bad.push("[network].allow"); } if !profile.net_deny.is_empty() { bad.push("[network].deny"); } - if !profile.net_allow_bind.is_empty() { bad.push("[network].allow_bind"); } + if !profile.net_allow_bind.is_default() { bad.push("[network].allow_bind"); } if !profile.net_deny_bind.is_empty() { bad.push("[network].deny_bind"); } if profile.port_remap { bad.push("[network].port_remap"); } if !profile.http_allow.is_empty() { bad.push("[http].allow"); } diff --git a/crates/sandlock-core/src/landlock.rs b/crates/sandlock-core/src/landlock.rs index 428807c9..4026a392 100644 --- a/crates/sandlock-core/src/landlock.rs +++ b/crates/sandlock-core/src/landlock.rs @@ -340,7 +340,9 @@ pub fn compute_net_mask( // must not gate BIND_TCP. Drop it from the handled set; the on-behalf // path becomes the sole bind enforcer. (Mutually exclusive with // `--net-allow-bind`, so no kernel bind rules are installed either.) - if !sandbox.net_deny_bind.is_empty() { + // `--net-allow-bind '*'` likewise leaves BIND_TCP unhandled: every + // port is allowed and nothing enforces on the on-behalf path. + if !sandbox.net_deny_bind.is_empty() || sandbox.net_allow_bind.is_all() { mask &= !LANDLOCK_ACCESS_NET_BIND_TCP; } (mask, net_wildcard) @@ -538,11 +540,15 @@ fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError // is 0 in that case and the kernel would reject any rule with EINVAL. let net_tcp_active = ProtectionStatus::resolve(Protection::NetTcp, abi, pol) == ProtectionStatus::Active; + // `BindPorts::All` installs no rules: BIND_TCP was dropped from the + // handled set, so every bind is already allowed. if handle_net && net_tcp_active { - for &port in &policy.net_allow_bind { - add_net_rule(&ruleset_fd, port, LANDLOCK_ACCESS_NET_BIND_TCP).map_err(|e| { - SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) - })?; + if let crate::sandbox::BindPorts::Ports(ports) = &policy.net_allow_bind { + for &port in ports { + add_net_rule(&ruleset_fd, port, LANDLOCK_ACCESS_NET_BIND_TCP).map_err(|e| { + SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e)) + })?; + } } } // For TCP connect, Landlock is the only enforcer on the direct path. @@ -838,4 +844,38 @@ mod mask_contract_tests { "net_deny_bind must not affect CONNECT_TCP handling", ); } + + #[test] + fn net_mask_bind_all_drops_bind_tcp() { + // `--net-allow-bind '*'` allows every TCP bind: Landlock must not + // handle BIND_TCP at all (no rules needed, no on-behalf enforcement). + // CONNECT_TCP handling is unaffected. + let pol = ProtectionPolicy::strict_all(); + let sb = Sandbox::builder() + .net_allow_bind("*") + .build() + .expect("wildcard allow-bind sandbox builds"); + let (mask, wildcard) = compute_net_mask(6, &pol, &sb, true); + assert_eq!( + mask, + LANDLOCK_ACCESS_NET_CONNECT_TCP, + "bind-all must drop BIND_TCP from the handled set and keep CONNECT_TCP", + ); + assert!(!wildcard, "bind-all must not force the connect wildcard"); + } + + #[test] + fn net_mask_bind_all_with_net_deny_yields_zero_mask() { + // net_deny drops CONNECT_TCP (on-behalf enforces connects) and + // bind-all drops BIND_TCP: nothing is left for Landlock to handle. + let pol = ProtectionPolicy::strict_all(); + let sb = Sandbox::builder() + .net_deny("10.0.0.0/8") + .net_allow_bind("*") + .build() + .expect("net_deny + wildcard allow-bind sandbox builds"); + let (mask, wildcard) = compute_net_mask(6, &pol, &sb, true); + assert_eq!(mask, 0, "net_deny + bind-all leaves no handled net access"); + assert!(wildcard, "net_deny must still set the wildcard flag"); + } } diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index dbc3233a..03fa5452 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -35,7 +35,9 @@ pub use error::SandlockError; pub use sys::structs::{SeccompData, SeccompNotif}; pub use checkpoint::{Checkpoint, SkippedFd}; pub use protection::{Protection, ProtectionState, ProtectionPolicy, ProtectionStatus}; -pub use sandbox::{Confinement, ConfinementBuilder, Process, Sandbox, SandboxBuilder, StdioMode}; +pub use sandbox::{ + BindPorts, Confinement, ConfinementBuilder, Process, Sandbox, SandboxBuilder, StdioMode, +}; pub use result::{RunResult, ExitStatus}; pub use pipeline::{Stage, Pipeline, Gather}; pub use dry_run::{Change, ChangeKind, DryRunResult}; diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 065c6dd2..9e263974 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -515,7 +515,10 @@ mod tests { // merge is the contract being verified here. assert!(policy.net_allow.len() >= 2); // allow_bind mixes a bare int port and a quoted range string. - assert_eq!(policy.net_allow_bind, vec![8080, 9000, 9001, 9002]); + assert_eq!( + policy.net_allow_bind, + crate::sandbox::BindPorts::Ports(vec![8080, 9000, 9001, 9002]) + ); assert_eq!(policy.http_allow.len(), 1); assert_eq!(policy.fs_mount.len(), 1); } @@ -587,7 +590,7 @@ mod tests { "#; let (policy, _spec) = parse_profile(toml).unwrap(); assert_eq!(policy.net_deny_bind, vec![8080, 9000, 9001, 9002]); - assert!(policy.net_allow_bind.is_empty()); + assert!(policy.net_allow_bind.is_default()); } #[test] diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index 220100a1..3cbf110c 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -140,7 +140,7 @@ impl TryFrom<&Sandbox> for Confinement { if !sandbox.extra_deny_syscalls.is_empty() { unsupported.push("extra_deny_syscalls"); } if !sandbox.net_allow.is_empty() { unsupported.push("net_allow"); } if !sandbox.net_deny.is_empty() { unsupported.push("net_deny"); } - if !sandbox.net_allow_bind.is_empty() { unsupported.push("net_allow_bind"); } + if !sandbox.net_allow_bind.is_default() { unsupported.push("net_allow_bind"); } if !sandbox.net_deny_bind.is_empty() { unsupported.push("net_deny_bind"); } if sandbox.allows_sysv_ipc() { unsupported.push("extra_allow_syscalls=[\"sysv_ipc\"]"); } if !sandbox.http_allow.is_empty() { unsupported.push("http_allow"); } @@ -280,6 +280,34 @@ enum RuntimeState { Stopped(crate::result::ExitStatus), } +/// TCP bind allowlist (`--net-allow-bind`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum BindPorts { + /// Allow binding only the listed ports. Empty means no bind is + /// permitted while the NetTcp protection is active (the default). + Ports(Vec), + /// `--net-allow-bind '*'`: any TCP port may be bound. + All, +} + +impl Default for BindPorts { + fn default() -> Self { + BindPorts::Ports(Vec::new()) + } +} + +impl BindPorts { + /// True when no allowlist was configured (the default deny-all state). + pub fn is_default(&self) -> bool { + matches!(self, BindPorts::Ports(p) if p.is_empty()) + } + + /// True for the `'*'` wildcard (any port may be bound). + pub fn is_all(&self) -> bool { + matches!(self, BindPorts::All) + } +} + /// Sandbox configuration. #[derive(Serialize, Deserialize)] pub struct Sandbox { @@ -332,8 +360,10 @@ pub struct Sandbox { /// Mutually exclusive with `net_allow`. pub net_deny: Vec, /// `--net-allow-bind`: TCP ports the sandbox may bind (default-deny - /// allowlist, Landlock-enforced). Mutually exclusive with `net_deny_bind`. - pub net_allow_bind: Vec, + /// allowlist, Landlock-enforced; `All` leaves Landlock's `BIND_TCP` + /// hook unhandled so any port may be bound). Mutually exclusive with + /// `net_deny_bind`. + pub net_allow_bind: BindPorts, /// `--net-deny-bind`: TCP ports the sandbox may NOT bind (default-allow /// denylist, enforced on the on-behalf `bind()` path). Mutually /// exclusive with `net_allow_bind`. @@ -2317,6 +2347,23 @@ fn validate_syscall_names(names: &[String]) -> Result<(), SandboxError> { } } +/// Parse `--net-allow-bind` specs. Accepts the `*` wildcard (any port), +/// which cannot be combined with port lists; repeating the bare wildcard +/// is idempotent. +fn parse_allow_bind_ports(specs: &[String], label: &str) -> Result { + let mut parts = specs.iter().flat_map(|s| s.split(',')).map(str::trim); + if !parts.clone().any(|part| part == "*") { + return Ok(BindPorts::Ports(parse_bind_ports(specs, label)?)); + } + if !parts.all(|part| part == "*") { + return Err(SandboxError::Invalid(format!( + "{}: wildcard `*` cannot be combined with port lists", + label + ))); + } + Ok(BindPorts::All) +} + /// Expand `--net-allow-bind` specs into a sorted, deduplicated port list. /// Each spec is a comma-separated list of single ports (`8080`) or inclusive /// `lo-hi` ranges (`8000-8010`). Mirrors the Python SDK's `parse_ports`. @@ -2331,6 +2378,12 @@ fn parse_bind_ports(specs: &[String], label: &str) -> Result, SandboxEr label, spec ))); } + if part == "*" { + return Err(SandboxError::Invalid(format!( + "{}: wildcard `*` is only supported for --net-allow-bind", + label + ))); + } match part.split_once('-') { Some((lo, hi)) => { let lo: u16 = lo.trim().parse().map_err(|_| { diff --git a/crates/sandlock-core/src/sandbox/builder.rs b/crates/sandlock-core/src/sandbox/builder.rs index 432572a3..079e143a 100644 --- a/crates/sandlock-core/src/sandbox/builder.rs +++ b/crates/sandlock-core/src/sandbox/builder.rs @@ -42,7 +42,8 @@ pub struct SandboxBuilder { /// `--net-allow-bind`: TCP ports the sandbox may bind/listen on /// (default-deny). Each value is a comma-separated list of single ports - /// or inclusive `lo-hi` ranges, e.g. `8080,9000-9005`. Repeatable. + /// or inclusive `lo-hi` ranges, e.g. `8080,9000-9005`, or `'*'` to + /// allow binding any port (cannot be mixed with port lists). Repeatable. #[cfg_attr(feature = "cli", arg(long = "net-allow-bind", value_name = "PORTS"))] pub net_allow_bind: Vec, @@ -365,7 +366,10 @@ impl SandboxBuilder { } /// Allow binding TCP ports from a spec: a comma-separated list of single - /// ports or inclusive `lo-hi` ranges (e.g. `"8080,9000-9005"`). + /// ports or inclusive `lo-hi` ranges (e.g. `"8080,9000-9005"`), or the + /// `"*"` wildcard to allow binding any port. Mixing the wildcard with + /// port lists fails at build time; repeating the bare wildcard is + /// idempotent. pub fn net_allow_bind(mut self, spec: impl Into) -> Self { self.net_allow_bind.push(spec.into()); self @@ -716,9 +720,9 @@ impl SandboxBuilder { // Expand bind port specs. --net-allow-bind (default-deny allowlist) // and --net-deny-bind (default-allow denylist) are contradictory. - let net_allow_bind = parse_bind_ports(&self.net_allow_bind, "--net-allow-bind")?; + let net_allow_bind = parse_allow_bind_ports(&self.net_allow_bind, "--net-allow-bind")?; let net_deny_bind = parse_bind_ports(&self.net_deny_bind, "--net-deny-bind")?; - if !net_allow_bind.is_empty() && !net_deny_bind.is_empty() { + if !net_allow_bind.is_default() && !net_deny_bind.is_empty() { return Err(SandboxError::Invalid( "--net-allow-bind and --net-deny-bind are mutually exclusive".into(), )); diff --git a/crates/sandlock-core/src/sandbox/tests.rs b/crates/sandlock-core/src/sandbox/tests.rs index a9cc4d39..daa17a57 100644 --- a/crates/sandlock-core/src/sandbox/tests.rs +++ b/crates/sandlock-core/src/sandbox/tests.rs @@ -190,7 +190,10 @@ fn builder_net_allow_bind_comma_and_ranges() { .net_allow_bind("9001,443") // overlaps dedup away .build() .unwrap(); - assert_eq!(policy.net_allow_bind, vec![443, 8080, 9000, 9001, 9002]); + assert_eq!( + policy.net_allow_bind, + BindPorts::Ports(vec![443, 8080, 9000, 9001, 9002]) + ); } #[test] @@ -201,6 +204,52 @@ fn builder_net_allow_bind_rejects_bad_specs() { assert!(Sandbox::builder().net_allow_bind("8080,").build().is_err()); // empty part } +#[test] +fn builder_net_allow_bind_wildcard() { + // `*`, padded ` * `, and a repeated bare wildcard (idempotent) all + // mean "any port". This parser is the single implementation of the + // wildcard rule; the SDKs forward specs verbatim over the C ABI. + for specs in [vec!["*"], vec![" * "], vec!["*", "*"], vec!["*,*"]] { + let mut builder = Sandbox::builder(); + for spec in specs.iter() { + builder = builder.net_allow_bind(*spec); + } + let policy = builder.build().unwrap(); + assert_eq!(policy.net_allow_bind, BindPorts::All, "specs: {specs:?}"); + } +} + +#[test] +fn builder_net_allow_bind_wildcard_rejects_mixing() { + // Within one spec. + assert!(Sandbox::builder().net_allow_bind("*,8080").build().is_err()); + // Across specs. + assert!(Sandbox::builder() + .net_allow_bind("*") + .net_allow_bind_port(8080) + .build() + .is_err()); + assert!(Sandbox::builder() + .net_allow_bind("8080") + .net_allow_bind("*") + .build() + .is_err()); +} + +#[test] +fn builder_net_deny_bind_rejects_wildcard() { + assert!(Sandbox::builder().net_deny_bind("*").build().is_err()); +} + +#[test] +fn builder_net_allow_bind_wildcard_exclusive_with_deny_bind() { + assert!(Sandbox::builder() + .net_allow_bind("*") + .net_deny_bind_port(22) + .build() + .is_err()); +} + #[test] fn builder_rejects_net_allow_and_net_deny_together() { let err = Sandbox::builder() @@ -219,7 +268,7 @@ fn builder_net_deny_bind_comma_and_ranges() { .build() .unwrap(); assert_eq!(policy.net_deny_bind, vec![443, 8080, 9000, 9001, 9002]); - assert!(policy.net_allow_bind.is_empty()); + assert!(policy.net_allow_bind.is_default()); } #[test] diff --git a/crates/sandlock-core/tests/integration/test_network.rs b/crates/sandlock-core/tests/integration/test_network.rs index aa5613c1..2eafba2b 100644 --- a/crates/sandlock-core/tests/integration/test_network.rs +++ b/crates/sandlock-core/tests/integration/test_network.rs @@ -795,6 +795,136 @@ async fn test_net_deny_bind_blocks_tcp_only() { assert!(content.contains("\"udp_denied\": \"ok\""), "UDP on the denied port must be allowed (TCP-only); got: {content}"); } +/// `--net-allow-bind '*'` leaves Landlock's BIND_TCP hook unhandled: any +/// TCP port may be bound, including an ephemeral port-0 bind that a port +/// enumeration cannot express. The control run pins the default: with no +/// allow-bind list, a TCP bind is denied with EACCES. +#[tokio::test] +async fn test_net_allow_bind_wildcard_permits_any_bind() { + let out = temp_file("bindall"); + + let script = format!(concat!( + "import socket, json\n", + "res = {{}}\n", + "for key, port in (('fixed', 18461), ('ephemeral', 0)):\n", + " s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", + " s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n", + " try:\n", + " s.bind(('127.0.0.1', port))\n", + " res[key] = 'ok'\n", + " except OSError as e:\n", + " res[key] = 'err:%d' % e.errno\n", + " s.close()\n", + "open('{out}', 'w').write(json.dumps(res))\n", + ), out = out.display()); + + // Control: no allow-bind list means default-deny for TCP bind. + let policy = base_policy().build().unwrap(); + let result = policy.clone().with_name("test") + .run_interactive(&["python3", "-c", &script]).await.unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!( + content.contains("\"fixed\": \"err:13\""), + "default must deny TCP bind with EACCES; got: {content}" + ); + + // Wildcard: every bind succeeds. + let policy = base_policy().net_allow_bind("*").build().unwrap(); + let result = policy.clone().with_name("test") + .run_interactive(&["python3", "-c", &script]).await.unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + assert!( + content.contains("\"fixed\": \"ok\""), + "wildcard must allow a fixed-port bind; got: {content}" + ); + assert!( + content.contains("\"ephemeral\": \"ok\""), + "wildcard must allow an ephemeral port-0 bind; got: {content}" + ); +} + +/// `--net-allow-bind '*'` must loosen bind only: connect enforcement stays +/// fully active. Exercised at runtime against a live loopback listener (so a +/// wrongly-permitted connect would SUCCEED, not merely ECONNREFUSED a dead +/// port), under both connect enforcers: +/// +/// 1. Wildcard bind alone (empty `net_allow` = deny-all): Landlock's +/// `CONNECT_TCP` deny-all must survive `BIND_TCP` being dropped from the +/// handled set — connect fails with `EACCES` at the kernel. +/// 2. Wildcard bind plus a bounded `net_allow`: the destination policy is +/// enforced on-behalf, which refuses a disallowed endpoint with +/// `ECONNREFUSED`. +/// +/// In both, a fixed-port bind succeeds alongside the failing connect. +#[tokio::test] +async fn test_net_allow_bind_wildcard_keeps_connect_enforcement() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let out = temp_file("bindall-connect"); + + let script = format!(concat!( + "import socket, json\n", + "res = {{}}\n", + "b = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", + "b.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n", + "try:\n", + " b.bind(('127.0.0.1', 18517))\n", + " res['bind'] = 'ok'\n", + "except OSError as e:\n", + " res['bind'] = 'err:%d' % e.errno\n", + "b.close()\n", + "c = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n", + "c.settimeout(3)\n", + "try:\n", + " c.connect(('127.0.0.1', {port}))\n", + " res['connect'] = 'ok'\n", + "except OSError as e:\n", + " res['connect'] = 'err:%d' % e.errno\n", + "c.close()\n", + "open('{out}', 'w').write(json.dumps(res))\n", + ), port = port, out = out.display()); + + // 1. Wildcard bind, empty net_allow: Landlock CONNECT_TCP deny-all. + let policy = base_policy().net_allow_bind("*").build().unwrap(); + let result = policy.with_name("test") + .run_interactive(&["python3", "-c", &script]).await.unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + assert!( + content.contains("\"bind\": \"ok\""), + "wildcard must allow the bind (deny-all connect case); got: {content}" + ); + assert!( + content.contains("\"connect\": \"err:13\""), + "empty net_allow must still deny connect via Landlock (EACCES); got: {content}" + ); + + // 2. Wildcard bind + bounded net_allow that does not cover `port`: the + // on-behalf destination policy refuses the endpoint. + let policy = base_policy() + .net_allow_bind("*") + .net_allow("127.0.0.1:80") + .build() + .unwrap(); + let result = policy.with_name("test") + .run_interactive(&["python3", "-c", &script]).await.unwrap(); + assert!(result.success(), "exit={:?}", result.code()); + let content = std::fs::read_to_string(&out).unwrap_or_default(); + let _ = std::fs::remove_file(&out); + drop(listener); + assert!( + content.contains("\"bind\": \"ok\""), + "wildcard must allow the bind (bounded net_allow case); got: {content}" + ); + assert!( + content.contains("\"connect\": \"err:111\""), + "bounded net_allow must refuse a disallowed connect on-behalf (ECONNREFUSED); got: {content}" + ); +} + /// Regression (deny-all bypass via the unix-socket connect gate): an empty /// `net_allow` must DENY outbound TCP even when filesystem grants are present. /// diff --git a/crates/sandlock-core/tests/integration/test_policy.rs b/crates/sandlock-core/tests/integration/test_policy.rs index e5b32de3..9fde2669 100644 --- a/crates/sandlock-core/tests/integration/test_policy.rs +++ b/crates/sandlock-core/tests/integration/test_policy.rs @@ -33,7 +33,10 @@ fn test_builder_network() { .net_allow("api.example.com:443,80") .build() .unwrap(); - assert_eq!(policy.net_allow_bind, vec![8080]); + assert_eq!( + policy.net_allow_bind, + sandlock_core::BindPorts::Ports(vec![8080]) + ); assert_eq!(policy.net_allow.len(), 1); let rule = &policy.net_allow[0]; assert!(matches!(&rule.target, sandlock_core::sandbox::NetTarget::Host(h) if h == "api.example.com")); diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 81378b6d..89bb9096 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -444,18 +444,27 @@ sandlock_builder_t *sandlock_sandbox_builder_net_allow(sandlock_builder_t *b, co sandlock_builder_t *sandlock_sandbox_builder_net_deny(sandlock_builder_t *b, const char *spec); /** + * Append a `--net-allow-bind` port spec: a comma-separated list of single + * ports / inclusive `lo-hi` ranges, or the `"*"` wildcard to allow binding + * any TCP port. Spec is validated when the policy is built; invalid specs + * (including `"*"` mixed with port lists) surface as a build error. + * * # Safety - * `b` must be a valid builder pointer. + * `b` and `spec` must be valid pointers. */ -sandlock_builder_t *sandlock_sandbox_builder_net_allow_bind_port(sandlock_builder_t *b, - uint16_t port); +sandlock_builder_t *sandlock_sandbox_builder_net_allow_bind(sandlock_builder_t *b, + const char *spec); /** + * Append a `--net-deny-bind` port spec: a comma-separated list of single + * ports / inclusive `lo-hi` ranges. The `"*"` wildcard is not accepted on + * the deny side. 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. */ -sandlock_builder_t *sandlock_sandbox_builder_net_deny_bind_port(sandlock_builder_t *b, - uint16_t port); +sandlock_builder_t *sandlock_sandbox_builder_net_deny_bind(sandlock_builder_t *b, const char *spec); /** * # Safety diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index 548d5336..0af860d4 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -387,32 +387,44 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_net_deny( Box::into_raw(Box::new(builder.net_deny(spec))) } +/// Append a `--net-allow-bind` port spec: a comma-separated list of single +/// ports / inclusive `lo-hi` ranges, or the `"*"` wildcard to allow binding +/// any TCP port. Spec is validated when the policy is built; invalid specs +/// (including `"*"` mixed with port lists) 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_sandbox_builder_net_allow_bind_port( +pub unsafe extern "C" fn sandlock_sandbox_builder_net_allow_bind( b: *mut SandboxBuilder, - port: u16, + spec: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() { + 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_allow_bind_port(port))) + Box::into_raw(Box::new(builder.net_allow_bind(spec))) } +/// Append a `--net-deny-bind` port spec: a comma-separated list of single +/// ports / inclusive `lo-hi` ranges. The `"*"` wildcard is not accepted on +/// the deny side. 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_sandbox_builder_net_deny_bind_port( +pub unsafe extern "C" fn sandlock_sandbox_builder_net_deny_bind( b: *mut SandboxBuilder, - port: u16, + spec: *const c_char, ) -> *mut SandboxBuilder { - if b.is_null() { + if b.is_null() || spec.is_null() { return b; } + let spec = CStr::from_ptr(spec).to_str().unwrap_or(""); let builder = *Box::from_raw(b); - Box::into_raw(Box::new(builder.net_deny_bind_port(port))) + Box::into_raw(Box::new(builder.net_deny_bind(spec))) } /// # Safety diff --git a/docs/sandbox-reference.md b/docs/sandbox-reference.md index 750bd7fe..5db03b55 100644 --- a/docs/sandbox-reference.md +++ b/docs/sandbox-reference.md @@ -217,7 +217,7 @@ Rule shapes: | Python | TOML | Type | Default | Description | | ------------ | ------------ | ----------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `net_allow` | `allow` | `Sequence[str]` | `()` | Outbound endpoint allowlist. Empty list denies all outbound. | -| `net_allow_bind` | `allow_bind` | `Sequence[int \| str]` | `()` | TCP ports the sandbox may bind/listen on (default-deny allowlist). Each entry is a port or a `"lo-hi"` range. Landlock ABI v4+ (TCP only; UDP `bind()` is not separately gated). Mutually exclusive with `net_deny_bind`. | +| `net_allow_bind` | `allow_bind` | `Sequence[int \| str]` | `()` | TCP ports the sandbox may bind/listen on (default-deny allowlist). Each entry is a port or a `"lo-hi"` range; `"*"` allows binding any port and cannot be mixed with port entries. Landlock ABI v4+ (TCP only; UDP `bind()` is not separately gated). Mutually exclusive with `net_deny_bind`. | | `net_deny_bind` | `deny_bind` | `Sequence[int \| str]` | `()` | TCP ports the sandbox may NOT bind (default-allow denylist; inverse of `net_allow_bind`). Same port syntax. Enforced on the on-behalf `bind()` path (Landlock `BIND_TCP` is relaxed). Mutually exclusive with `net_allow_bind`. | | `port_remap` | `port_remap` | `bool` | `False` | Enable transparent TCP port virtualization. Each sandbox receives an independent virtual port space; conflicting binds are remapped to unique real ports via `pidfd_getfd`. | diff --git a/go/internal/policy/spec.go b/go/internal/policy/spec.go index 455e922c..dfd5e61c 100644 --- a/go/internal/policy/spec.go +++ b/go/internal/policy/spec.go @@ -6,16 +6,12 @@ package policy import ( "fmt" "regexp" - "slices" "strconv" "strings" "time" ) -var ( - sizeRe = regexp.MustCompile(`^\s*(\d+(?:\.\d+)?)\s*([KMGTkmgt])?\s*$`) - portRe = regexp.MustCompile(`^(\d+)(?:-(\d+))?$`) -) +var sizeRe = regexp.MustCompile(`^\s*(\d+(?:\.\d+)?)\s*([KMGTkmgt])?\s*$`) var sizeUnits = map[byte]uint64{ 'K': 1 << 10, @@ -44,45 +40,6 @@ func ParseMemory(s string) (uint64, error) { return uint64(value), nil } -// ParsePorts expands a list of port specs into a sorted, de-duplicated list of -// individual port numbers. Each spec is a comma-separated list of single ports -// ("80") or inclusive ranges ("8000-9000"), e.g. "8080,9000-9005" (matching -// the CLI's --net-allow-bind grammar). Values must fall in [0, 65535]. -func ParsePorts(specs []string) ([]uint16, error) { - set := map[uint16]struct{}{} - for _, spec := range specs { - for _, part := range strings.Split(spec, ",") { - m := portRe.FindStringSubmatch(strings.TrimSpace(part)) - if m == nil { - return nil, fmt.Errorf("invalid port spec: %q", part) - } - lo, err := strconv.Atoi(m[1]) - if err != nil { - return nil, fmt.Errorf("invalid port spec: %q", part) - } - hi := lo - if m[2] != "" { - hi, err = strconv.Atoi(m[2]) - if err != nil { - return nil, fmt.Errorf("invalid port spec: %q", part) - } - } - if lo > hi || lo < 0 || hi > 65535 { - return nil, fmt.Errorf("invalid port range: %q", part) - } - for p := lo; p <= hi; p++ { - set[uint16(p)] = struct{}{} - } - } - } - out := make([]uint16, 0, len(set)) - for p := range set { - out = append(out, p) - } - slices.Sort(out) - return out, nil -} - // ParseTimeStart resolves a time-virtualization start point to whole seconds // since the Unix epoch. It accepts an RFC 3339 / ISO 8601 timestamp // (e.g. "2000-01-01T00:00:00Z") or a plain integer/float number of seconds. diff --git a/go/internal/policy/spec_test.go b/go/internal/policy/spec_test.go index eb3b6db6..3bba5caf 100644 --- a/go/internal/policy/spec_test.go +++ b/go/internal/policy/spec_test.go @@ -1,7 +1,6 @@ package policy import ( - "reflect" "testing" ) @@ -41,42 +40,6 @@ func TestParseMemory(t *testing.T) { } } -func TestParsePorts(t *testing.T) { - cases := []struct { - in []string - want []uint16 - wantErr bool - }{ - {[]string{"80"}, []uint16{80}, false}, - {[]string{"8000-8002"}, []uint16{8000, 8001, 8002}, false}, - {[]string{"443", "80", "443"}, []uint16{80, 443}, false}, - {[]string{"3000-3001", "3001-3002"}, []uint16{3000, 3001, 3002}, false}, - {[]string{"8080,9090"}, []uint16{8080, 9090}, false}, - {[]string{"8080,9000-9002", "443"}, []uint16{443, 8080, 9000, 9001, 9002}, false}, - {nil, []uint16{}, false}, - {[]string{"70000"}, nil, true}, - {[]string{"10-5"}, nil, true}, - {[]string{"x"}, nil, true}, - {[]string{"8080,"}, nil, true}, - } - for _, c := range cases { - got, err := ParsePorts(c.in) - if c.wantErr { - if err == nil { - t.Errorf("ParsePorts(%v): expected error, got %v", c.in, got) - } - continue - } - if err != nil { - t.Errorf("ParsePorts(%v): unexpected error: %v", c.in, err) - continue - } - if !reflect.DeepEqual(got, c.want) { - t.Errorf("ParsePorts(%v) = %v, want %v", c.in, got, c.want) - } - } -} - func TestParseTimeStart(t *testing.T) { cases := []struct { in string diff --git a/go/sandbox.go b/go/sandbox.go index 57a28e61..fb9222a8 100644 --- a/go/sandbox.go +++ b/go/sandbox.go @@ -218,6 +218,8 @@ type Sandbox struct { // NetAllowBind lists TCP ports the sandbox may bind/listen on // (default-deny). Each entry is a comma-separated list of single ports // or inclusive "lo-hi" ranges ("8080", "3000-3010", "8080,9000-9005"). + // The "*" wildcard allows binding any port and cannot be mixed with + // port entries. // Mutually exclusive with NetDenyBind. NetAllowBind []string // NetDenyBind is the inverse of NetAllowBind: default-allow binding, diff --git a/go/sandlock_linux.go b/go/sandlock_linux.go index b35db46c..9769a45c 100644 --- a/go/sandlock_linux.go +++ b/go/sandlock_linux.go @@ -219,25 +219,15 @@ func (s *Sandbox) buildPolicy() (*C.sandlock_sandbox_t, error) { return C.sandlock_sandbox_builder_net_deny(b, c) }, spec) } - if len(s.NetAllowBind) > 0 { - ports, err := policy.ParsePorts(s.NetAllowBind) - if err != nil { - freeBuilderViaBuild(b) - return nil, err - } - for _, p := range ports { - b = C.sandlock_sandbox_builder_net_allow_bind_port(b, C.uint16_t(p)) - } + for _, spec := range s.NetAllowBind { + str(func(b *C.sandlock_builder_t, c *C.char) *C.sandlock_builder_t { + return C.sandlock_sandbox_builder_net_allow_bind(b, c) + }, spec) } - if len(s.NetDenyBind) > 0 { - ports, err := policy.ParsePorts(s.NetDenyBind) - if err != nil { - freeBuilderViaBuild(b) - return nil, err - } - for _, p := range ports { - b = C.sandlock_sandbox_builder_net_deny_bind_port(b, C.uint16_t(p)) - } + for _, spec := range s.NetDenyBind { + str(func(b *C.sandlock_builder_t, c *C.char) *C.sandlock_builder_t { + return C.sandlock_sandbox_builder_net_deny_bind(b, c) + }, spec) } if s.PortRemap { b = C.sandlock_sandbox_builder_port_remap(b, cbool(true)) diff --git a/go/sandlock_linux_test.go b/go/sandlock_linux_test.go index cc1acf75..74d81bc1 100644 --- a/go/sandlock_linux_test.go +++ b/go/sandlock_linux_test.go @@ -680,3 +680,59 @@ func TestRunDegradedBelowV6(t *testing.T) { t.Fatalf("degraded run failed: exit=%d stdout=%q stderr=%q", res.ExitCode, res.Stdout, res.Stderr) } } + +// TestNetAllowBindWildcard pins the end-to-end `*` path through the Go SDK: +// NetAllowBind: ["*"] forwards to the native spec entry point and permits +// both a fixed-port and an ephemeral port-0 bind, while the no-allowlist +// control still denies TCP bind with EACCES (13). Mirrors the Rust +// integration test test_net_allow_bind_wildcard_permits_any_bind. +func TestNetAllowBindWildcard(t *testing.T) { + requireLandlock(t) + if _, err := os.Stat("/usr/bin/python3"); err != nil { + t.Skip("python3 not available") + } + script := ` +import json, socket +res = {} +for key, port in (("fixed", 18493), ("ephemeral", 0)): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind(("127.0.0.1", port)) + res[key] = "ok" + except OSError as e: + res[key] = "err:%d" % e.errno + s.close() +print(json.dumps(res)) +` + + // Control: no allow-bind list means default-deny for TCP bind. + ctrl := &sandlock.Sandbox{FSReadable: rootfs} + res, err := ctrl.Run(context.Background(), "python3", "-c", script) + if err != nil { + t.Fatalf("control Run: %v", err) + } + if !res.Success { + t.Fatalf("control run failed: exit=%d stderr=%q", res.ExitCode, res.Stderr) + } + if out := string(res.Stdout); !strings.Contains(out, `"fixed": "err:13"`) { + t.Fatalf("default must deny TCP bind with EACCES; got stdout=%q", out) + } + + // Wildcard: every bind succeeds. + sb := &sandlock.Sandbox{FSReadable: rootfs, NetAllowBind: []string{"*"}} + res, err = sb.Run(context.Background(), "python3", "-c", script) + if err != nil { + t.Fatalf("wildcard Run: %v", err) + } + if !res.Success { + t.Fatalf("wildcard run failed: exit=%d stderr=%q", res.ExitCode, res.Stderr) + } + out := string(res.Stdout) + if !strings.Contains(out, `"fixed": "ok"`) { + t.Fatalf("wildcard must allow a fixed-port bind; got stdout=%q", out) + } + if !strings.Contains(out, `"ephemeral": "ok"`) { + t.Fatalf("wildcard must allow an ephemeral port-0 bind; got stdout=%q", out) + } +} diff --git a/python/README.md b/python/README.md index 89579214..2bc5ec7f 100644 --- a/python/README.md +++ b/python/README.md @@ -111,7 +111,7 @@ with Sandbox(fs_readable=["/usr", "/lib"]) as sb: | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `net_allow` | `list[str]` | `[]` | Outbound endpoint rules. Bare `host:port` is TCP; protocol prefixes opt others in: `tcp://host:port`, `udp://host:port` (or `udp://*:*` for any UDP), `icmp://host` (or `icmp://*` for any ICMP echo via the kernel ping socket — gated by `net.ipv4.ping_group_range` on the host). Empty = deny all. Raw ICMP is not exposed. | -| `net_allow_bind` | `list[int \| str]` | `[]` | TCP ports the sandbox may bind (empty = deny all) | +| `net_allow_bind` | `list[int \| str]` | `[]` | TCP ports the sandbox may bind (empty = deny all; `["*"]` = allow any port) | | `port_remap` | `bool` | `False` | Transparent TCP port virtualization | #### HTTP ACL diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index eee3c413..9ed89f03 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -89,8 +89,8 @@ def _builder_fn(name, *extra_args): _b_num_cpus = _builder_fn("sandlock_sandbox_builder_num_cpus", ctypes.c_uint32) _b_net_allow = _builder_fn("sandlock_sandbox_builder_net_allow", ctypes.c_char_p) _b_net_deny = _builder_fn("sandlock_sandbox_builder_net_deny", ctypes.c_char_p) -_b_net_allow_bind_port = _builder_fn("sandlock_sandbox_builder_net_allow_bind_port", ctypes.c_uint16) -_b_net_deny_bind_port = _builder_fn("sandlock_sandbox_builder_net_deny_bind_port", ctypes.c_uint16) +_b_net_allow_bind = _builder_fn("sandlock_sandbox_builder_net_allow_bind", ctypes.c_char_p) +_b_net_deny_bind = _builder_fn("sandlock_sandbox_builder_net_deny_bind", ctypes.c_char_p) _b_port_remap = _builder_fn("sandlock_sandbox_builder_port_remap", ctypes.c_bool) _b_http_allow = _builder_fn("sandlock_sandbox_builder_http_allow", ctypes.c_char_p) _b_http_deny = _builder_fn("sandlock_sandbox_builder_http_deny", ctypes.c_char_p) @@ -1042,7 +1042,7 @@ def __del__(self): @staticmethod def _build_from_policy(policy: PolicyDataclass): """Build a native builder from a Python Sandbox dataclass. Returns builder pointer.""" - from .sandbox import parse_memory_size, parse_ports + from .sandbox import parse_memory_size b = _lib.sandlock_sandbox_builder_new() @@ -1111,10 +1111,10 @@ def _build_from_policy(policy: PolicyDataclass): b = _b_net_allow(b, _encode(str(spec))) for spec in (policy.net_deny or []): b = _b_net_deny(b, _encode(str(spec))) - for port in parse_ports(policy.net_allow_bind) if policy.net_allow_bind else []: - b = _b_net_allow_bind_port(b, port) - for port in parse_ports(policy.net_deny_bind) if policy.net_deny_bind else []: - b = _b_net_deny_bind_port(b, port) + for spec in (policy.net_allow_bind or []): + b = _b_net_allow_bind(b, _encode(str(spec))) + for spec in (policy.net_deny_bind or []): + b = _b_net_deny_bind(b, _encode(str(spec))) for rule in (policy.http_allow or []): b = _b_http_allow(b, _encode(str(rule))) diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index 2e0359a1..b4750de0 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -215,8 +215,9 @@ class Sandbox: net_allow_bind: Sequence[int | str] = field(default_factory=list) """TCP ports the sandbox may bind (default-deny allowlist). Empty = deny all. Each entry is a port number or a ``"lo-hi"`` range string (or a - comma-separated list). Landlock's port hooks are TCP-only — UDP bind is - not separately gated. Mutually exclusive with :attr:`net_deny_bind`.""" + comma-separated list); ``["*"]`` allows binding any port and cannot be + mixed with port entries. Landlock's port hooks are TCP-only — UDP bind is not + separately gated. Mutually exclusive with :attr:`net_deny_bind`.""" net_deny_bind: Sequence[int | str] = field(default_factory=list) """TCP ports the sandbox may NOT bind (default-allow denylist; the @@ -456,14 +457,6 @@ def _ensure_native(self): # Config helper methods # ------------------------------------------------------------------ - def bind_ports(self) -> list[int]: - """Return parsed allow-bind port list, or empty if unrestricted.""" - return parse_ports(self.net_allow_bind) if self.net_allow_bind else [] - - def deny_bind_ports(self) -> list[int]: - """Return parsed deny-bind port list, or empty if none.""" - return parse_ports(self.net_deny_bind) if self.net_deny_bind else [] - def memory_bytes(self) -> int | None: """Return max_memory as bytes, or None if unset.""" if self.max_memory is None: diff --git a/python/tests/test_profile.py b/python/tests/test_profile.py index db8a0592..bed5fc0f 100644 --- a/python/tests/test_profile.py +++ b/python/tests/test_profile.py @@ -94,6 +94,12 @@ def test_network_section(self): assert list(p.net_allow) == ["api.example.com:443", ":8080"] assert p.port_remap is True + def test_network_allow_bind_wildcard(self): + p = policy_from_dict({ + "network": {"allow_bind": ["*"]}, + }) + assert p.net_allow_bind == ["*"] + def test_network_deny_section(self): p = policy_from_dict({ "network": {"deny": ["10.0.0.0/8", "169.254.169.254:80"]}, @@ -104,7 +110,7 @@ def test_network_deny_bind_section(self): p = policy_from_dict({ "network": {"deny_bind": [8080, "9000-9002"]}, }) - assert p.deny_bind_ports() == [8080, 9000, 9001, 9002] + assert list(p.net_deny_bind) == [8080, "9000-9002"] def test_http_section(self): p = policy_from_dict({ diff --git a/python/tests/test_sandbox.py b/python/tests/test_sandbox.py index 006d1725..7ccf8aa0 100644 --- a/python/tests/test_sandbox.py +++ b/python/tests/test_sandbox.py @@ -152,15 +152,63 @@ def test_net_deny_bind_builds_and_runs(self): assert result.success assert result.stdout.strip() == b"ok" - def test_deny_bind_ports_expands(self): - sb = _policy(net_deny_bind=["8080,9000-9002", 443]) - assert sb.deny_bind_ports() == [443, 8080, 9000, 9001, 9002] + def test_deny_bind_invalid_spec_rejected_at_build(self): + # Spec validation happens in the native build, like net_allow/net_deny. + with pytest.raises(RuntimeError): + _policy(net_deny_bind=["9000-8000"]).run(["echo", "ok"]) + with pytest.raises(RuntimeError, match="wildcard"): + _policy(net_deny_bind=["*"]).run(["echo", "ok"]) def test_allow_bind_and_deny_bind_mutually_exclusive(self): with pytest.raises(RuntimeError, match="mutually exclusive"): _policy(net_allow_bind=[8080], net_deny_bind=[9090]).run(["echo", "ok"]) +class TestNetAllowBindWildcard: + """`net_allow_bind=["*"]` wired through the FFI: any TCP port may be + bound while the other network protections stay active.""" + + def test_wildcard_allows_bind(self): + script = ( + "import socket\n" + "s = socket.socket()\n" + "s.bind(('127.0.0.1', 18462))\n" + "s.close()\n" + "print('BOUND')\n" + ) + result = _policy(net_allow_bind=["*"]).run(["python3", "-c", script]) + assert result.success + assert b"BOUND" in result.stdout + + def test_default_denies_bind(self): + # Control for the wildcard test: with no allow-bind list the same + # bind is denied by Landlock with EACCES. + script = ( + "import socket\n" + "s = socket.socket()\n" + "try:\n" + " s.bind(('127.0.0.1', 18462))\n" + " print('BOUND')\n" + "except PermissionError:\n" + " print('DENIED')\n" + ) + result = _policy().run(["python3", "-c", script]) + assert result.success + assert b"DENIED" in result.stdout + + def test_wildcard_mixed_with_ports_rejected(self): + with pytest.raises(RuntimeError, match="wildcard"): + _policy(net_allow_bind=["*", 8080]).run(["echo", "ok"]) + + def test_repeated_bare_wildcard_is_idempotent(self): + result = _policy(net_allow_bind=["*", "*"]).run(["echo", "ok"]) + assert result.success + + def test_wildcard_exclusive_with_deny_bind(self): + with pytest.raises(RuntimeError, match="mutually exclusive"): + _policy(net_allow_bind=["*"], net_deny_bind=[22]).run(["echo", "ok"]) + + class TestSandlockRunCAbiMultiThreaded: """Regression for issue #47 covering only the C ABI ``sandlock_run`` path. diff --git a/python/tests/test_sandbox_config.py b/python/tests/test_sandbox_config.py index ce3eb8a5..f8f44eef 100644 --- a/python/tests/test_sandbox_config.py +++ b/python/tests/test_sandbox_config.py @@ -177,13 +177,9 @@ def test_empty(self): class TestNetPolicy: - def test_bind_ports(self): - p = Sandbox(net_allow_bind=[80, "443", "8000-8002"]) - assert p.bind_ports() == [80, 443, 8000, 8001, 8002] - def test_unrestricted_by_default(self): p = Sandbox() - assert p.bind_ports() == [] + assert p.net_allow_bind == [] assert p.net_allow == []