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: 26 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ 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

# Unrestricted outbound — `:*` opens any host and any port. UDP socket
# creation is still gated by --allow-udp; pair the two for full egress.
sandlock run --net-allow :* --allow-udp -r /usr -r /lib -r /etc -- ./client

# UDP — opt in to UDP and allowlist the destination (e.g. DNS)
sandlock run --allow-udp --net-allow 1.1.1.1:53 --net-allow :443 \
-r /usr -r /lib -r /etc -- ./client
Expand Down Expand Up @@ -517,23 +524,32 @@ one rule. The same allowlist applies to TCP `connect()` and to UDP
```
--net-allow <spec> repeatable; no rules = deny all outbound
<spec> = host:port[,port,...] (IP-restricted)
| :port | *:port (any IP)
| :port | *:port (any IP, listed port)
| host:* (host, any port)
| :* | *:* (any IP, any port)
```

**Defaults.** With no `--net-allow` and no HTTP ACL flags, Landlock
denies every TCP `connect()`, UDP and raw socket creation are denied
at the seccomp layer, and there is no on-behalf path active. There is
no "allow-all networking" mode — opt in with explicit endpoints.
at the seccomp layer, and there is no on-behalf path active. For
unrestricted egress, opt in explicitly with `--net-allow :*` (still
UDP-gated by `--allow-udp`).

**Resolution.** Concrete hostnames are resolved once at sandbox start
and pinned in a synthetic `/etc/hosts`. The synthetic file replaces
the real one only when `--net-allow` includes at least one concrete
host; pure `:port` rules 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 `*` form is only valid as the host part of a `*:port` rule (alias
for `:port`).
**Wildcards.** Hostnames are matched literally — `--net-allow
*.example.com:443` is **not** supported, list each domain you need.
The `*` token is allowed in two positions: as the host (alias for
empty: `*:port` ≡ `:port`) and as the port to mean "any port"
(`host:*`, `:*`, `*:*`). Mixing `*` with concrete ports
(`host:80,*`) is rejected — use either the wildcard or an explicit
list. When any 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:

