Skip to content
Open
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
51 changes: 39 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,12 @@ sandlock run --net-allow api.openai.com:443 -r /usr -r /lib -r /etc -- python3 a
sandlock run --net-allow github.com:22,443 --net-allow :8080 \
-r /usr -r /lib -r /etc -- python3 agent.py

# Wildcard port `host:*` permits every port to the host
sandlock run --net-allow github.com:* -r /usr -r /lib -r /etc -- ssh user@github.com
# Wildcard port (optional): a bare `host` (or `host:*`) permits every port
sandlock run --net-allow github.com -r /usr -r /lib -r /etc -- ssh user@github.com

# Unrestricted outbound — `:*` opens any host and any TCP port. For full
# egress add a UDP wildcard via the `udp://*:*` scheme.
sandlock run --net-allow :* --net-allow udp://*:* \
# Unrestricted outbound: `*` opens any host and any TCP port (`:*` / `*:*`
# are equivalent). For full egress add a UDP wildcard, `udp://*`.
sandlock run --net-allow '*' --net-allow 'udp://*' \
-r /usr -r /lib -r /etc -- ./client

# UDP — scheme prefix gates the protocol and scopes the destination
Expand All @@ -126,6 +126,11 @@ sandlock run --net-allow udp://1.1.1.1:53 --net-allow :443 \
# Ping — kernel ping socket (SOCK_DGRAM) gated by net.ipv4.ping_group_range
sandlock run --net-allow icmp://github.com -r /usr -r /lib -r /etc -- ping github.com

# Denylist: default-allow networking, block specific IPs/CIDRs/ports
# (inverse of --net-allow; mutually exclusive with it). Port is optional.
sandlock run --net-deny 169.254.169.254 --net-deny private \
-r /usr -r /lib -r /etc -- python3 agent.py

# HTTP-level ACL (method + host + path rules via transparent proxy)
# HTTP rules with concrete hosts auto-extend --net-allow with host:80,443
sandlock run \
Expand Down Expand Up @@ -580,9 +585,10 @@ Outbound traffic is gated by a single endpoint allowlist that names

```
--net-allow <spec> repeatable; no rules = deny all outbound
bare form host:port[,port,...] / :port / *:port / host:* / :* / *:* (TCP)
bare form host[:port[,port,...]] / :port / *:port / host:* / :* / *:* (TCP)
the port is optional: `host` or `*` alone means all ports
tcp:// same suffix grammar — explicit TCP
udp:// same suffix grammar — UDP (`udp://*:*` opens any UDP)
udp:// same suffix grammar — UDP (`udp://*` opens any UDP)
icmp:// host or `*`, no port — kernel ping socket (SOCK_DGRAM)
```

Expand All @@ -606,20 +612,41 @@ and port (port is N/A for ICMP).
denies every TCP `connect()`, UDP / ICMP / raw socket creation are
denied at the seccomp layer, and there is no on-behalf path active.
For unrestricted TCP egress, opt in explicitly with
`--net-allow :*`; for any UDP, add `--net-allow udp://*:*`.
`--net-allow '*'`; for any UDP, add `--net-allow 'udp://*'`.

**Denylist (`--net-deny`).** The inverse model: networking is
default-allow and the listed targets are blocked. Mutually exclusive
with `--net-allow`. Targets are literal IPs or CIDRs (hostnames are
rejected; use `--http-deny` for domains); the port is optional and
`*` matches any IP. The same scheme grammar applies (`tcp://` default,
`udp://`, `icmp://`), and the `private` token expands to all internal
ranges across every protocol.

```
--net-deny 10.0.0.0/8 # all ports on a CIDR (all protocols)
--net-deny 169.254.169.254:80 # one IP, one port
--net-deny 169.254.169.254:80,443 # comma-separated ports in one rule
--net-deny '*' # any IP, all ports (TCP)
--net-deny 'udp://192.168.0.0/16' # any UDP to a CIDR
--net-deny private # all internal ranges
```

Repeat the flag for multiple rules.

**Resolution.** Concrete hostnames are resolved once at sandbox start
and pinned in a synthetic `/etc/hosts` (across all protocols). The
synthetic file replaces the real one only when at least one rule has
a concrete host; pure `:port` / `udp://*:*` / `icmp://*` rules leave
a concrete host; pure `:port` / `udp://*` / `icmp://*` rules leave
the real `/etc/hosts` and DNS visible.

