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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,9 @@ comma-separated list of single ports or inclusive `lo-hi` ranges (e.g.
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.
unhandled). When network supervision is active (`--net-allow`, `--net-deny`,
`--http-allow`, `--port-remap`, a policy function) `bind()` runs on the
supervisor's on-behalf path, which enforces the same allowlist.
`--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
3 changes: 3 additions & 0 deletions crates/sandlock-cli/src/learn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,9 @@ pub async fn run(args: LearnArgs) -> Result<()> {
// A scheme-less "*" covers TCP and UDP; ICMP always needs its own rule.
.net_allow("*")
.net_allow("icmp://*")
// Observation must see every bind the program attempts, so nothing
// may refuse one before it is recorded.
.net_allow_bind("*")
.max_memory(sandlock_core::sandbox::ByteSize(1 << 43)) // 8 TiB
.policy_fn(move |event, _ctx| observer_cb.on_event(event));

Expand Down
35 changes: 21 additions & 14 deletions crates/sandlock-core/src/port_remap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,20 +169,21 @@ pub(crate) async fn handle_bind(
return NotifAction::Errno(libc::EACCES);
}

// Non-IP family or ephemeral (port == 0): bind verbatim — nothing to
// track or remap. extract_port returns None for non-IP families and
// for truncated buffers; in both cases the kernel will validate.
let virtual_port = match extract_port(&bytes) {
Some(p) if p != 0 => p,
_ => return bind_verbatim(&dup_fd, &bytes, addr_len),
};

// --net-deny-bind: reject binding a denied TCP port. Only TCP is gated
// (mirroring --net-allow-bind); UDP/other binds are unaffected. The
// SO_PROTOCOL probe is skipped entirely when the denylist is empty.
let denied = {
let ns = network.lock().await;
!ns.bind_deny_ports.is_empty() && ns.bind_deny_ports.contains(&virtual_port)
// Non-IP family or truncated buffer: extract_port returns None and the
// kernel validates the bind.
let ip_port = extract_port(&bytes);

// TCP bind allowlist / denylist. Only TCP is gated (Landlock's BIND_TCP
// is TCP-only); UDP/other binds are unaffected. A port-0 bind is refused
// under an allowlist because Landlock refuses it too: only `'*'` can
// express "any ephemeral port".
let denied = match ip_port {
Some(port) => {
let ns = network.lock().await;
ns.bind_allow_ports.as_ref().is_some_and(|allow| !allow.contains(&port))
|| ns.bind_deny_ports.contains(&port)
}
None => false,
};
if denied
&& crate::network::query_socket_protocol(dup_fd.as_raw_fd())
Expand All @@ -191,6 +192,12 @@ pub(crate) async fn handle_bind(
return NotifAction::Errno(libc::EACCES);
}

// Ephemeral (port == 0): bind verbatim, nothing to track or remap.
let virtual_port = match ip_port {
Some(p) if p != 0 => p,
_ => return bind_verbatim(&dup_fd, &bytes, addr_len),
};

// Pick a first-attempt port: cached real port if known, else the
// virtual port itself. The cached real port keeps repeat binds of
// the same virtual port consistent across the sandbox; the virtual
Expand Down
16 changes: 13 additions & 3 deletions crates/sandlock-core/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,9 +422,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; `All` leaves Landlock's `BIND_TCP`
/// hook unhandled so any port may be bound). Mutually exclusive with
/// `net_deny_bind`.
/// allowlist, enforced by Landlock on the direct path and by the
/// on-behalf `bind()` handler under network supervision; `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
Expand Down Expand Up @@ -2135,6 +2136,15 @@ impl Sandbox {
net_state.http_acl_ports = self.http_ports.iter().copied().collect();
net_state.http_acl_orig_dest = self.rt().http_acl_handle.as_ref().map(|h| h.orig_dest.clone());
net_state.bind_deny_ports = self.net_deny_bind.iter().copied().collect();
let net_tcp_active = self.active_protections()?
.into_iter()
.any(|(p, s)| p == Protection::NetTcp && s == ProtectionStatus::Active);
net_state.bind_allow_ports = match &self.net_allow_bind {
BindPorts::Ports(ports) if net_tcp_active && self.net_deny_bind.is_empty() => {
Some(ports.iter().copied().collect())
}
_ => None,
};
if let Some(cb) = self.rt_mut().on_bind.take() {
net_state.port_map.on_bind = Some(cb);
}
Expand Down
7 changes: 7 additions & 0 deletions crates/sandlock-core/src/seccomp/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,12 @@ pub struct NetworkState {
/// denylist). The on-behalf `bind()` handler rejects a TCP bind to any
/// port in this set with `EACCES`; empty = no bind denylist.
pub bind_deny_ports: HashSet<u16>,
/// `--net-allow-bind`: TCP ports the sandbox may bind. The on-behalf
/// `bind()` runs in the supervisor, outside the child's Landlock domain,
/// so the kernel rules never see it and this set is the only enforcer
/// there. `None` = unrestricted (`'*'` or NetTcp inactive); `Some(empty)`
/// = the default deny-all.
pub bind_allow_ports: Option<HashSet<u16>>,
/// Per-PID network overrides from policy_fn (IP-only via the legacy
/// `restrict_network(ips)` API; any port is permitted to listed IPs).
pub pid_ip_overrides: std::sync::Arc<std::sync::RwLock<HashMap<u32, HashSet<std::net::IpAddr>>>>,
Expand All @@ -487,6 +493,7 @@ impl NetworkState {
icmp_policy: crate::seccomp::notif::NetworkPolicy::Unrestricted,
port_map: crate::port_remap::PortMap::new(),
bind_deny_ports: HashSet::new(),
bind_allow_ports: None,
pid_ip_overrides: std::sync::Arc::new(std::sync::RwLock::new(HashMap::new())),
http_acl_addr: None,
http_acl_ports: HashSet::new(),
Expand Down
85 changes: 85 additions & 0 deletions crates/sandlock-core/tests/integration/test_network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1276,3 +1276,88 @@ async fn test_sendmmsg_blocking_entry_defers_and_delivers_under_net_policy() {
);
assert_eq!(got, N, "peer must receive all {N} bytes of the deferred batch entry");
}

/// Any network supervision (`--net-allow`, `--port-remap`, ...) moves
/// `bind()` onto the on-behalf path, where the supervisor binds outside the
/// child's Landlock domain. The bind allowlist must still hold there:
/// listed ports bind, unlisted ports and ephemeral `bind(0)` fail with
/// EACCES, and UDP is untouched.
#[tokio::test]
async fn test_net_allow_bind_enforced_on_behalf() {
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
let allowed = free_port();
let mut other = free_port();
while other == allowed {
other = free_port();
}

let script = format!(concat!(
"import socket, json\n",
"res = {{}}\n",
"def tcp(key, port):\n",
" s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
" s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)\n",
" try:\n",
" s.bind(('127.0.0.1', port)); res[key] = 'ok'\n",
" except PermissionError:\n",
" res[key] = 'eacces'\n",
" except OSError as e:\n",
" res[key] = 'err:%d' % e.errno\n",
" s.close()\n",
"tcp('allowed', {allowed})\n",
"tcp('other', {other})\n",
"tcp('ephemeral', 0)\n",
"u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)\n",
"try:\n",
" u.bind(('127.0.0.1', {other})); res['udp'] = 'ok'\n",
"except OSError as e:\n",
" res['udp'] = 'err:%d' % e.errno\n",
"open('{out}', 'w').write(json.dumps(res))\n",
), allowed = allowed, other = other, out = "{out}");

let supervised = [
("net_allow", base_policy().net_allow("udp://*").net_allow("127.0.0.1:1")),
("net_deny", base_policy().net_deny("10.0.0.0/8")),
("port_remap", base_policy().net_allow("udp://*").port_remap(true)),
];
for (label, builder) in supervised {
let out = temp_file(&format!("allowbind_{label}"));
let policy = builder.net_allow_bind_port(allowed).build().unwrap();
let script = script.replace("{out}", &out.display().to_string());
let result = policy.clone()
.run_interactive(&["python3", "-c", &script]).await.unwrap();
assert!(result.success(), "{label}: exit={:?}", result.code());
let content = std::fs::read_to_string(&out).unwrap_or_default();
let _ = std::fs::remove_file(&out);
assert!(content.contains("\"allowed\": \"ok\""), "{label}: listed port must bind; got: {content}");
assert!(content.contains("\"other\": \"eacces\""), "{label}: unlisted port must fail with EACCES; got: {content}");
assert!(content.contains("\"ephemeral\": \"eacces\""), "{label}: bind(0) must fail with EACCES; got: {content}");
assert!(content.contains("\"udp\": \"ok\""), "{label}: UDP bind must be unaffected; got: {content}");
}
}

/// With no allowlist at all, the default is deny-every-TCP-bind. That must
/// hold on the on-behalf path too, not only under Landlock.
#[tokio::test]
async fn test_default_bind_deny_enforced_on_behalf() {
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let out = temp_file("defaultbind");
let policy = base_policy().port_remap(true).build().unwrap();
let script = format!(concat!(
"import socket\n",
"s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
"try:\n",
" s.bind(('127.0.0.1', {port})); r = 'ok'\n",
"except PermissionError:\n",
" r = 'eacces'\n",
"open('{out}', 'w').write(r)\n",
), port = port, out = out.display());
let result = policy.clone()
.run_interactive(&["python3", "-c", &script]).await.unwrap();
assert!(result.success(), "exit={:?}", result.code());
let content = std::fs::read_to_string(&out).unwrap_or_default();
let _ = std::fs::remove_file(&out);
assert_eq!(content, "eacces", "TCP bind with no allowlist must fail under port_remap");
}
8 changes: 4 additions & 4 deletions python/tests/test_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ def test_two_sandboxes_same_virtual_port(self):
"print(s.getsockname()[1]); "
"s.close()"
)
policy = _policy(port_remap=True)
policy = _policy(port_remap=True, net_allow_bind=[8080])

r1 = policy.run(["python3", "-c", code])
r2 = policy.run(["python3", "-c", code])
Expand All @@ -601,7 +601,7 @@ def test_getsockname_returns_virtual_port(self):
"print(name[0], name[1]); "
"s.close()"
)
policy = _policy(port_remap=True)
policy = _policy(port_remap=True, net_allow_bind=[4000])
result = policy.run(["python3", "-c", code])

assert result.success
Expand All @@ -618,7 +618,7 @@ def test_ephemeral_port_not_remapped(self):
"print(s.getsockname()[1]); "
"s.close()"
)
policy = _policy(port_remap=True)
policy = _policy(port_remap=True, net_allow_bind=["*"])
result = policy.run(["python3", "-c", code])

assert result.success
Expand All @@ -633,7 +633,7 @@ def test_ipv6_bind_remapped(self):
"print(s.getsockname()[1]); "
"s.close()"
)
policy = _policy(port_remap=True)
policy = _policy(port_remap=True, net_allow_bind=[5000])
result = policy.run(["python3", "-c", code])

assert result.success
Expand Down
Loading