Skip to content

network.host: allow-list is not enforced for raw TCP (non-proxy-path) traffic — host services reachable regardless of config #461

Description

@dpup

Summary

network.host is enforced in exactly one place: the proxy daemon's request path (checkNetworkPolicyForRequestisAllowedHostPort in gatekeeper). That check only runs for traffic that actually traverses the proxy — HTTP requests and CONNECT tunnels. Raw TCP connections (socket.create_connection, and most non-HTTP clients: database drivers, VNC, rtl_tcp, anything not honoring HTTP_PROXY) never touch the proxy, so the port allow-list does not apply to them.

Measured on Docker Linux (this sandbox), with network: host: [1234] in moat.yaml and a listener running on forbidden host port 5555:

Mode Raw TCP to forbidden host port 5555 Via proxy CONNECT
permissive, host-net (Docker Linux default when no ports published) CONNECTED correctly 407 policy-blocked
strict, host-net, iptables rules confirmed active in-container CONNECTED blocked (but see below)
permissive, bridge (published ports; also Docker Desktop/Apple path) CONNECTED blocked
strict, bridge dropped — but the allowed port is dropped too blocked

Details per mode:

  • Host-net mode (Docker Linux, no published ports — the default for grant-less network.host runs): the container shares the host loopback, so 127.0.0.1:<any port> is directly reachable. Strict policy does install in-container iptables OUTPUT rules, but the first rule is -o lo -j ACCEPT (internal/container/docker.go, SetupFirewall), which whitelists the entire host loopback and defeats the port allow-list. Verified with rules active: forbidden 5555 reachable, while the same ruleset correctly dropped raw TCP to an external IP (140.82.112.3:443 timed out) — i.e. the firewall works, but its loopback allow swallows exactly the traffic network.host is supposed to scope.
  • Bridge + permissive (the Docker Desktop / macOS default the largest group of users hit): no iptables rules are installed at all (needsProxyForFirewall is true only for network.policy: strictinternal/run/manager_create.go:360), so raw TCP reaches any host port the host permits via the gateway/host.docker.internal.
  • Bridge + strict: raw egress is dropped, but indistinguishably from the allowed port — the in-container OUTPUT rules allow only the proxy port and lo; there is no per-host-port allow rule. So strict bridge blocks network.host entirely rather than scoping it.

The documented claim

docs/content/reference/02-moat-yaml.md (~line 921) says:

By default, containers cannot reach any service running on the host machine — even in permissive mode. network.policy controls outbound internet access; network.host controls access to the host independently. You must list each port explicitly.

That is true only for proxy-aware HTTP clients. The design comment in internal/run/manager_network.go:52 ("moat-host … NOT in NO_PROXY, so it flows through the proxy for network policy enforcement") carries the same assumption — an HTTP-client assumption baked into a network-boundary guarantee.

Relation to prior issues

What to think through

This is a tracking issue for a design pass, not a bug report with an obvious patch — the interesting question is what the enforcement model for local host services should be, given:

  1. Host-net mode makes iptables-based scoping structurally hard. The container's loopback is the host's loopback; you cannot write an OUTPUT rule that distinguishes "host services the run is allowed to reach" from the proxy/loopback traffic the platform itself needs (-o lo -j ACCEPT exists because the proxy path needs it). Options that come to mind: stop using host-net for network.host runs (bridge + published ports already work); per-run host-side listeners (like the serial RFC2217 broker — a moat-owned socket per run is the one place the allow-list can be real); or rejecting network.host on host-net runtimes.
  2. Permissive installs no firewall at all. If network.host is meant to be a security boundary even in permissive mode (as the doc claims), the firewall needs to be installed whenever network.host is non-empty, not only under strict — and the rules need host-gateway-IP + dport allow entries, which the current script doesn't have.
  3. Strict + bridge currently breaks network.host rather than scoping it (allowed port dropped along with everything else). Whatever comes out of the design should make strict more precise, not less usable.
  4. The proxy-path check is good and should stay — CONNECT to a forbidden host port correctly returns the actionable policy-block 407 ("Host service access to moat-host:5555 is not allowed by default … add to moat.yaml"). The gap is only non-proxy traffic.

Concrete surface found while measuring (for whoever picks this up): checkNetworkPolicyForRequest in gatekeeper's proxy.go:1332; SetupFirewall scripts in internal/container/docker.go:787 and internal/container/apple.go:575 (lo-allow with no host-port entries, and no rule keyed to the host-gateway IP); needsProxyForFirewall gating on network.policy == "strict" only (internal/run/manager_create.go:360); resolveNetworkConfig choosing host-net whenever there are no published ports (internal/run/manager_network.go:22).

Repro

# host: listener on a port NOT in network.host
python3 -c 'import socket,threading
s=socket.socket(); s.bind(("0.0.0.0",5555)); s.listen(5)
while True:
    c,_=s.accept(); c.sendall(b"HELLO"); c.close()' &

# moat.yaml: network: host: [1234]
moat run -- python3 -c 'import socket
s=socket.create_connection(("moat-host",5555),timeout=5)
print("connected:", s.recv(16))'
# Expected per docs: blocked (5555 not in host allow-list)
# Actual (permissive or strict, host-net): connected: b'HELLO'

Surfaced while validating the examples/serial-sdr example (RTL-SDR via host-side rtl_tcp) on feat/serial-devices — the example's spectrum sweep is a raw-TCP rtl_tcp client, i.e. exactly the non-proxy-path traffic class.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions