Skip to content

Commit 0098225

Browse files
committed
feat(net): support the '*' wildcard in --net-allow-bind
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 143742e commit 0098225

24 files changed

Lines changed: 489 additions & 165 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -727,8 +727,11 @@ with no content inspection.
727727
**Bind.** `--net-allow-bind <ports>` is independent from `--net-allow` and
728728
governs server-side `bind()` as a default-deny allowlist. Each value is a
729729
comma-separated list of single ports or inclusive `lo-hi` ranges (e.g.
730-
`--net-allow-bind 8080,9000-9005`), and the flag repeats. Landlock enforces
731-
it (TCP only); `--port-remap` adds on-behalf virtualization for binding.
730+
`--net-allow-bind 8080,9000-9005`), and the flag repeats. The `'*'` wildcard
731+
allows binding any port, including an ephemeral `bind(0)`; it cannot be
732+
mixed with port lists (repeating the bare wildcard is fine). Landlock enforces the
733+
allowlist (TCP only; the wildcard simply leaves Landlock's `BIND_TCP` hook
734+
unhandled); `--port-remap` adds on-behalf virtualization for binding.
732735
`--net-deny-bind <ports>` is the inverse: default-allow binding, deny the
733736
listed TCP ports (same port syntax, mutually exclusive with
734737
`--net-allow-bind`). Because Landlock is allowlist-only, a deny-bind relaxes

