Skip to content

fix(sandbox/linux): allow open_port=0 to disable Landlock TCP in Prox…#1053

Open
jbndbc wants to merge 3 commits into
always-further:mainfrom
jbndbc:fix/open-port-zero-proxy-mode-linux
Open

fix(sandbox/linux): allow open_port=0 to disable Landlock TCP in Prox…#1053
jbndbc wants to merge 3 commits into
always-further:mainfrom
jbndbc:fix/open-port-zero-proxy-mode-linux

Conversation

@jbndbc

@jbndbc jbndbc commented Jun 1, 2026

Copy link
Copy Markdown

Allow open_port = [0] in ProxyOnly mode on Linux

Closes #611


What this fixes

When domain filtering is active (--allow-domain / --proxy), nono enters ProxyOnly mode.
Landlock restricts outbound TCP to the proxy port only. This breaks Testcontainers and Maven
Surefire, both of which bind to dynamically assigned ports on localhost that cannot be known in
advance:

  • Testcontainers maps Docker container ports to random ephemeral ports on 127.0.0.1
  • Maven Surefire binds to 127.0.0.1:0 (OS-assigned ephemeral port) for inter-process
    coordination

Before this change, users had to disable domain filtering entirely to run their test suite,
losing the protection they wanted.


Approach

On macOS, open_port = [0] already works: Seatbelt generates
(allow network-outbound (remote tcp "localhost:*")), a genuine IP-scoped wildcard that permits
any localhost port while external hosts remain restricted. Linux cannot do the same.

Why Linux requires a different mechanism

Landlock 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-address filter. Port 0 has
access-specific semantics but is not a general wildcard:

  • BindTcp + port 0: meaningful — matches bind(port=0) calls, triggering OS ephemeral
    port assignment from /proc/sys/net/ipv4/ip_local_port_range. Covers Maven Surefire's bind
    side only.
  • ConnectTcp + port 0: matches connect() to destination port 0 only. A Testcontainers
    connect(localhost:32768) has no matching rule and is denied EACCES.

There is no way to express "allow 127.0.0.1:* but deny *.*.*.*:*" in Landlock — rules carry
no IP-address component.

The correct mechanism: when open_port = [0] is set in ProxyOnly mode, skip
handle_access(AccessNet) on the Landlock ruleset entirely
. A ruleset with no AccessNet
handling imposes no kernel-level TCP restrictions. The proxy — already running, already injecting
HTTPS_PROXY / HTTP_PROXY / ALL_PROXY — becomes the sole network enforcer.

This also applies to the seccomp fallback path (kernels without Landlock v4+): the seccomp TCP
filter is similarly skipped.

Security scope of this change

Skipping handle_access(AccessNet) removes kernel-level TCP enforcement entirely, not just for
localhost. A process can make direct raw TCP connections to remote hosts on any port,
bypassing the proxy. This affects non-HTTP TCP clients such as psql, redis-cli, nc, or
custom socket code with a hardcoded IP — tools that do not read HTTPS_PROXY.

This is a deliberate and disclosed tradeoff: ProxyOnly mode is designed to constrain HTTP/HTTPS
requests
to unauthorized domains, which standard HTTP libraries route through the proxy
automatically. The remaining sandbox layers (Landlock filesystem restrictions, command blocklists,
process isolation) continue to apply.

A complete fix for raw TCP bypass would require either:

  • Network namespace isolation: confine the sandbox to a namespace with only loopback and a
    veth to the proxy, making external IPs unreachable by construction
  • cgroup BPF (BPF_PROG_TYPE_CGROUP_SOCK_ADDR): a BPF program that permits 127.0.0.1:*
    and denies all other destinations

Both require capabilities outside nono's current architecture. This change is a pragmatic
improvement for the common case, with the limitation fully documented in code and in this PR.

open_port = [0] in Blocked mode (no proxy) continues to be rejected with an error directing
users to --allow-domain.


Files changed

File Change
crates/nono/src/sandbox/linux.rs Port 0 rejection narrowed to Blocked mode only; localhost_wildcard_proxy flag added; needs_network_handling skips when flag is set; network_handled_by_landlock gates the NetPort rule block (prevents adding port rules to a ruleset not configured for AccessNet); warn!() emitted at runtime
crates/nono/src/capability.rs allow_localhost_port doc updated to explain port 0 semantics per platform and the Linux scope difference

Tests

  • test_reject_localhost_port_wildcard_zero_in_blocked_mode — renamed and updated; verifies
    port 0 in block_network mode is still rejected with a message directing to ProxyOnly mode
  • test_accept_localhost_port_wildcard_zero_in_proxy_only_mode — new; verifies apply_with_abi
    succeeds with proxy_only(8080) + localhost_port(0) on Landlock v4+ kernels; skips
    gracefully on older kernels

Contributor notes

This PR was prepared by Claude (Anthropic), an AI coding assistant, at the request of a
maintainer. The implementation went through two iterations: the first attempt incorrectly assumed
NetPort::new(0, ...) was a Landlock wildcard (it is not); the second iteration skips network
handling entirely, which is the correct mechanism. The scope of the TCP relaxation — not just
localhost but all TCP — was identified and disclosed during implementation.

Files and sections consulted:

  • crates/nono/src/sandbox/linux.rsapply_with_abi, SeccompNetFallback,
    seccomp_network_fallback_mode, existing port 0 rejection guard
  • crates/nono/src/sandbox/macos.rspush_localhost_tcp_outbound_seatbelt_rules,
    port 0 → localhost:* wildcard handling
  • crates/nono/src/capability.rsNetworkMode, CapabilitySet, allow_localhost_port,
    localhost_ports field docs
  • crates/nono-cli/src/capability_ext.rs — port flow from CLI/profile into CapabilitySet
  • Linux kernel security/landlock/net.c — confirmed exact-port red-black tree matching;
    no wildcard rule exists; port 0 semantics per access type

