Summary
network.host is enforced in exactly one place: the proxy daemon's request path (checkNetworkPolicyForRequest → isAllowedHostPort 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: strict — internal/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:
- 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.
- 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.
- 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.
- 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.
Summary
network.hostis enforced in exactly one place: the proxy daemon's request path (checkNetworkPolicyForRequest→isAllowedHostPortin 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 honoringHTTP_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:Details per mode:
network.hostruns): the container shares the host loopback, so127.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 trafficnetwork.hostis supposed to scope.needsProxyForFirewallis true only fornetwork.policy: strict—internal/run/manager_create.go:360), so raw TCP reaches any host port the host permits via the gateway/host.docker.internal.lo; there is no per-host-port allow rule. So strict bridge blocksnetwork.hostentirely rather than scoping it.The documented claim
docs/content/reference/02-moat-yaml.md(~line 921) says: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
curl http://localhost:5432bypass the proxy under host networking). This issue is the raw-TCP half: even with perfect NO_PROXY hygiene, non-HTTP clients were never going to be caught by a proxy-path check.docker execafter start, and fast containers can complete outbound requests before rules apply.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:
-o lo -j ACCEPTexists because the proxy path needs it). Options that come to mind: stop using host-net fornetwork.hostruns (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 rejectingnetwork.hoston host-net runtimes.network.hostis meant to be a security boundary even in permissive mode (as the doc claims), the firewall needs to be installed whenevernetwork.hostis non-empty, not only understrict— and the rules need host-gateway-IP + dport allow entries, which the current script doesn't have.network.hostrather than scoping it (allowed port dropped along with everything else). Whatever comes out of the design should make strict more precise, not less usable.Concrete surface found while measuring (for whoever picks this up):
checkNetworkPolicyForRequestin gatekeeper's proxy.go:1332;SetupFirewallscripts ininternal/container/docker.go:787andinternal/container/apple.go:575(lo-allow with no host-port entries, and no rule keyed to the host-gateway IP);needsProxyForFirewallgating onnetwork.policy == "strict"only (internal/run/manager_create.go:360);resolveNetworkConfigchoosing host-net whenever there are no published ports (internal/run/manager_network.go:22).Repro
Surfaced while validating the
examples/serial-sdrexample (RTL-SDR via host-side rtl_tcp) onfeat/serial-devices— the example's spectrum sweep is a raw-TCP rtl_tcp client, i.e. exactly the non-proxy-path traffic class.