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
35 changes: 21 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,15 @@ sandlock run --net-allow github.com -r /usr -r /lib -r /etc -- ssh user@github.c
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://*' \
# Unrestricted outbound: `*` opens any host and any port over both TCP
# and UDP (`:*` / `*:*` are equivalent). ICMP still needs `icmp://`.
sandlock run --net-allow '*' \
-r /usr -r /lib -r /etc -- ./client

# UDP — scheme prefix gates the protocol and scopes the destination
# (e.g. DNS to 1.1.1.1, plus TCP HTTPS to anywhere)
sandlock run --net-allow udp://1.1.1.1:53 --net-allow :443 \
# Scheme prefix: a spec with no scheme covers TCP and UDP; `tcp://` or
# `udp://` pins one protocol (e.g. UDP DNS to 1.1.1.1 only, plus
# TCP-only HTTPS to anywhere)
sandlock run --net-allow udp://1.1.1.1:53 --net-allow tcp://:443 \
-r /usr -r /lib -r /etc -- ./client

# Ping — kernel ping socket (SOCK_DGRAM) gated by net.ipv4.ping_group_range
Expand Down Expand Up @@ -623,12 +624,16 @@ Outbound traffic is gated by an endpoint list naming
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)
scheme none = tcp + udp · tcp:// · 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 spec with no scheme applies to both TCP and UDP (it expands to one
rule per protocol at parse time); a scheme pins the spec to that one
protocol. ICMP is never implied and always needs `icmp://`.

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.
Expand All @@ -639,7 +644,8 @@ and port (port is N/A for ICMP).

**Protocol gating** falls out of rule presence per scheme:

* No UDP rule → UDP socket creation is denied at the seccomp layer.
* No UDP rule → UDP socket creation is denied at the seccomp layer
(a scheme-less rule counts for both TCP and UDP).
* No ICMP rule → kernel ping socket creation (SOCK_DGRAM + IPPROTO_ICMP)
is denied at the seccomp layer.
* Raw ICMP (SOCK_RAW + IPPROTO_ICMP) is **never exposed** — packet
Expand All @@ -652,8 +658,8 @@ and port (port is N/A for ICMP).
**Defaults.** With no `--net-allow` and no HTTP ACL flags, Landlock
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://*'`.
For unrestricted TCP and UDP egress, opt in explicitly with
`--net-allow '*'`; ICMP needs its own `--net-allow 'icmp://*'`.

**Denylist (`--net-deny`).** The inverse of the allowlist: networking is
default-allow and the listed targets are blocked. It uses the same
Expand All @@ -662,11 +668,12 @@ 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 10.0.0.0/8 # all ports on a CIDR (TCP and UDP)
--net-deny 169.254.169.254:80 # one IP, one port (TCP and UDP)
--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 '*' # any IP, all ports (TCP and UDP)
--net-deny 'udp://192.168.0.0/16' # UDP only, to a CIDR
--net-deny 'tcp://10.0.0.1:22' # TCP only, one IP and port
```

**Resolution.** Only hostname targets touch DNS: they are resolved once
Expand Down
53 changes: 30 additions & 23 deletions crates/sandlock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -818,9 +818,11 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()>

/// 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 `:*`.
/// builder. Allow and deny share one grammar. The scheme is always
/// rendered: a scheme-less spec parses as a TCP + UDP pair, so a
/// single-protocol rule must carry its scheme to round-trip exactly.
/// IPv6 is 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 {
Expand All @@ -839,7 +841,7 @@ fn format_net_rule(rule: &sandlock_core::sandbox::NetRule) -> String {
match rule.protocol {
Protocol::Icmp => format!("icmp://{}", target),
proto => {
let scheme = if matches!(proto, Protocol::Udp) { "udp://" } else { "" };
let scheme = if matches!(proto, Protocol::Udp) { "udp://" } else { "tcp://" };
if rule.all_ports {
format!("{}{}", scheme, target)
} else {
Expand Down Expand Up @@ -880,44 +882,49 @@ mod render_tests {

#[test]
fn render_allow_drops_redundant_all_ports_star() {
let r = NetRule::parse_allow("udp://*:*").unwrap();
assert_eq!(format_net_rule(&r), "udp://*");
let r = &NetRule::parse_allow("udp://*:*").unwrap()[0];
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), "*");
fn render_allow_any_ip_all_ports_schemeless_pair() {
// `:*` expands to a TCP + UDP pair; each rule renders its scheme
// so the pair round-trips without widening.
let rules = NetRule::parse_allow(":*").unwrap();
let rendered: Vec<String> = rules.iter().map(format_net_rule).collect();
assert_eq!(rendered, vec!["tcp://*", "udp://*"]);
}

#[test]
fn render_allow_host_ports() {
let r = NetRule::parse_allow("example.com:443").unwrap();
assert_eq!(format_net_rule(&r), "example.com:443");
let rules = NetRule::parse_allow("example.com:443").unwrap();
assert_eq!(format_net_rule(&rules[0]), "tcp://example.com:443");
assert_eq!(format_net_rule(&rules[1]), "udp://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");
assert_eq!(format_net_rule(&NetRule::parse_allow("10.0.0.0/8:80").unwrap()[0]), "tcp://10.0.0.0/8:80");
assert_eq!(format_net_rule(&NetRule::parse_deny("10.0.0.0/8").unwrap()[0]), "tcp://10.0.0.0/8");
assert_eq!(format_net_rule(&NetRule::parse_allow("[::1]:443").unwrap()[0]), "tcp://[::1]:443");
assert_eq!(format_net_rule(&NetRule::parse_allow("::1").unwrap()[0]), "tcp://::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",
"tcp://*", "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}");
for r in &NetRule::parse_allow(spec).unwrap() {
let rendered = format_net_rule(r);
// A rendered rule always carries its scheme, so it must
// reparse to exactly one rule equal to the original.
let reparsed = NetRule::parse_allow(&rendered).unwrap();
assert_eq!(reparsed.len(), 1, "rendered `{rendered}` from {spec} not single");
assert_eq!(*r, reparsed[0], "round-trip mismatch for {spec} via `{rendered}`");
}
}
}
}
Loading
Loading