Skip to content

Commit 5463bdb

Browse files
committed
feat(net): apply scheme-less --net-allow/--net-deny rules to both TCP and UDP
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 52d6783 commit 5463bdb

11 files changed

Lines changed: 351 additions & 218 deletions

File tree

README.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,15 @@ sandlock run --net-allow github.com -r /usr -r /lib -r /etc -- ssh user@github.c
135135
sandlock run --net-allow 10.0.0.0/8:443 --net-allow '[2606:4700::/32]:443' \
136136
-r /usr -r /lib -r /etc -- python3 agent.py
137137

138-
# Unrestricted outbound: `*` opens any host and any TCP port (`:*` / `*:*`
139-
# are equivalent). For full egress add a UDP wildcard, `udp://*`.
140-
sandlock run --net-allow '*' --net-allow 'udp://*' \
138+
# Unrestricted outbound: `*` opens any host and any port over both TCP
139+
# and UDP (`:*` / `*:*` are equivalent). ICMP still needs `icmp://`.
140+
sandlock run --net-allow '*' \
141141
-r /usr -r /lib -r /etc -- ./client
142142

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

148149
# Ping — kernel ping socket (SOCK_DGRAM) gated by net.ipv4.ping_group_range
@@ -623,12 +624,16 @@ Outbound traffic is gated by an endpoint list naming
623624
target host | <ip> | <cidr> | * (`*` or empty target = any IP)
624625
forms target[:port[,port,...]] · :port · host:* · :* · *:*
625626
[<ipv6|cidr>]:port (bracket IPv6 when a port follows)
626-
scheme tcp:// (default) · udp:// (`udp://*` = any UDP) · icmp:// (no port)
627+
scheme none = tcp + udp · tcp:// · udp:// (`udp://*` = any UDP) · icmp:// (no port)
627628
628629
--net-allow target may also be a hostname, resolved via DNS at start
629630
--net-deny target must be a literal IP/CIDR (no hostnames; use --http-deny)
630631
```
631632

633+
A spec with no scheme applies to both TCP and UDP (it expands to one
634+
rule per protocol at parse time); a scheme pins the spec to that one
635+
protocol. ICMP is never implied and always needs `icmp://`.
636+
632637
A comma groups ports within one spec (`host:80,443`); to pass multiple
633638
rules, repeat the flag. IP and CIDR targets are matched by containment
634639
with no DNS (an IP literal is a `/32` or `/128`); only hostnames resolve.
@@ -639,7 +644,8 @@ and port (port is N/A for ICMP).
639644

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

642-
* No UDP rule → UDP socket creation is denied at the seccomp layer.
647+
* No UDP rule → UDP socket creation is denied at the seccomp layer
648+
(a scheme-less rule counts for both TCP and UDP).
643649
* No ICMP rule → kernel ping socket creation (SOCK_DGRAM + IPPROTO_ICMP)
644650
is denied at the seccomp layer.
645651
* Raw ICMP (SOCK_RAW + IPPROTO_ICMP) is **never exposed** — packet
@@ -652,8 +658,8 @@ and port (port is N/A for ICMP).
652658
**Defaults.** With no `--net-allow` and no HTTP ACL flags, Landlock
653659
denies every TCP `connect()`, UDP / ICMP / raw socket creation are
654660
denied at the seccomp layer, and there is no on-behalf path active.
655-
For unrestricted TCP egress, opt in explicitly with
656-
`--net-allow '*'`; for any UDP, add `--net-allow 'udp://*'`.
661+
For unrestricted TCP and UDP egress, opt in explicitly with
662+
`--net-allow '*'`; ICMP needs its own `--net-allow 'icmp://*'`.
657663

658664
**Denylist (`--net-deny`).** The inverse of the allowlist: networking is
659665
default-allow and the listed targets are blocked. It uses the same
@@ -662,11 +668,12 @@ must be literal IPs/CIDRs (hostnames are rejected; use `--http-deny` for
662668
domains). Examples:
663669

664670
```
665-
--net-deny 10.0.0.0/8 # all ports on a CIDR (all protocols)
666-
--net-deny 169.254.169.254:80 # one IP, one port
671+
--net-deny 10.0.0.0/8 # all ports on a CIDR (TCP and UDP)
672+
--net-deny 169.254.169.254:80 # one IP, one port (TCP and UDP)
667673
--net-deny 169.254.169.254:80,443 # comma-separated ports in one rule
668-
--net-deny '*' # any IP, all ports (TCP)
669-
--net-deny 'udp://192.168.0.0/16' # any UDP to a CIDR
674+
--net-deny '*' # any IP, all ports (TCP and UDP)
675+
--net-deny 'udp://192.168.0.0/16' # UDP only, to a CIDR
676+
--net-deny 'tcp://10.0.0.1:22' # TCP only, one IP and port
670677
```
671678

672679
**Resolution.** Only hostname targets touch DNS: they are resolved once

crates/sandlock-cli/src/main.rs

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -818,9 +818,11 @@ fn validate_no_supervisor_profile(profile: &Sandbox, source: &str) -> Result<()>
818818

819819
/// Render a parsed `NetRule` back into a `--net-allow` / `--net-deny` spec
820820
/// string, so a profile loaded via `--profile-file` round-trips through the
821-
/// builder. Allow and deny share one grammar: bare TCP, explicit
822-
/// `udp://`/`icmp://`, IPv6 bracketed only when a port follows, and the
823-
/// all-ports case drops the redundant `:*`.
821+
/// builder. Allow and deny share one grammar. The scheme is always
822+
/// rendered: a scheme-less spec parses as a TCP + UDP pair, so a
823+
/// single-protocol rule must carry its scheme to round-trip exactly.
824+
/// IPv6 is bracketed only when a port follows, and the all-ports case
825+
/// drops the redundant `:*`.
824826
fn format_net_rule(rule: &sandlock_core::sandbox::NetRule) -> String {
825827
use sandlock_core::sandbox::{NetTarget, Protocol};
826828
let target = match &rule.target {
@@ -839,7 +841,7 @@ fn format_net_rule(rule: &sandlock_core::sandbox::NetRule) -> String {
839841
match rule.protocol {
840842
Protocol::Icmp => format!("icmp://{}", target),
841843
proto => {
842-
let scheme = if matches!(proto, Protocol::Udp) { "udp://" } else { "" };
844+
let scheme = if matches!(proto, Protocol::Udp) { "udp://" } else { "tcp://" };
843845
if rule.all_ports {
844846
format!("{}{}", scheme, target)
845847
} else {
@@ -880,44 +882,49 @@ mod render_tests {
880882

881883
#[test]
882884
fn render_allow_drops_redundant_all_ports_star() {
883-
let r = NetRule::parse_allow("udp://*:*").unwrap();
884-
assert_eq!(format_net_rule(&r), "udp://*");
885+
let r = &NetRule::parse_allow("udp://*:*").unwrap()[0];
886+
assert_eq!(format_net_rule(r), "udp://*");
885887
}
886888

887889
#[test]
888-
fn render_allow_any_ip_all_ports_tcp_is_bare_star() {
889-
let r = NetRule::parse_allow(":*").unwrap();
890-
assert_eq!(format_net_rule(&r), "*");
890+
fn render_allow_any_ip_all_ports_schemeless_pair() {
891+
// `:*` expands to a TCP + UDP pair; each rule renders its scheme
892+
// so the pair round-trips without widening.
893+
let rules = NetRule::parse_allow(":*").unwrap();
894+
let rendered: Vec<String> = rules.iter().map(format_net_rule).collect();
895+
assert_eq!(rendered, vec!["tcp://*", "udp://*"]);
891896
}
892897

893898
#[test]
894899
fn render_allow_host_ports() {
895-
let r = NetRule::parse_allow("example.com:443").unwrap();
896-
assert_eq!(format_net_rule(&r), "example.com:443");
900+
let rules = NetRule::parse_allow("example.com:443").unwrap();
901+
assert_eq!(format_net_rule(&rules[0]), "tcp://example.com:443");
902+
assert_eq!(format_net_rule(&rules[1]), "udp://example.com:443");
897903
}
898904

899905
#[test]
900906
fn render_cidr_and_ipv6_round_trip() {
901907
// CIDR and IPv6-literal targets render identically for allow/deny.
902-
assert_eq!(format_net_rule(&NetRule::parse_allow("10.0.0.0/8:80").unwrap()), "10.0.0.0/8:80");
903-
assert_eq!(format_net_rule(&NetRule::parse_deny("10.0.0.0/8").unwrap()), "10.0.0.0/8");
904-
assert_eq!(format_net_rule(&NetRule::parse_allow("[::1]:443").unwrap()), "[::1]:443");
905-
assert_eq!(format_net_rule(&NetRule::parse_allow("::1").unwrap()), "::1");
908+
assert_eq!(format_net_rule(&NetRule::parse_allow("10.0.0.0/8:80").unwrap()[0]), "tcp://10.0.0.0/8:80");
909+
assert_eq!(format_net_rule(&NetRule::parse_deny("10.0.0.0/8").unwrap()[0]), "tcp://10.0.0.0/8");
910+
assert_eq!(format_net_rule(&NetRule::parse_allow("[::1]:443").unwrap()[0]), "tcp://[::1]:443");
911+
assert_eq!(format_net_rule(&NetRule::parse_allow("::1").unwrap()[0]), "tcp://::1");
906912
}
907913

908914
#[test]
909915
fn render_roundtrips_through_parse() {
910916
for spec in [
911917
"example.com:443", "udp://1.1.1.1:53", "icmp://github.com", "*", "udp://*",
912-
"10.0.0.0/8:80", "[fc00::/7]:443", "::1", "1.2.3.4",
918+
"tcp://*", "10.0.0.0/8:80", "[fc00::/7]:443", "::1", "1.2.3.4",
913919
] {
914-
let r = NetRule::parse_allow(spec).unwrap();
915-
let rendered = format_net_rule(&r);
916-
let r2 = NetRule::parse_allow(&rendered).unwrap();
917-
assert_eq!(r.target, r2.target, "target mismatch for {spec}");
918-
assert_eq!(r.ports, r2.ports, "ports mismatch for {spec}");
919-
assert_eq!(r.all_ports, r2.all_ports, "all_ports mismatch for {spec}");
920-
assert_eq!(r.protocol, r2.protocol, "protocol mismatch for {spec}");
920+
for r in &NetRule::parse_allow(spec).unwrap() {
921+
let rendered = format_net_rule(r);
922+
// A rendered rule always carries its scheme, so it must
923+
// reparse to exactly one rule equal to the original.
924+
let reparsed = NetRule::parse_allow(&rendered).unwrap();
925+
assert_eq!(reparsed.len(), 1, "rendered `{rendered}` from {spec} not single");
926+
assert_eq!(*r, reparsed[0], "round-trip mismatch for {spec} via `{rendered}`");
927+
}
921928
}
922929
}
923930
}

0 commit comments

Comments
 (0)