Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -716,8 +716,11 @@ with no content inspection.
**Bind.** `--net-allow-bind <ports>` 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 <ports>` 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
Expand Down
9 changes: 7 additions & 2 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,12 @@ async fn run_command(args: RunArgs) -> Result<i32> {
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);
Expand Down Expand Up @@ -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"); }
Expand Down
50 changes: 45 additions & 5 deletions crates/sandlock-core/src/landlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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");
}
}
4 changes: 3 additions & 1 deletion crates/sandlock-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
7 changes: 5 additions & 2 deletions crates/sandlock-core/src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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]
Expand Down
59 changes: 56 additions & 3 deletions crates/sandlock-core/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"); }
Expand Down Expand Up @@ -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<u16>),
/// `--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 {
Expand Down Expand Up @@ -332,8 +360,10 @@ pub struct Sandbox {
/// Mutually exclusive with `net_allow`.
pub net_deny: Vec<NetDeny>,
/// `--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<u16>,
/// 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`.
Expand Down Expand Up @@ -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<BindPorts, SandboxError> {
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`.
Expand All @@ -2331,6 +2378,12 @@ fn parse_bind_ports(specs: &[String], label: &str) -> Result<Vec<u16>, 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(|_| {
Expand Down
12 changes: 8 additions & 4 deletions crates/sandlock-core/src/sandbox/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

Expand Down Expand Up @@ -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<String>) -> Self {
self.net_allow_bind.push(spec.into());
self
Expand Down Expand Up @@ -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(),
));
Expand Down
53 changes: 51 additions & 2 deletions crates/sandlock-core/src/sandbox/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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()
Expand All @@ -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]
Expand Down
Loading
Loading