crates/sandlock-cli/src/main.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,12 @@ async fn run_command(args: RunArgs) -> Result<i32> {
377377
for rule in &base.net_deny {
378378
b = b.net_deny(format_net_rule(rule));
379379
}
380-
for p in &base.net_allow_bind { b = b.net_allow_bind_port(*p); }
380+
match &base.net_allow_bind {
381+
sandlock_core::BindPorts::All => b = b.net_allow_bind("*"),
382+
sandlock_core::BindPorts::Ports(ports) => {
383+
for p in ports { b = b.net_allow_bind_port(*p); }
384+
}
385+
}
381386
for p in &base.net_deny_bind { b = b.net_deny_bind_port(*p); }
382387
for rule in &base.http_allow {
383388
let s = format!("{} {}{}", rule.method, rule.host, rule.path);
@@ -769,7 +774,7 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()>
769774
if !profile.fs_denied.is_empty() { bad.push("[filesystem].deny"); }
770775
if !profile.net_allow.is_empty() { bad.push("[network].allow"); }
771776
if !profile.net_deny.is_empty() { bad.push("[network].deny"); }
772-
if !profile.net_allow_bind.is_empty() { bad.push("[network].allow_bind"); }
777+
if !profile.net_allow_bind.is_default() { bad.push("[network].allow_bind"); }
773778
if !profile.net_deny_bind.is_empty() { bad.push("[network].deny_bind"); }
774779
if profile.port_remap { bad.push("[network].port_remap"); }
775780
if !profile.http_allow.is_empty() { bad.push("[http].allow"); }

crates/sandlock-core/src/landlock.rs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,9 @@ pub fn compute_net_mask(
340340
// must not gate BIND_TCP. Drop it from the handled set; the on-behalf
341341
// path becomes the sole bind enforcer. (Mutually exclusive with
342342
// `--net-allow-bind`, so no kernel bind rules are installed either.)
343-
if !sandbox.net_deny_bind.is_empty() {
343+
// `--net-allow-bind '*'` likewise leaves BIND_TCP unhandled: every
344+
// port is allowed and nothing enforces on the on-behalf path.
345+
if !sandbox.net_deny_bind.is_empty() || sandbox.net_allow_bind.is_all() {
344346
mask &= !LANDLOCK_ACCESS_NET_BIND_TCP;
345347
}
346348
(mask, net_wildcard)
@@ -538,11 +540,15 @@ fn confine_inner(policy: &Sandbox, handle_net: bool) -> Result<(), SandlockError
538540
// is 0 in that case and the kernel would reject any rule with EINVAL.
539541
let net_tcp_active =
540542
ProtectionStatus::resolve(Protection::NetTcp, abi, pol) == ProtectionStatus::Active;
543+
// `BindPorts::All` installs no rules: BIND_TCP was dropped from the
544+
// handled set, so every bind is already allowed.
541545
if handle_net && net_tcp_active {
542-
for &port in &policy.net_allow_bind {
543-
add_net_rule(&ruleset_fd, port, LANDLOCK_ACCESS_NET_BIND_TCP).map_err(|e| {
544-
SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e))
545-
})?;
546+
if let crate::sandbox::BindPorts::Ports(ports) = &policy.net_allow_bind {
547+
for &port in ports {
548+
add_net_rule(&ruleset_fd, port, LANDLOCK_ACCESS_NET_BIND_TCP).map_err(|e| {
549+
SandlockError::Runtime(crate::error::SandboxRuntimeError::Confinement(e))
550+
})?;
551+
}
546552
}
547553
}
548554
// For TCP connect, Landlock is the only enforcer on the direct path.
@@ -838,4 +844,38 @@ mod mask_contract_tests {
838844
"net_deny_bind must not affect CONNECT_TCP handling",
839845
);
840846
}
847+
848+
#[test]
849+
fn net_mask_bind_all_drops_bind_tcp() {
850+
// `--net-allow-bind '*'` allows every TCP bind: Landlock must not
851+
// handle BIND_TCP at all (no rules needed, no on-behalf enforcement).
852+
// CONNECT_TCP handling is unaffected.
853+
let pol = ProtectionPolicy::strict_all();
854+
let sb = Sandbox::builder()
855+
.net_allow_bind("*")
856+
.build()
857+
.expect("wildcard allow-bind sandbox builds");
858+
let (mask, wildcard) = compute_net_mask(6, &pol, &sb, true);
859+
assert_eq!(
860+
mask,
861+
LANDLOCK_ACCESS_NET_CONNECT_TCP,
862+
"bind-all must drop BIND_TCP from the handled set and keep CONNECT_TCP",
863+
);
864+
assert!(!wildcard, "bind-all must not force the connect wildcard");
865+
}
866+
867+
#[test]
868+
fn net_mask_bind_all_with_net_deny_yields_zero_mask() {
869+
// net_deny drops CONNECT_TCP (on-behalf enforces connects) and
870+
// bind-all drops BIND_TCP: nothing is left for Landlock to handle.
871+
let pol = ProtectionPolicy::strict_all();
872+
let sb = Sandbox::builder()
873+
.net_deny("10.0.0.0/8")
874+
.net_allow_bind("*")
875+
.build()
876+
.expect("net_deny + wildcard allow-bind sandbox builds");
877+
let (mask, wildcard) = compute_net_mask(6, &pol, &sb, true);
878+
assert_eq!(mask, 0, "net_deny + bind-all leaves no handled net access");
879+
assert!(wildcard, "net_deny must still set the wildcard flag");
880+
}
841881
}

crates/sandlock-core/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ pub use error::SandlockError;
3636
pub use sys::structs::{SeccompData, SeccompNotif};
3737
pub use checkpoint::{Checkpoint, SkippedFd};
3838
pub use protection::{Protection, ProtectionState, ProtectionPolicy, ProtectionStatus};
39-
pub use sandbox::{Confinement, ConfinementBuilder, Process, Sandbox, SandboxBuilder, StdioMode};
39+
pub use sandbox::{
40+
BindPorts, Confinement, ConfinementBuilder, Process, Sandbox, SandboxBuilder, StdioMode,
41+
};
4042
pub use result::{RunResult, ExitStatus};
4143
pub use pipeline::{Stage, Pipeline, Gather};
4244
pub use dry_run::{Change, ChangeKind, DryRunResult};

crates/sandlock-core/src/profile.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,10 @@ mod tests {
515515
// merge is the contract being verified here.
516516
assert!(policy.net_allow.len() >= 2);
517517
// allow_bind mixes a bare int port and a quoted range string.
518-
assert_eq!(policy.net_allow_bind, vec![8080, 9000, 9001, 9002]);
518+
assert_eq!(
519+
policy.net_allow_bind,
520+
crate::sandbox::BindPorts::Ports(vec![8080, 9000, 9001, 9002])
521+
);
519522
assert_eq!(policy.http_allow.len(), 1);
520523
assert_eq!(policy.fs_mount.len(), 1);
521524
}
@@ -587,7 +590,7 @@ mod tests {
587590
"#;
588591
let (policy, _spec) = parse_profile(toml).unwrap();
589592
assert_eq!(policy.net_deny_bind, vec![8080, 9000, 9001, 9002]);
590-
assert!(policy.net_allow_bind.is_empty());
593+
assert!(policy.net_allow_bind.is_default());
591594
}
592595

593596
#[test]

crates/sandlock-core/src/sandbox.rs

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ impl TryFrom<&Sandbox> for Confinement {
140140
if !sandbox.extra_deny_syscalls.is_empty() { unsupported.push("extra_deny_syscalls"); }
141141
if !sandbox.net_allow.is_empty() { unsupported.push("net_allow"); }
142142
if !sandbox.net_deny.is_empty() { unsupported.push("net_deny"); }
143-
if !sandbox.net_allow_bind.is_empty() { unsupported.push("net_allow_bind"); }
143+
if !sandbox.net_allow_bind.is_default() { unsupported.push("net_allow_bind"); }
144144
if !sandbox.net_deny_bind.is_empty() { unsupported.push("net_deny_bind"); }
145145
if sandbox.allows_sysv_ipc() { unsupported.push("extra_allow_syscalls=[\"sysv_ipc\"]"); }
146146
if !sandbox.http_allow.is_empty() { unsupported.push("http_allow"); }
@@ -281,6 +281,34 @@ enum RuntimeState {
281281
Stopped(crate::result::ExitStatus),
282282
}
283283

284+
/// TCP bind allowlist (`--net-allow-bind`).
285+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286+
pub enum BindPorts {
287+
/// Allow binding only the listed ports. Empty means no bind is
288+
/// permitted while the NetTcp protection is active (the default).
289+
Ports(Vec<u16>),
290+
/// `--net-allow-bind '*'`: any TCP port may be bound.
291+
All,
292+
}
293+
294+
impl Default for BindPorts {
295+
fn default() -> Self {
296+
BindPorts::Ports(Vec::new())
297+
}
298+
}
299+
300+
impl BindPorts {
301+
/// True when no allowlist was configured (the default deny-all state).
302+
pub fn is_default(&self) -> bool {
303+
matches!(self, BindPorts::Ports(p) if p.is_empty())
304+
}
305+
306+
/// True for the `'*'` wildcard (any port may be bound).
307+
pub fn is_all(&self) -> bool {
308+
matches!(self, BindPorts::All)
309+
}
310+
}
311+
284312
/// Sandbox configuration.
285313
#[derive(Serialize, Deserialize)]
286314
pub struct Sandbox {
@@ -333,8 +361,10 @@ pub struct Sandbox {
333361
/// Mutually exclusive with `net_allow`.
334362
pub net_deny: Vec<NetDeny>,
335363
/// `--net-allow-bind`: TCP ports the sandbox may bind (default-deny
336-
/// allowlist, Landlock-enforced). Mutually exclusive with `net_deny_bind`.
337-
pub net_allow_bind: Vec<u16>,
364+
/// allowlist, Landlock-enforced; `All` leaves Landlock's `BIND_TCP`
365+
/// hook unhandled so any port may be bound). Mutually exclusive with
366+
/// `net_deny_bind`.
367+
pub net_allow_bind: BindPorts,
338368
/// `--net-deny-bind`: TCP ports the sandbox may NOT bind (default-allow
339369
/// denylist, enforced on the on-behalf `bind()` path). Mutually
340370
/// exclusive with `net_allow_bind`.
@@ -2332,6 +2362,23 @@ fn validate_syscall_names(names: &[String]) -> Result<(), SandboxError> {
23322362
}
23332363
}
23342364

2365+
/// Parse `--net-allow-bind` specs. Accepts the `*` wildcard (any port),
2366+
/// which cannot be combined with port lists; repeating the bare wildcard
2367+
/// is idempotent.
2368+
fn parse_allow_bind_ports(specs: &[String], label: &str) -> Result<BindPorts, SandboxError> {
2369+
let mut parts = specs.iter().flat_map(|s| s.split(',')).map(str::trim);
2370+
if !parts.clone().any(|part| part == "*") {
2371+
return Ok(BindPorts::Ports(parse_bind_ports(specs, label)?));
2372+
}
2373+
if !parts.all(|part| part == "*") {
2374+
return Err(SandboxError::Invalid(format!(
2375+
"{}: wildcard `*` cannot be combined with port lists",
2376+
label
2377+
)));
2378+
}
2379+
Ok(BindPorts::All)
2380+
}
2381+
23352382
/// Expand `--net-allow-bind` specs into a sorted, deduplicated port list.
23362383
/// Each spec is a comma-separated list of single ports (`8080`) or inclusive
23372384
/// `lo-hi` ranges (`8000-8010`). Mirrors the Python SDK's `parse_ports`.
@@ -2346,6 +2393,12 @@ fn parse_bind_ports(specs: &[String], label: &str) -> Result<Vec<u16>, SandboxEr
23462393
label, spec
23472394
)));
23482395
}
2396+
if part == "*" {
2397+
return Err(SandboxError::Invalid(format!(
2398+
"{}: wildcard `*` is only supported for --net-allow-bind",
2399+
label
2400+
)));
2401+
}
23492402
match part.split_once('-') {
23502403
Some((lo, hi)) => {
23512404
let lo: u16 = lo.trim().parse().map_err(|_| {

crates/sandlock-core/src/sandbox/builder.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ pub struct SandboxBuilder {
4242

4343
/// `--net-allow-bind`: TCP ports the sandbox may bind/listen on
4444
/// (default-deny). Each value is a comma-separated list of single ports
45-
/// or inclusive `lo-hi` ranges, e.g. `8080,9000-9005`. Repeatable.
45+
/// or inclusive `lo-hi` ranges, e.g. `8080,9000-9005`, or `'*'` to
46+
/// allow binding any port (cannot be mixed with port lists). Repeatable.
4647
#[cfg_attr(feature = "cli", arg(long = "net-allow-bind", value_name = "PORTS"))]
4748
pub net_allow_bind: Vec<String>,
4849

@@ -386,7 +387,10 @@ impl SandboxBuilder {
386387
}
387388

388389
/// Allow binding TCP ports from a spec: a comma-separated list of single
389-
/// ports or inclusive `lo-hi` ranges (e.g. `"8080,9000-9005"`).
390+
/// ports or inclusive `lo-hi` ranges (e.g. `"8080,9000-9005"`), or the
391+
/// `"*"` wildcard to allow binding any port. Mixing the wildcard with
392+
/// port lists fails at build time; repeating the bare wildcard is
393+
/// idempotent.
390394
pub fn net_allow_bind(mut self, spec: impl Into<String>) -> Self {
391395
self.net_allow_bind.push(spec.into());
392396
self
@@ -823,9 +827,9 @@ impl SandboxBuilder {
823827

824828
// Expand bind port specs. --net-allow-bind (default-deny allowlist)
825829
// and --net-deny-bind (default-allow denylist) are contradictory.
826-
let net_allow_bind = parse_bind_ports(&self.net_allow_bind, "--net-allow-bind")?;
830+
let net_allow_bind = parse_allow_bind_ports(&self.net_allow_bind, "--net-allow-bind")?;
827831
let net_deny_bind = parse_bind_ports(&self.net_deny_bind, "--net-deny-bind")?;
828-
if !net_allow_bind.is_empty() && !net_deny_bind.is_empty() {
832+
if !net_allow_bind.is_default() && !net_deny_bind.is_empty() {
829833
return Err(SandboxError::Invalid(
830834
"--net-allow-bind and --net-deny-bind are mutually exclusive".into(),
831835
));

crates/sandlock-core/src/sandbox/tests.rs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,10 @@ fn builder_net_allow_bind_comma_and_ranges() {
190190
.net_allow_bind("9001,443") // overlaps dedup away
191191
.build()
192192
.unwrap();
193-
assert_eq!(policy.net_allow_bind, vec![443, 8080, 9000, 9001, 9002]);
193+
assert_eq!(
194+
policy.net_allow_bind,
195+
BindPorts::Ports(vec![443, 8080, 9000, 9001, 9002])
196+
);
194197
}
195198

196199
#[test]
@@ -201,6 +204,52 @@ fn builder_net_allow_bind_rejects_bad_specs() {
201204
assert!(Sandbox::builder().net_allow_bind("8080,").build().is_err()); // empty part
202205
}
203206

207+
#[test]
208+
fn builder_net_allow_bind_wildcard() {
209+
// `*`, padded ` * `, and a repeated bare wildcard (idempotent) all
210+
// mean "any port". This parser is the single implementation of the
211+
// wildcard rule; the SDKs forward specs verbatim over the C ABI.
212+
for specs in [vec!["*"], vec![" * "], vec!["*", "*"], vec!["*,*"]] {
213+
let mut builder = Sandbox::builder();
214+
for spec in specs.iter() {
215+
builder = builder.net_allow_bind(*spec);
216+
}
217+
let policy = builder.build().unwrap();
218+
assert_eq!(policy.net_allow_bind, BindPorts::All, "specs: {specs:?}");
219+
}
220+
}
221+
222+
#[test]
223+
fn builder_net_allow_bind_wildcard_rejects_mixing() {
224+
// Within one spec.
225+
assert!(Sandbox::builder().net_allow_bind("*,8080").build().is_err());
226+
// Across specs.
227+
assert!(Sandbox::builder()
228+
.net_allow_bind("*")
229+
.net_allow_bind_port(8080)
230+
.build()
231+
.is_err());
232+
assert!(Sandbox::builder()
233+
.net_allow_bind("8080")
234+
.net_allow_bind("*")
235+
.build()
236+
.is_err());
237+
}
238+
239+
#[test]
240+
fn builder_net_deny_bind_rejects_wildcard() {
241+
assert!(Sandbox::builder().net_deny_bind("*").build().is_err());
242+
}
243+
244+
#[test]
245+
fn builder_net_allow_bind_wildcard_exclusive_with_deny_bind() {
246+
assert!(Sandbox::builder()
247+
.net_allow_bind("*")
248+
.net_deny_bind_port(22)
249+
.build()
250+
.is_err());
251+
}
252+
204253
#[test]
205254
fn builder_rejects_net_allow_and_net_deny_together() {
206255
let err = Sandbox::builder()
@@ -219,7 +268,7 @@ fn builder_net_deny_bind_comma_and_ranges() {
219268
.build()
220269
.unwrap();
221270
assert_eq!(policy.net_deny_bind, vec![443, 8080, 9000, 9001, 9002]);
222-
assert!(policy.net_allow_bind.is_empty());
271+
assert!(policy.net_allow_bind.is_default());
223272
}
224273

225274
#[test]

0 commit comments

Comments
 (0)