Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1d8f249
net: add IpCidr type for --net-deny matching
congwang-mk Jun 3, 2026
a7de070
net: parse --net-deny rules with CIDR and private token
congwang-mk Jun 4, 2026
7c6dd74
net: reject empty icmp --net-deny body; document IPv6 bracket form
congwang-mk Jun 4, 2026
23675be
seccomp: add NetworkPolicy::DenyList variant
congwang-mk Jun 4, 2026
7de52a6
net: resolve --net-deny rules to per-protocol policies
congwang-mk Jun 4, 2026
c62392f
sandbox: add --net-deny flag, builder method, and exclusivity check
congwang-mk Jun 4, 2026
80c76eb
sandbox: enforce --net-deny via DenyList policies in the supervisor
congwang-mk Jun 4, 2026
fe8e70d
profile: support [network].deny and --net-deny round-trip
congwang-mk Jun 4, 2026
d9ffc7f
cli: reject --net-deny under --no-supervisor
congwang-mk Jun 4, 2026
eb9a1f4
net: enforce --net-deny at runtime (relax Landlock connect, trap on-b…
congwang-mk Jun 4, 2026
2dec801
cli: wire --net-deny override into the builder and test exclusivity
congwang-mk Jun 4, 2026
f1f9163
net: make port optional and drop redundant :* in net rule grammar
congwang-mk Jun 5, 2026
88bd17c
landlock: test that --net-deny forces the TCP connect wildcard
congwang-mk Jun 5, 2026
6cc4d6a
net: remove the --net-deny private token
congwang-mk Jun 7, 2026
003cf00
net: collapse NetDeny::parse to return a single rule
congwang-mk Jun 7, 2026
cb26593
net: unify --net-allow/--net-deny rules and add CIDR/IPv6 support to …
congwang-mk Jun 7, 2026
c3067ea
docs: document --net-allow/--net-deny grammar parity (CIDR/IPv6 in al…
congwang-mk Jun 7, 2026
33bff86
net: rename --net-bind to --net-allow-bind (flag, field, profile key,…
congwang-mk Jun 7, 2026
9ffbbff
cli: accept comma-separated ports and lo-hi ranges in --net-allow-bind
congwang-mk Jun 7, 2026
2fc8524
profile: accept port range strings in [network].allow_bind for CLI/Py…
congwang-mk Jun 7, 2026
de3f269
python: split commas in parse_ports so net_allow_bind matches the CLI…
congwang-mk Jun 7, 2026
a2a470d
ffi/python: wire --net-deny into the FFI, Python SDK, and profile loader
congwang-mk Jun 7, 2026
fcdb7b7
go: fix net-allow-bind rename, add NetDeny, split commas in ParsePorts
congwang-mk Jun 7, 2026
632e31c
net: add --net-deny-bind (default-allow bind denylist) across CLI/FFI…
congwang-mk Jun 8, 2026
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
122 changes: 85 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,17 @@ 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://*:* \
# IP, CIDR range, or IPv6 literal as the target (matched by containment,
# no DNS); same grammar as --net-deny
sandlock run --net-allow 10.0.0.0/8:443 --net-allow '[2606:4700::/32]:443' \
-r /usr -r /lib -r /etc -- python3 agent.py

# 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 +131,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 10.0.0.0/8 \
-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 @@ -156,8 +166,9 @@ sandlock run \
--http-ca ca.pem --http-key ca-key.pem \
-r /usr -r /lib -r /etc -- python3 agent.py

# Server listening on a port (Landlock --net-bind, separate from --net-allow)
sandlock run --net-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py
# Server listening on ports (Landlock --net-allow-bind, separate from --net-allow;
# accepts comma-separated ports and lo-hi ranges, repeatable)
sandlock run --net-allow-bind 8080,9000-9005 -r /usr -r /lib -r /etc -- python3 server.py

# Clean environment
sandlock run --clean-env --env CC=gcc \
Expand All @@ -167,11 +178,11 @@ sandlock run --clean-env --env CC=gcc \
sandlock run --time-start "2000-01-01T00:00:00Z" --random-seed 42 -- ./build.sh

# Port virtualization (multiple sandboxes can bind the same port)
sandlock run --port-remap --net-bind 6379 -r /usr -r /lib -r /etc -- redis-server --port 6379
sandlock run --port-remap --net-allow-bind 6379 -r /usr -r /lib -r /etc -- redis-server --port 6379

# Port virtualization with named sandboxes (enables network discovery)
sandlock run --name api.local --port-remap --net-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py
sandlock run --name web.local --port-remap --net-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py
sandlock run --name api.local --port-remap --net-allow-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py
sandlock run --name web.local --port-remap --net-allow-bind 8080 -r /usr -r /lib -r /etc -- python3 server.py

# List all running sandboxes
sandlock list
Expand Down Expand Up @@ -575,17 +586,25 @@ Landlock + seccomp confinement. `CLONE_ID=0..N-1` is set automatically.

### Network Model

Outbound traffic is gated by a single endpoint allowlist that names
**protocol × destination**. Each `--net-allow` rule is one of:
Outbound traffic is gated by an endpoint list naming
**protocol × destination**. `--net-allow` (allowlist) and `--net-deny`
(denylist) share one grammar and are mutually exclusive:

```
--net-allow <spec> repeatable; no rules = deny all outbound
bare form host:port[,port,...] / :port / *:port / host:* / :* / *:* (TCP)
tcp:// same suffix grammar — explicit TCP
udp:// same suffix grammar — UDP (`udp://*:*` opens any UDP)
icmp:// host or `*`, no port — kernel ping socket (SOCK_DGRAM)
<spec> repeatable; the port is optional (a bare target = all ports)
target host | <ip> | <cidr> | * (`*` or empty target = any IP)
forms target[:port[,port,...]] · :port · host:* · :* · *:*
[<ipv6|cidr>]:port (bracket IPv6 when a port follows)
scheme tcp:// (default) · udp:// (`udp://*` = any UDP) · icmp:// (no port)

--net-allow target may also be a hostname, resolved via DNS at start
--net-deny target must be a literal IP/CIDR (no hostnames; use --http-deny)
```

A comma groups ports within one spec (`host:80,443`); to pass multiple
rules, repeat the flag. IP and CIDR targets are matched by containment
with no DNS (an IP literal is a `/32` or `/128`); only hostnames resolve.

Multiple rules are OR'd. A destination is permitted iff some rule
matches the **same protocol** as the socket plus the destination IP
and port (port is N/A for ICMP).
Expand All @@ -606,33 +625,55 @@ 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 of the allowlist: networking is
default-allow and the listed targets are blocked. It uses the same
grammar as `--net-allow` above, the only difference being that targets
must be literal IPs/CIDRs (hostnames are rejected; use `--http-deny` for
domains). Examples:

```
--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
```

**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
the real `/etc/hosts` and DNS visible.
**Resolution.** Only hostname targets touch DNS: they are resolved once
at sandbox start and pinned in a synthetic `/etc/hosts` (across all
protocols). IP and CIDR targets are matched by containment directly, so
they never resolve and never appear in `/etc/hosts`. The synthetic file
replaces the real one only when at least one rule has a concrete
hostname; rules made purely of IPs/CIDRs, `:port`, `udp://*`, or
`icmp://*` 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
*.example.com:443` is **not** supported, list each domain you need (or
use a CIDR/IP target for an address range). The `*` token is allowed as
the target (alias for empty: `*:port` ≡ `: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
allow-all.

**Implementation.** Two enforcement paths:

* **Direct path** — pure `:port` TCP policies (no concrete host)
and no HTTP ACL. Landlock enforces the TCP port allowlist at the
kernel level; no per-syscall overhead. UDP and ICMP are not
covered by Landlock and always use the on-behalf path when allowed.
* **On-behalf path** — any concrete host, any HTTP ACL rule, or any
UDP / ICMP rule. Seccomp traps `connect()`, `sendto()`, `sendmsg()`,
* **Direct path** — pure `:port` TCP policies (any IP, no concrete
host/IP/CIDR) and no HTTP ACL. Landlock enforces the TCP port
allowlist at the kernel level; no per-syscall overhead. UDP and ICMP
are not covered by Landlock and always use the on-behalf path when
allowed.
* **On-behalf path** — any host, IP, or CIDR target, any HTTP ACL
rule, or any UDP / ICMP rule (the destination IP must be checked,
which Landlock cannot do). Seccomp traps `connect()`, `sendto()`,
`sendmsg()`,
and `sendmmsg()`; the supervisor dups the child fd, queries
`getsockopt(SOL_SOCKET, SO_PROTOCOL)` to learn whether the socket
is TCP / UDP / ICMP, then checks the destination against that
Expand All @@ -655,9 +696,16 @@ var at it (e.g. `NODE_EXTRA_CA_CERTS`). Without any of these, port 443
is not intercepted: `--net-allow host:443` permits raw TLS to the host
with no content inspection.

**Bind.** `--net-bind <port>` is independent from `--net-allow` and
governs server-side `bind()`. Landlock enforces it (TCP only);
`--port-remap` adds on-behalf virtualization for binding.
**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-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
the Landlock `BIND_TCP` hook and enforces the denylist on the on-behalf
seccomp `bind()` path instead.

**AF_UNIX sockets** are governed by Landlock's
`LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET`, independent from `--net-allow`.
Expand Down
136 changes: 108 additions & 28 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,23 +341,13 @@ 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_rule(rule));
}
for rule in &base.net_deny {
b = b.net_deny(format_net_rule(rule));
}
for p in &base.net_bind { b = b.net_bind_port(*p); }
for p in &base.net_allow_bind { 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);
b = b.http_allow(&s);
Expand Down Expand Up @@ -416,7 +406,9 @@ 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 p in &pb.net_bind { builder = builder.net_bind_port(*p); }
for spec in &pb.net_deny { builder = builder.net_deny(spec); }
for spec in &pb.net_allow_bind { builder = builder.net_allow_bind(spec); }
for spec in &pb.net_deny_bind { builder = builder.net_deny_bind(spec); }
if let Some(seed) = pb.random_seed { builder = builder.random_seed(seed); }
if pb.clean_env { builder = builder.clean_env(true); }
if let Some(n) = pb.num_cpus { builder = builder.num_cpus(n); }
Expand Down Expand Up @@ -590,7 +582,11 @@ async fn run_command(args: RunArgs) -> Result<i32> {
let registered_hosts: Vec<String> = policy
.net_allow
.iter()
.filter_map(|r| r.host.clone())
.filter_map(|r| match &r.target {
sandlock_core::sandbox::NetTarget::Host(h) => Some(h.clone()),
sandlock_core::sandbox::NetTarget::Cidr(c) => Some(c.to_string()),
sandlock_core::sandbox::NetTarget::AnyIp => None,
})
.collect();
if let Err(e) = network_registry::register(
&sandbox_name, pid, std::collections::HashMap::new(),
Expand Down Expand Up @@ -664,7 +660,9 @@ 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_bind.is_empty() { bad.push("--net-bind"); }
if !pb.net_deny.is_empty() { bad.push("--net-deny"); }
if !pb.net_allow_bind.is_empty() { bad.push("--net-allow-bind"); }
if !pb.net_deny_bind.is_empty() { bad.push("--net-deny-bind"); }
if !pb.http_allow.is_empty() { bad.push("--http-allow"); }
if !pb.http_deny.is_empty() { bad.push("--http-deny"); }
if !pb.http_ports.is_empty() { bad.push("--http-port"); }
Expand Down Expand Up @@ -722,7 +720,9 @@ 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_bind.is_empty() { bad.push("[network].bind"); }
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_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"); }
if !profile.http_deny.is_empty() { bad.push("[http].deny"); }
Expand Down Expand Up @@ -763,17 +763,48 @@ 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 `NetRule` back into a `--net-allow` / `--net-deny` spec
/// string, so a profile loaded via `--profile-file` round-trips through the
/// builder. Allow and deny share one grammar: bare TCP, explicit
/// `udp://`/`icmp://`, IPv6 bracketed only when a port follows, and the
/// all-ports case drops the redundant `:*`.
fn format_net_rule(rule: &sandlock_core::sandbox::NetRule) -> String {
use sandlock_core::sandbox::{NetTarget, Protocol};
let target = match &rule.target {
NetTarget::AnyIp => "*".to_string(),
NetTarget::Host(h) => h.clone(),
NetTarget::Cidr(c) => {
// 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!("[{}]", c)
} else {
c.to_string()
}
}
};
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 +819,52 @@ 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::NetRule;

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

#[test]
fn render_allow_any_ip_all_ports_tcp_is_bare_star() {
let r = NetRule::parse_allow(":*").unwrap();
assert_eq!(format_net_rule(&r), "*");
}

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

#[test]
fn render_cidr_and_ipv6_round_trip() {
// CIDR and IPv6-literal targets render identically for allow/deny.
assert_eq!(format_net_rule(&NetRule::parse_allow("10.0.0.0/8:80").unwrap()), "10.0.0.0/8:80");
assert_eq!(format_net_rule(&NetRule::parse_deny("10.0.0.0/8").unwrap()), "10.0.0.0/8");
assert_eq!(format_net_rule(&NetRule::parse_allow("[::1]:443").unwrap()), "[::1]:443");
assert_eq!(format_net_rule(&NetRule::parse_allow("::1").unwrap()), "::1");
}

#[test]
fn render_roundtrips_through_parse() {
for spec in [
"example.com:443", "udp://1.1.1.1:53", "icmp://github.com", "*", "udp://*",
"10.0.0.0/8:80", "[fc00::/7]:443", "::1", "1.2.3.4",
] {
let r = NetRule::parse_allow(spec).unwrap();
let rendered = format_net_rule(&r);
let r2 = NetRule::parse_allow(&rendered).unwrap();
assert_eq!(r.target, r2.target, "target 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}");
}
}
}
Loading
Loading