Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 10 additions & 2 deletions crates/nono/src/capability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1066,8 +1066,16 @@ impl CapabilitySet {
/// `127.0.0.1:port`. Works across all network modes.
///
/// On macOS: outbound is per-port via Seatbelt; bind/inbound is blanket
/// (same tradeoff as `--allow-bind`).
/// On Linux: per-port ConnectTcp + BindTcp via Landlock.
/// (same tradeoff as `--allow-bind`). Port `0` generates a `localhost:*`
/// wildcard rule, allowing any localhost port.
///
/// On Linux: per-port ConnectTcp + BindTcp via Landlock. Port `0` has no
/// wildcard semantics in Landlock — `ConnectTcp/0` only matches connects to
/// destination port 0, and `BindTcp/0` only permits `bind(port=0)` (OS ephemeral
/// assignment). Neither covers connecting to a random high port such as a
/// Testcontainers mapping. Instead, port `0` causes Landlock network handling to be
/// skipped entirely, leaving the proxy as the sole domain enforcer. This is only
/// permitted in `ProxyOnly` mode; `Blocked` mode rejects port `0` at sandbox init.
#[must_use]
pub fn allow_localhost_port(mut self, port: u16) -> Self {
self.localhost_ports.push(port);
Expand Down
93 changes: 79 additions & 14 deletions crates/nono/src/sandbox/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,14 +479,49 @@ pub fn apply_with_abi(caps: &CapabilitySet, abi: &DetectedAbi) -> Result<Seccomp
info!("Using Landlock ABI {:?}", target_abi);
let scopes = requested_scopes(caps, abi)?;

if !matches!(caps.network_mode(), NetworkMode::AllowAll) && caps.localhost_ports().contains(&0)
{
// Port 0 in localhost_ports means "allow all TCP ports" (localhost wildcard).
//
// Landlock's network rules match on exact port number via a red-black tree lookup
// in security/landlock/net.c; there is no wildcard rule type and no IP filter.
// Port 0 has access-specific semantics:
// - BindTcp/port 0: matches bind(port=0), allowing OS ephemeral port assignment
// from ip_local_port_range. Useful, but only covers the bind side.
// - ConnectTcp/port 0: matches connect() to destination port 0 only — not a
// wildcard. connect(localhost:32768) still has no rule and is denied EACCES.
// There is also no way to express "allow 127.0.0.1:* but deny external:*" — rules
// carry no IP-address component.
//
// In Blocked mode there is no proxy to enforce host-level restrictions; reject here.
//
// In ProxyOnly mode the correct approach is to skip Landlock network handling entirely.
// Without handle_access(AccessNet) on the ruleset, the kernel imposes no TCP port
// restrictions. The proxy (injected via HTTPS_PROXY / HTTP_PROXY env vars) then acts
// as the sole domain enforcer. This matches macOS behaviour where Seatbelt expresses
// localhost:* as a true wildcard; Linux achieves the same result by not adding any
// Landlock TCP rules.
if matches!(caps.network_mode(), NetworkMode::Blocked) && caps.localhost_ports().contains(&0) {
return Err(NonoError::SandboxInit(
"open_port 0 (localhost TCP wildcard) is macOS-only; on Linux use explicit ports or a network profile."
"open_port 0 (localhost TCP wildcard) requires ProxyOnly mode on Linux. \
Landlock cannot restrict TCP connects by destination IP, so port 0 must \
disable Landlock network enforcement; this is only safe when a proxy \
enforces domain restrictions. Use --proxy or --allow-domain."
.to_string(),
));
}

// When port 0 is in localhost_ports with ProxyOnly, skip all Landlock network handling.
// The proxy is the sole TCP enforcer; all kernel-level port restrictions are lifted.
let localhost_wildcard_proxy = matches!(caps.network_mode(), NetworkMode::ProxyOnly { .. })
&& caps.localhost_ports().contains(&0);

if localhost_wildcard_proxy {
warn!(
"open_port 0 in ProxyOnly mode: Landlock TCP enforcement disabled. \
All ports are accessible at the kernel level; domain restrictions \
are enforced by the proxy only."
);
}

// Determine which access rights to handle based on ABI
let handled_fs = AccessFs::from_all(target_abi);

Expand All @@ -502,16 +537,22 @@ pub fn apply_with_abi(caps: &CapabilitySet, abi: &DetectedAbi) -> Result<Seccomp
.map_err(|e| NonoError::SandboxInit(format!("Failed to handle fs access: {}", e)))?
.set_compatibility(CompatLevel::BestEffort);

// Determine if we need network handling (any mode besides AllowAll)
let needs_network_handling = !matches!(caps.network_mode(), NetworkMode::AllowAll)
|| !caps.tcp_connect_ports().is_empty()
|| !caps.tcp_bind_ports().is_empty();
// Skip all kernel-level TCP filtering when localhost_wildcard_proxy is set; the proxy
// is the enforcer. Also skip when AllowAll and no explicit port lists are requested.
let needs_network_handling = !localhost_wildcard_proxy
&& (!matches!(caps.network_mode(), NetworkMode::AllowAll)
|| !caps.tcp_connect_ports().is_empty()
|| !caps.tcp_bind_ports().is_empty());

let mut seccomp_net_fallback = SeccompNetFallback::None;
// True only when Landlock itself is configured to handle AccessNet (not seccomp fallback,
// and not skipped via localhost_wildcard_proxy). Only then can NetPort rules be added.
let mut network_handled_by_landlock = false;

let ruleset_builder = if needs_network_handling {
let handled_net = AccessNet::from_all(target_abi);
if !handled_net.is_empty() {
network_handled_by_landlock = true;
debug!("Handling network access: {:?}", handled_net);
ruleset_builder
.set_compatibility(CompatLevel::HardRequirement)
Expand Down Expand Up @@ -594,10 +635,10 @@ pub fn apply_with_abi(caps: &CapabilitySet, abi: &DetectedAbi) -> Result<Seccomp
.create()
.map_err(|e| NonoError::SandboxInit(format!("Failed to create ruleset: {}", e)))?;

// Add Landlock network port rules ONLY when Landlock is handling networking.
// When a seccomp fallback is active (BlockAll or ProxyOnly), the ruleset was
// created without handle_access(AccessNet), so adding NetPort rules would fail.
if matches!(seccomp_net_fallback, SeccompNetFallback::None) {
// Add Landlock network port rules only when Landlock itself is handling networking.
// Skipped when seccomp fallback is active (ruleset has no AccessNet), or when
// localhost_wildcard_proxy disabled Landlock network handling entirely.
if network_handled_by_landlock {
// Add per-port TCP connect rules (ProxyOnly port + explicit tcp_connect_ports)
if let NetworkMode::ProxyOnly { port, bind_ports } = caps.network_mode() {
debug!("Adding ProxyOnly TCP connect rule for port {}", port);
Expand Down Expand Up @@ -648,6 +689,8 @@ pub fn apply_with_abi(caps: &CapabilitySet, abi: &DetectedAbi) -> Result<Seccomp
// Add localhost IPC port rules (connect + bind per port).
// Only meaningful in Blocked/ProxyOnly modes. In AllowAll mode, all ports are
// already reachable and adding Landlock network handling would restrict them.
// Port 0 (localhost wildcard in ProxyOnly mode) is handled above by skipping
// network handling entirely, so localhost_ports here never contains 0 in that path.
if !matches!(caps.network_mode(), NetworkMode::AllowAll) {
for port in caps.localhost_ports() {
debug!("Adding localhost TCP connect rule for port {}", port);
Expand Down Expand Up @@ -3219,17 +3262,39 @@ mod tests {
);
}

/// Rejects `open_port: [0]` on Linux for any restricted network mode (not Landlock-only).
/// Rejects `open_port: [0]` in Blocked mode — no proxy to enforce host-level restrictions.
#[test]
fn test_reject_localhost_port_wildcard_zero_on_linux() {
fn test_reject_localhost_port_wildcard_zero_in_blocked_mode() {
let Ok(detected) = detect_abi() else {
return;
};
let mut caps = CapabilitySet::new().block_network();
caps.add_localhost_port(0);
let err = apply_with_abi(&caps, &detected).expect_err("port 0 wildcard must be rejected");
let msg = format!("{err}");
assert!(msg.contains("macOS-only"), "unexpected error: {msg}");
assert!(
msg.contains("ProxyOnly mode"),
"unexpected error: {msg}"
);
}

/// Accepts `open_port: [0]` in ProxyOnly mode on Linux (Landlock v4+) — the proxy
/// enforces domain restrictions; port 0 acts as a wildcard to allow localhost
/// connections on dynamically assigned ports (e.g. Testcontainers, Maven Surefire).
#[test]
fn test_accept_localhost_port_wildcard_zero_in_proxy_only_mode() {
let Ok(detected) = detect_abi() else {
return;
};
if !detected.has_network() {
// Landlock < v4: no TCP filtering; test not applicable.
return;
}
let mut caps = CapabilitySet::new().proxy_only(8080);
caps.add_localhost_port(0);
// Should not return an error; port 0 is the Landlock wildcard.
apply_with_abi(&caps, &detected)
.expect("port 0 in ProxyOnly mode must be accepted on Linux");
}
Comment thread
jbndbc marked this conversation as resolved.

#[test]
Expand Down
Loading