**Wildcards.** Hostnames are matched literally — `--net-allow
*.example.com:443` is **not** supported, list each domain you need.
The `*` token is allowed as the host (alias for empty: `*:port` ≡
`:port`) and as the port for TCP/UDP rules (`host:*`, `:*`, `*:*`,
`udp://*:*`). Mixing `*` with concrete ports (`host:80,*`) is
rejected. When any TCP rule uses the all-ports wildcard, Landlock no
`:port`) and as the port for TCP/UDP rules (`host:*`, `:*`, `*:*`).
The port is optional: omitting it means all ports, so `host` ≡
`host:*` and `*` ≡ `:*` ≡ `*:*` (and `udp://*` ≡ `udp://*:*`). Mixing
`*` with concrete ports (`host:80,*`) is rejected. When any TCP rule
uses the all-ports wildcard, Landlock no
longer filters TCP connect at the kernel level (it cannot express
"every port" without enumerating 65535 rules); the on-behalf path
becomes the sole enforcer, and for `:*` it short-circuits to
Expand Down
124 changes: 101 additions & 23 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,21 +341,10 @@ async fn run_command(args: RunArgs) -> Result<i32> {
for p in &base.fs_writable { b = b.fs_write(p); }
for p in &base.fs_denied { b = b.fs_deny(p); }
for rule in &base.net_allow {
let host_part = rule.host.as_deref().unwrap_or("*");
let spec = match rule.protocol {
sandlock_core::sandbox::Protocol::Tcp => {
let ports = format_ports(&rule.ports, rule.all_ports);
format!("tcp://{}:{}", host_part, ports)
}
sandlock_core::sandbox::Protocol::Udp => {
let ports = format_ports(&rule.ports, rule.all_ports);
format!("udp://{}:{}", host_part, ports)
}
sandlock_core::sandbox::Protocol::Icmp => {
format!("icmp://{}", host_part)
}
};
b = b.net_allow(spec);
b = b.net_allow(format_net_allow(rule));
}
for rule in &base.net_deny {
b = b.net_deny(format_net_deny(rule));
}
for p in &base.net_bind { b = b.net_bind_port(*p); }
for rule in &base.http_allow {
Expand Down Expand Up @@ -416,6 +405,7 @@ async fn run_command(args: RunArgs) -> Result<i32> {
for p in &pb.fs_writable { builder = builder.fs_write(p); }
if let Some(n) = pb.max_processes { builder = builder.max_processes(n); }
for spec in &pb.net_allow { builder = builder.net_allow(spec); }
for spec in &pb.net_deny { builder = builder.net_deny(spec); }
for p in &pb.net_bind { builder = builder.net_bind_port(*p); }
if let Some(seed) = pb.random_seed { builder = builder.random_seed(seed); }
if pb.clean_env { builder = builder.clean_env(true); }
Expand Down Expand Up @@ -664,6 +654,7 @@ fn validate_no_supervisor(args: &RunArgs) -> Result<()> {
if pb.max_open_files.is_some() { bad.push("--max-open-files"); }
if args.timeout.is_some() { bad.push("--timeout"); }
if !pb.net_allow.is_empty() { bad.push("--net-allow"); }
if !pb.net_deny.is_empty() { bad.push("--net-deny"); }
if !pb.net_bind.is_empty() { bad.push("--net-bind"); }
if !pb.http_allow.is_empty() { bad.push("--http-allow"); }
if !pb.http_deny.is_empty() { bad.push("--http-deny"); }
Expand Down Expand Up @@ -722,6 +713,7 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()>

if !profile.fs_denied.is_empty() { bad.push("[filesystem].deny"); }
if !profile.net_allow.is_empty() { bad.push("[network].allow"); }
if !profile.net_deny.is_empty() { bad.push("[network].deny"); }
if !profile.net_bind.is_empty() { bad.push("[network].bind"); }
if profile.port_remap { bad.push("[network].port_remap"); }
if !profile.http_allow.is_empty() { bad.push("[http].allow"); }
Expand Down Expand Up @@ -763,17 +755,66 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()>
Ok(())
}

/// Parse an ISO 8601 timestamp (e.g. "2000-01-01T00:00:00Z") into a SystemTime.
/// Render a port list back into the `--net-allow` port-suffix form:
/// concrete ports become `80,443`; the all-ports wildcard becomes `*`.
fn format_ports(ports: &[u16], all_ports: bool) -> String {
if all_ports {
"*".to_string()
} else {
ports.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(",")
/// Render a parsed NetAllow rule back into a --net-allow spec string, so a
/// profile loaded via --profile-file round-trips through the builder. Mirrors
/// `format_net_deny`: bare TCP, explicit `udp://`/`icmp://`, and the all-ports
/// case drops the redundant `:*`.
fn format_net_allow(rule: &sandlock_core::sandbox::NetAllow) -> String {
use sandlock_core::sandbox::Protocol;
let host = rule.host.as_deref().unwrap_or("*");
match rule.protocol {
Protocol::Icmp => format!("icmp://{}", host),
proto => {
let scheme = if matches!(proto, Protocol::Udp) { "udp://" } else { "" };
if rule.all_ports {
format!("{}{}", scheme, host)
} else {
let ports = format_ports(&rule.ports);
format!("{}{}:{}", scheme, host, ports)
}
}
}
}

/// Render a parsed NetDeny rule back into a --net-deny spec string, so a
/// profile loaded via --profile-file round-trips through the builder.
fn format_net_deny(rule: &sandlock_core::sandbox::NetDeny) -> String {
use sandlock_core::sandbox::{DenyTarget, Protocol};
let target = match &rule.target {
DenyTarget::AnyIp => "*".to_string(),
DenyTarget::Cidr(c) => {
let base = format!("{}/{}", c.addr, c.prefix_len);
// Bracket IPv6 only when a port suffix will follow, because a
// bare addr:port is itself a valid IPv6 address.
if matches!(c.addr, std::net::IpAddr::V6(_)) && !rule.all_ports {
format!("[{}]", base)
} else {
base
}
}
};
match rule.protocol {
Protocol::Icmp => format!("icmp://{}", target),
proto => {
let scheme = if matches!(proto, Protocol::Udp) { "udp://" } else { "" };
if rule.all_ports {
format!("{}{}", scheme, target)
} else {
let ports = format_ports(&rule.ports);
format!("{}{}:{}", scheme, target, ports)
}
}
}
}

/// Render a concrete port list into the comma-separated port-suffix form
/// (`80,443`). The all-ports case is handled by the callers, which drop the
/// suffix entirely rather than emitting `:*`.
fn format_ports(ports: &[u16]) -> String {
ports.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(",")
}

/// Parse an ISO 8601 timestamp (e.g. "2000-01-01T00:00:00Z") into a SystemTime.
fn parse_time_start(s: &str) -> Result<SystemTime> {
let ts: jiff::Timestamp = s.parse()
.map_err(|e| anyhow!("invalid --time-start '{}': {}", s, e))?;
Expand All @@ -788,3 +829,40 @@ fn parse_branch_action(flag: &str, s: &str) -> Result<BranchAction> {
other => Err(anyhow!("invalid {} value '{}': expected commit | abort | keep", flag, other)),
}
}

#[cfg(test)]
mod render_tests {
use super::*;
use sandlock_core::sandbox::NetAllow;

#[test]
fn render_allow_drops_redundant_all_ports_star() {
let r = NetAllow::parse("udp://*:*").unwrap();
assert_eq!(format_net_allow(&r), "udp://*");
}

#[test]
fn render_allow_any_ip_all_ports_tcp_is_bare_star() {
let r = NetAllow::parse(":*").unwrap();
assert_eq!(format_net_allow(&r), "*");
}

#[test]
fn render_allow_host_ports() {
let r = NetAllow::parse("example.com:443").unwrap();
assert_eq!(format_net_allow(&r), "example.com:443");
}

#[test]
fn render_allow_roundtrips_through_parse() {
for spec in ["example.com:443", "udp://1.1.1.1:53", "icmp://github.com", "*", "udp://*"] {
let r = NetAllow::parse(spec).unwrap();
let rendered = format_net_allow(&r);
let r2 = NetAllow::parse(&rendered).unwrap();
assert_eq!(r.host, r2.host, "host mismatch for {spec}");
assert_eq!(r.ports, r2.ports, "ports mismatch for {spec}");
assert_eq!(r.all_ports, r2.all_ports, "all_ports mismatch for {spec}");
assert_eq!(r.protocol, r2.protocol, "protocol mismatch for {spec}");
}
}
}
24 changes: 24 additions & 0 deletions crates/sandlock-cli/tests/cli_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,30 @@ fn test_no_supervisor_rejects_fs_deny() {
assert!(stderr.contains("--fs-deny"), "stderr: {}", stderr);
}

#[test]
fn test_no_supervisor_rejects_net_deny() {
let output = sandlock_bin()
.args(["run", "--no-supervisor", "--net-deny", "10.0.0.0/8", "--", "/bin/true"])
.output()
.expect("failed to run");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("--net-deny"), "stderr: {}", stderr);
}

#[test]
fn test_net_allow_and_net_deny_are_mutually_exclusive() {
// Also guards the CLI wiring: --net-deny must reach build(), otherwise
// the exclusivity check never fires and the flag is silently dropped.
let output = sandlock_bin()
.args(["run", "--net-allow", "github.com:443", "--net-deny", "10.0.0.0/8", "--", "/bin/true"])
.output()
.expect("failed to run");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("mutually exclusive"), "stderr: {}", stderr);
}

#[test]
fn test_no_supervisor_rejects_incompatible_flags() {
let output = sandlock_bin()
Expand Down
21 changes: 20 additions & 1 deletion crates/sandlock-core/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ const PORT_REMAP_SYSCALLS: &[i64] = &[

fn needs_network_supervision(policy: &Sandbox) -> bool {
!policy.net_allow.is_empty()
|| !policy.net_deny.is_empty()
|| policy.policy_fn.is_some()
|| !policy.http_allow.is_empty()
|| !policy.http_deny.is_empty()
Expand Down Expand Up @@ -584,9 +585,14 @@ pub fn arg_filters(policy: &Sandbox) -> Vec<SockFilter> {
use crate::sandbox::Protocol;
let any_udp_rule = policy.net_allow.iter().any(|r| r.protocol == Protocol::Udp);
let any_icmp_rule = policy.net_allow.iter().any(|r| r.protocol == Protocol::Icmp);
// `--net-deny` is default-allow, so UDP and the kernel ping socket
// (both SOCK_DGRAM) must be creatable; without this the sandbox
// could not even do DNS over UDP. Per-destination UDP/ICMP denial
// is still enforced on the sendto on-behalf path via the DenyList.
let net_deny_active = !policy.net_deny.is_empty();
let mut blocked_types: Vec<u32> = Vec::new();
blocked_types.push(SOCK_RAW);
if !any_udp_rule && !any_icmp_rule {
if !any_udp_rule && !any_icmp_rule && !net_deny_active {
blocked_types.push(SOCK_DGRAM);
}

Expand Down Expand Up @@ -1170,6 +1176,19 @@ mod tests {
assert!(nrs.contains(&(libc::SYS_sendmmsg as u32)));
}

#[test]
fn test_notif_syscalls_net_deny() {
// --net-deny is default-allow but still needs every connect/sendto
// routed to the on-behalf path so the DenyList can refuse matches.
let policy = Sandbox::builder()
.net_deny("10.0.0.0/8")
.build()
.unwrap();
let nrs = notif_syscalls(&policy, None);
assert!(nrs.contains(&(libc::SYS_connect as u32)));
assert!(nrs.contains(&(libc::SYS_sendto as u32)));
}

#[test]
fn test_notif_syscalls_sandbox_name_enables_hostname_virtualization() {
let policy = Sandbox::builder().build().unwrap();
Expand Down
35 changes: 31 additions & 4 deletions crates/sandlock-core/src/landlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,11 @@ pub fn compute_fs_mask(abi: u32, pol: &ProtectionPolicy) -> u64 {
/// covers every port we drop `CONNECT_TCP` from the handled set (the
/// on-behalf path is then the sole enforcer).
///
/// `--net-deny` is default-allow: every TCP connect must reach the
/// on-behalf seccomp path (the DenyList enforcer), so Landlock must not
/// gate `CONNECT_TCP` at all. A non-empty `net_deny` therefore forces the
/// wildcard treatment, exactly like an all-ports `--net-allow` rule.
///
/// Returns `(0, false)` when `Protection::NetTcp` is not `Active`
/// (either disabled by policy or degraded on a kernel that does not
/// provide TCP network hooks).
Expand All @@ -302,10 +307,11 @@ pub fn compute_net_mask(
return (0, false);
}
use crate::sandbox::Protocol;
let net_wildcard = sandbox
.net_allow
.iter()
.any(|r| r.protocol == Protocol::Tcp && r.all_ports);
let net_wildcard = !sandbox.net_deny.is_empty()
|| sandbox
.net_allow
.iter()
.any(|r| r.protocol == Protocol::Tcp && r.all_ports);
let mask = if net_wildcard {
LANDLOCK_ACCESS_NET_BIND_TCP
} else {
Expand Down Expand Up @@ -731,4 +737,25 @@ mod mask_contract_tests {
assert_eq!(mask, 0);
assert!(!wildcard);
}

#[test]
fn net_mask_net_deny_forces_wildcard_dropping_connect_tcp() {
// `--net-deny` is default-allow and enforced on the on-behalf
// seccomp path, so Landlock must not gate CONNECT_TCP: a non-empty
// net_deny forces the wildcard treatment (BIND_TCP only), exactly
// like an all-ports --net-allow rule. This pins the reconciliation
// of the net-deny runtime relaxation with compute_net_mask.
let pol = ProtectionPolicy::strict_all();
let sb = Sandbox::builder()
.net_deny("10.0.0.0/8")
.build()
.expect("net_deny sandbox builds");
let (mask, wildcard) = compute_net_mask(6, &pol, &sb, true);
assert_eq!(
mask,
LANDLOCK_ACCESS_NET_BIND_TCP,
"net_deny must drop CONNECT_TCP so all TCP connects reach the on-behalf path",
);
assert!(wildcard, "net_deny must set the wildcard flag");
}
}
Loading
Loading