Agent Compliance Check

  • I am not prohibited from contributing under this policy
  • An issue already exists (Allow domain filtering and unknown ports on localhost #611)
  • I reviewed repository coding and security rules for the affected area
  • I provided required attribution for reused or adapted code (none reused; all new logic)
  • I did not use forbidden patterns such as unwrap/expect
  • I used NonoError where required
  • I validated and canonicalized all relevant paths (no path handling in this change)
  • This PR matches the approved or disclosed issue scope
  • I described my intent and approach in the issue discussion before implementing

The final item is unchecked: implementation preceded issue discussion because this was a
maintainer-directed change. The security tradeoff (raw TCP beyond localhost) was identified during
implementation and is disclosed here rather than in the issue thread.

…yOnly mode

  Testcontainers maps Docker containers to random ephemeral ports on localhost.
  Maven Surefire binds to 127.0.0.1:0 for inter-process coordination. Neither
  can be expressed as explicit port rules in nono's ProxyOnly configuration.

  Landlock network rules match on exact port number with no wildcard and no
  IP filter. Port 0 is not a catch-all: ConnectTcp/0 matches connects to
  destination port 0 only; BindTcp/0 matches bind(port=0) calls only.
  There is no Landlock primitive for 'allow 127.0.0.1:* only'.

  When open_port = [0] is set in ProxyOnly mode, skip handle_access(AccessNet)
  on the Landlock ruleset entirely. Without AccessNet handling the kernel
  imposes no TCP restrictions; the proxy becomes the sole domain enforcer
  via HTTPS_PROXY / HTTP_PROXY / ALL_PROXY. The same applies to the seccomp
  fallback path on kernels without Landlock v4+.

  Disclosed tradeoff: this removes kernel-level TCP enforcement entirely, not
  just for localhost. Non-HTTP TCP clients (psql, redis-cli, nc, raw sockets)
  can reach remote hosts directly. ProxyOnly mode protects HTTP/HTTPS traffic
  via standard library proxy support; raw TCP bypass requires hardcoded IPs.
  Proper localhost-only restriction would require network namespaces or cgroup BPF.

  open_port = [0] in Blocked mode (no proxy) is still rejected with an error.

  Closes always-further#611

Signed-off-by: Jan Bauer Nielsen <jbn@dbc.dk>
@github-actions github-actions Bot added bug Something isn't working nono size/medium labels Jun 1, 2026
@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

PR Review Summary

Size

Metric Value
Lines added +111
Lines removed -16
Total changed 127
Classification Medium (50–300 lines)

Affected crates

  • crates/nono (core library) — careful review required. This is the security-critical sandbox primitive. A bug here bypasses OS-level isolation for every downstream user.

Blast radius — Contained

This PR touches: source code


Updated automatically on each push to this PR.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the Linux sandbox implementation to support port 0 (localhost TCP wildcard) in ProxyOnly mode by skipping Landlock network enforcement and relying solely on the proxy. In Blocked mode, port 0 remains rejected. Feedback points out a critical issue in the new unit test: calling apply_with_abi directly without forking will sandbox the test runner process itself, which is irreversible and will cause subsequent tests to fail. A suggestion is provided to run the test in a forked child process.

Comment thread crates/nono/src/sandbox/linux.rs
jbndbc and others added 2 commits June 2, 2026 07:21
`test_accept_localhost_port_wildcard_zero_in_proxy_only_mode` called
`apply_with_abi()` directly, which invokes `restrict_self()` and
permanently sandboxes the test-runner process. Tests running later in
the same process could fail spuriously due to the unexpected Landlock
restrictions.

Fix: wrap the call in a `fork()`/`waitpid()` child, matching the pattern
used in `probe_abstract_socket_with_landlock` (line 2630).

Signed-off-by: Jan Bauer Nielsen <jbn@dbc.dk>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Collapse single-argument assert! calls to one line as rustfmt requires.

Signed-off-by: Jan Bauer Nielsen <jbn@dbc.dk>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@lukehinds

Copy link
Copy Markdown
Contributor

Sorry for the late engagement @jbndbc - this is something we are aware of an intend to resolve, but we are thinking of leveraging seccomp-notify and in fact making the fallback the main approach, this saves having the brittle Landlock ABI selection gate and more importantly it can express the policy Landlock fundamentally can't and is limiting the use pattern you have with maven etc

The tradeoff as you outlined (raw TCP to any host bypassing the proxy) is the part we want avoid. taking a seccomp-notify / supervisor path avoids that tradeoff and the bonus is the supervisor infrastructure mostly exists already: the ProxyOnly fallback routes connect()/bind() through SECCOMP_RET_USER_NOTIF and the supervisor reads the full sockaddr, so unlike Landlock it sees the destination address, not just the port. That lets us set a precise policy this issue needs:

destination is loopback → allow any port (Testcontainers, Surefire);

otherwise → proxy port only.

the kernel-level enforcement stays on, so no raw-TCP window. We already plan to set that path the default instead of an ABI-gated fallback - as it gives us one enforcement path to test across all kernels. @SequeI is covering this work as part of the network refactor, and we have an issue: #1101 - we will put heads together and make sure this is covered.

if you need a make shift for now, maybe iptables could cover, but either apologies for the brittle current implementation and we will have a solution for you in the not to distance future.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working nono size/medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow domain filtering and unknown ports on localhost

2 participants