Expand Down Expand Up @@ -635,8 +651,9 @@ Policy(
allow_syscalls=None, # Allowlist mode (stricter)

# Network — see "Network Model" above. Each entry is `host:port[,port,...]`,
# `:port`, or `*:port`. Empty list = deny all outbound. Same allowlist
# gates UDP destinations when allow_udp=True (e.g. `:53` for DNS).
# `:port`, `*:port`, `host:*`, or `:*` / `*:*`. Empty list = deny all
# outbound; `:*` = unrestricted. Same allowlist gates UDP destinations
# when allow_udp=True (e.g. `:53` for DNS).
net_allow=["api.example.com:443", "github.com:22,443", ":8080"],
net_bind=[8080], # TCP bind ports (Landlock; ABI v4+)

Expand Down
46 changes: 32 additions & 14 deletions crates/sandlock-core/src/landlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,21 @@ pub fn confine(policy: &Policy) -> Result<(), SandlockError> {
// Step 2 -- build handled_access_fs / handled_access_net / scoped.
let handled_access_fs = base_fs_access(abi);

// Always restrict TCP bind/connect via Landlock. Empty net_bind/net_connect
// means deny all — same semantics as fs_readable/fs_writable.
let handled_access_net = LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP;
// Restrict TCP bind/connect via Landlock by default. When any
// `--net-allow` rule has the all-ports wildcard (`host:*` or
// `:*`), Landlock cannot express "every port" without enumerating
// 65535 rules, so we drop CONNECT_TCP from the handled set —
// unhandled access is unrestricted by Landlock. The on-behalf
// path (seccomp notif on connect/sendto/sendmsg) still enforces
// the per-rule IP allowlist when the rule is `host:*`. For `:*`
// the on-behalf path becomes `NetworkPolicy::Unrestricted` (no
// additional check). Bind enforcement is unaffected.
let net_wildcard = policy.net_allow.iter().any(|r| r.all_ports);
let handled_access_net = if net_wildcard {
LANDLOCK_ACCESS_NET_BIND_TCP
} else {
LANDLOCK_ACCESS_NET_BIND_TCP | LANDLOCK_ACCESS_NET_CONNECT_TCP
};

// IPC and signal isolation are always enabled.
let scoped = LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET | LANDLOCK_SCOPE_SIGNAL;
Expand Down Expand Up @@ -286,19 +298,25 @@ pub fn confine(policy: &Policy) -> Result<(), SandlockError> {
// the kernel rejects before seccomp gets a chance to dispatch. Allow
// every port that any --net-allow rule mentions, plus every HTTP
// intercept port; the on-behalf check ensures the IP also matches.
let mut connect_ports: std::collections::HashSet<u16> = std::collections::HashSet::new();
for rule in &policy.net_allow {
for &p in &rule.ports {
//
// When `net_wildcard` is set we already excluded CONNECT_TCP from
// `handled_access_net`, so adding rules here would fail with EINVAL.
// Skip — the on-behalf path is the sole enforcer.
if !net_wildcard {
let mut connect_ports: std::collections::HashSet<u16> = std::collections::HashSet::new();
for rule in &policy.net_allow {
for &p in &rule.ports {
connect_ports.insert(p);
}
}
for &p in &policy.http_ports {
connect_ports.insert(p);
}
}
for &p in &policy.http_ports {
connect_ports.insert(p);
}
for port in connect_ports {
add_net_rule(&ruleset_fd, port, LANDLOCK_ACCESS_NET_CONNECT_TCP).map_err(|e| {
SandlockError::Sandbox(crate::error::SandboxError::Confinement(e))
})?;
for port in connect_ports {
add_net_rule(&ruleset_fd, port, LANDLOCK_ACCESS_NET_CONNECT_TCP).map_err(|e| {
SandlockError::Sandbox(crate::error::SandboxError::Confinement(e))
})?;
}
}

// Step 6 — enforce (irreversible).
Expand Down
95 changes: 88 additions & 7 deletions crates/sandlock-core/src/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -603,10 +603,20 @@ pub(crate) async fn handle_net(
/// Resolved form of `Policy::net_allow`, ready for the on-behalf path.
pub struct ResolvedNetAllow {
/// Per-IP port rules (each concrete-host entry resolves to one or
/// more IPs).
/// more IPs). An IP appearing here with an empty port set means
/// "all ports for this IP" (from a `host:*` rule).
pub per_ip: HashMap<IpAddr, HashSet<u16>>,
/// IPs permitted on every port (from `host:*` rules after host
/// resolution). The on-behalf path treats these the same as
/// `PortAllow::Any` — the entry in `per_ip` is kept as a
/// placeholder for diagnostic / `/etc/hosts` purposes.
pub per_ip_all_ports: HashSet<IpAddr>,
/// Ports permitted to any IP (the `:port` form).
pub any_ip_ports: HashSet<u16>,
/// Any-host any-port wildcard (`:*` / `*:*`). When true, the
/// sandbox is fully unrestricted on outbound TCP/UDP and the
/// on-behalf path is bypassed (`NetworkPolicy::Unrestricted`).
pub any_ip_all_ports: bool,
/// Synthetic `/etc/hosts` content for any concrete hostnames.
/// `None` when no concrete hostnames are present (real `/etc/hosts`
/// stays visible).
Expand All @@ -618,15 +628,21 @@ pub async fn resolve_net_allow(
rules: &[crate::policy::NetAllow],
) -> io::Result<ResolvedNetAllow> {
let mut per_ip: HashMap<IpAddr, HashSet<u16>> = HashMap::new();
let mut per_ip_all_ports: HashSet<IpAddr> = HashSet::new();
let mut any_ip_ports: HashSet<u16> = HashSet::new();
let mut any_ip_all_ports = false;
let mut etc_hosts = String::from("127.0.0.1 localhost\n::1 localhost\n");
let mut has_concrete_host = false;

for rule in rules {
match &rule.host {
None => {
for &p in &rule.ports {
any_ip_ports.insert(p);
if rule.all_ports {
any_ip_all_ports = true;
} else {
for &p in &rule.ports {
any_ip_ports.insert(p);
}
}
}
Some(host) => {
Expand All @@ -640,9 +656,17 @@ pub async fn resolve_net_allow(
})?;
for socket_addr in resolved {
let ip = socket_addr.ip();
let entry = per_ip.entry(ip).or_default();
for &p in &rule.ports {
entry.insert(p);
if rule.all_ports {
per_ip_all_ports.insert(ip);
// Keep an entry in per_ip so callers iterating
// resolved hosts still see this IP. The runtime
// policy honors per_ip_all_ports first.
per_ip.entry(ip).or_default();
} else {
let entry = per_ip.entry(ip).or_default();
for &p in &rule.ports {
entry.insert(p);
}
}
etc_hosts.push_str(&format!("{} {}\n", ip, host));
}
Expand All @@ -652,7 +676,9 @@ pub async fn resolve_net_allow(

Ok(ResolvedNetAllow {
per_ip,
per_ip_all_ports,
any_ip_ports,
any_ip_all_ports,
etc_hosts: if has_concrete_host { Some(etc_hosts) } else { None },
})
}
Expand All @@ -679,6 +705,7 @@ mod tests {
let rules = vec![NetAllow {
host: Some("localhost".to_string()),
ports: vec![80, 443],
all_ports: false,
}];
let resolved = resolve_net_allow(&rules).await.unwrap();
// localhost should resolve to at least one loopback addr.
Expand All @@ -692,10 +719,64 @@ mod tests {

#[tokio::test]
async fn test_resolve_net_allow_any_ip() {
let rules = vec![NetAllow { host: None, ports: vec![8080] }];
let rules = vec![NetAllow { host: None, ports: vec![8080], all_ports: false }];
let resolved = resolve_net_allow(&rules).await.unwrap();
assert!(resolved.per_ip.is_empty());
assert!(resolved.any_ip_ports.contains(&8080));
assert!(!resolved.any_ip_all_ports);
assert!(resolved.etc_hosts.is_none());
}

#[tokio::test]
async fn test_resolve_net_allow_any_ip_all_ports() {
// `:*` — fully unrestricted egress.
let rules = vec![NetAllow { host: None, ports: vec![], all_ports: true }];
let resolved = resolve_net_allow(&rules).await.unwrap();
assert!(resolved.any_ip_all_ports);
assert!(resolved.per_ip.is_empty());
assert!(resolved.per_ip_all_ports.is_empty());
assert!(resolved.any_ip_ports.is_empty());
}

#[tokio::test]
async fn test_resolve_net_allow_concrete_host_all_ports() {
// `localhost:*` — every port to localhost only.
let rules = vec![NetAllow {
host: Some("localhost".to_string()),
ports: vec![],
all_ports: true,
}];
let resolved = resolve_net_allow(&rules).await.unwrap();
assert!(!resolved.any_ip_all_ports);
assert!(!resolved.per_ip_all_ports.is_empty(),
"localhost should resolve to at least one IP marked as any-port");
// per_ip has placeholder entries for the same IPs (so callers
// iterating per_ip still see them).
for ip in resolved.per_ip_all_ports.iter() {
assert!(resolved.per_ip.contains_key(ip));
}
// /etc/hosts is synthesized for concrete hosts.
assert!(resolved.etc_hosts.is_some());
}

#[tokio::test]
async fn test_resolve_net_allow_mixed_wildcard_and_concrete() {
// Wildcard rule alongside concrete: wildcard sets the global
// any-host any-port flag; concrete rule still resolves into
// per_ip (the runtime layer chooses Unrestricted, ignoring the
// concrete entries — that's a runtime-policy concern, not a
// resolver concern).
let rules = vec![
NetAllow { host: None, ports: vec![], all_ports: true },
NetAllow {
host: Some("localhost".to_string()),
ports: vec![22],
all_ports: false,
},
];
let resolved = resolve_net_allow(&rules).await.unwrap();
assert!(resolved.any_ip_all_ports);
// Concrete entries still present in per_ip.
assert!(!resolved.per_ip.is_empty());
}
}
Loading
Loading