# Networking: how `--net allow` actually holds Everything in this file was measured on the reference host (Fedora 44, podman 5.8.9, rootless, `pasta` as the rootless network helper, SELinux enforcing) with [`tests/spike_network.sh`](../tests/spike_network.sh). Re-run that script on a new machine before trusting the model; podman's rootless networking is the part of the stack most likely to differ. ## The two mechanisms `--net allow` stacks two independent controls: 1. **A netfilter ruleset inside the container's own network namespace.** The container starts with `--cap-add=NET_ADMIN` and the image's entrypoint runs as root *only long enough* to install an `inet aisb` table with `policy drop` on the output hook, then it execs the real command through ``` setpriv --reuid=1000 --regid=1000 --clear-groups --inh-caps=-all --bounding-set=-all --no-new-privs ``` After that exec the command has no capabilities and no root. Removing a rule needs `CAP_NET_ADMIN`, opening a new network namespace needs `CAP_SYS_ADMIN`, and a setuid helper can't hand either back because `no-new-privileges` is set on the container. The ruleset is therefore a one-way door. 2. **An egress gate on the host loopback.** With `pasta`, a container can be given a magic address that *means* the host's loopback: ``` --network=pasta:--map-host-loopback,100.64.0.1 ``` Connecting to `100.64.0.1:PORT` inside the container lands on the host's `127.0.0.1:PORT`. The gate is a squid container started with `--network=host` (so it has internet and the host's resolver) bound to `127.0.0.1:`, with `http_access deny all` after the allowlist. The sandbox gets `HTTP(S)_PROXY` pointing at `100.64.0.1:`, and the netfilter ruleset allows nothing else out. The gate itself runs as the image's unprivileged `proxy` user (`--user proxy`, `--cap-drop=ALL`, `pid_filename /tmp/...`): squid normally starts as root and drops privileges itself, which needs SETUID/SETGID we would rather not grant. Binding a high loopback port does not need them. squid 6 treats obsolete directives as hard errors, so the generated config avoids `dns_v4_first` and `max_mem_obj_size` and does not redeclare the built-in `manager` ACL. Together: the harness can only talk to the gate, and the gate only relays to domains you allowlisted, resolved by the gate itself. CDNs, SNI and CONNECT-time filtering all work because squid terminates the CONNECT request and resolves the name itself. ## Measurements | Probe | Result | Consequence | |---|---|---| | `--network=` container, egress | blocked (no default route) | a blackout network is available | | `--network=` container, DNS | blocked | internal mode is a real blackout | | container → container on the same user-defined network | **does not work** (`Failed to connect ... Could not connect to server`) | rules out the "sandbox + sidecar proxy on a shared internal network" design | | `--network=host` in a rootless container | works, has internet | makes the host-loopback gate possible | | `--network=pasta` (default), egress | works | pasta is a plain internet tap, no filtering | | container → host `127.0.0.1` via `--map-host-loopback,100.64.0.1` | **works** | the gate address | | `--map-host-loopback,127.0.0.1` | rejected by passt (`Invalid address to remap to host`) | the magic address must not itself be loopback | | `--network=pasta:--map-gw` | works (the gateway address already maps to the host by default) | `--map-host-loopback` chosen for explicitness | | squid as root with `--cap-drop=ALL` | `setgid: (1) Operation not permitted`, gate dies | run the gate as `proxy` from the start | | squid 6 with `dns_v4_first` / `max_mem_obj_size` | `ERROR: Directive ... is obsolete`, config refused | generate only current directives | | sandbox in allow mode, `curl` with the proxy env removed | connection times out | netfilter, not just `HTTP_PROXY`, is doing the gating | | sandbox in allow mode, raw TCP socket to a literal IP, DNS to 8.8.8.8 | both blocked | nothing bypasses the ruleset by ignoring proxy variables | | root in a rootless container, `nft add table` with podman's default caps | **fails** (`Operation not permitted`) | NET_ADMIN is not in podman's default cap set, so it must be added deliberately | | root + `--cap-add=NET_ADMIN`, drop `tcp dport 80` inside the container | egress to port 80 **times out** | in-container netfilter really does gate the pasta path | | `--cap-drop=ALL`, `nft add table` | fails | a non-privileged process cannot install or remove rules | | `--pids-limit 64 --memory 256m --cpus 0.5` | `pids.max=64`, `memory.max=268435456` visible inside | limits are real (note: `/proc/cpuinfo` still lists all cores; use `nproc`/cgroup limits, not cpuinfo) | | `-v $dir:/project` with SELinux enforcing, `--userns=keep-id:uid=1000,gid=1000` | write **denied** | bind mounts need `:z` on SELinux hosts | | same, with `:z` | write succeeds, file owned by the host uid (1000:1000) | project files keep sane ownership | ## Why not the other designs * **Internal network + sidecar proxy container.** The obvious design, and it is what most write-ups suggest. On this host, two containers on the same user-defined network cannot reach each other at all, so the sandbox could never reach its own proxy. Rejected on measurement, not taste. * **`--network=none` plus a unix-socket proxy.** Structurally the strongest (no IP stack at all) and worth revisiting, but only `curl` and a handful of tools speak `--unix-socket`; everything else (git, npm, pip, node's fetch) would need a TCP-to-socket forwarder inside the container, and DNS would need its own path. The current design gets the same practical containment with the two controls above. * **DNS filtering only.** A resolver allowlist is trivially bypassed by connecting to a literal IP, and it breaks the SNI-based allowlist that real agent traffic needs. Used only as the fallback in `--no-gate` mode, where it is paired with IP-pinned netfilter rules. * **`--network=slirp4netns`.** Not installed here; `pasta` is the default helper in podman 5 and is what the code targets (it falls back to whatever helper podman picks, since the code only ever asks for the `pasta` driver). ## Failure modes we chose * If the ruleset cannot be installed (no NET_ADMIN, no `nft`, weird kernel) the container **refuses to start** with exit 77 rather than running unfiltered. `--allow-unenforced` exists for debugging and says so in the flag name. * If the policy file is missing the entrypoint exits 78 before running anything. * The gate container is created per session, bound to loopback only, and removed when the sandboxed command exits. A gate that outlives its session would be an open door with nobody watching it. `gate up` is the explicit, named exception. * In gate mode DNS is **closed** inside the container. That is deliberate: it stops a sandboxed process from resolving names and then talking to them behind the proxy's back. Tools that insist on resolving names themselves need `--allow ` entries instead.