Skip to content

Build the allowlist without connection tracking where there is none - #108

Merged
Amitgb14 merged 4 commits into
mainfrom
feat/conntrack-optional-firewall
Aug 12, 2026
Merged

Amitgb14 merged 4 commits into
mainfrom
feat/conntrack-optional-firewall

Conversation

@Amitgb14

Copy link
Copy Markdown
Owner

Makes the conntrack rules conditional, so the egress allowlist can be
programmed on a kernel that has no connection tracking — which is what makes
--runtime runsc usable with an allowlist at all, and unblocks the conflict in
#88.

Why this is possible

The allowlist does not need connection state in principle, only in the way it
was written. Measured on the gVisor host, OUTPUT at default-DROP, no conntrack
anywhere:

$ iptables-legacy -P OUTPUT DROP
$ iptables-legacy -A OUTPUT -o lo -j ACCEPT
$ iptables-legacy -A OUTPUT -m owner --uid-owner 0 -j ACCEPT
$ curl -s -o /dev/null -w "%{http_code}\n" -m 10 https://registry.npmjs.org
200

A complete request — DNS, handshake, TLS, response — carried by the uid rule
alone. Kernel-generated packets match owner under gVisor, which was the
objection most likely to sink this.

What changes, and what does not

Detected by trying the rule on a scratch chain, not inferred from a runtime
name, so nothing changes on an ordinary host.

Where conntrack is unavailable:

  • Outbound filtering is unchanged. Only the proxy's uid may send anything
    out; the guest's 80/443 is still REDIRECTed into it; the redirected packets
    are loopback-bound and already accepted by the rule above.
  • Inbound filtering is not applied. A stateless chain cannot tell a reply
    from an unsolicited connection. Denying both breaks every allowed request;
    admitting both is what a default-ACCEPT INPUT already does. The run prints
    this rather than leaving it to be found.
  • IPv6's accept-replies rules go the same way and lose nothing: IPv6 is rejected
    in both directions regardless, so nothing could ever have been established.

The part that is still an argument

Why losing inbound filtering is a limitation and not a hole: nothing inside the
container can answer an unsolicited connection, because the answer would leave
from a uid OUTPUT denies, so the handshake never completes. That is
reasoning, not a measurement.
docs/roadmap/task-3-gvisor-egress.md (#107)
says which parts are which, and settling it needs two containers on one network.

Testing

Not run end to end by me — no daemon here. On the gVisor host:

git fetch origin && git checkout feat/conntrack-optional-firewall
make build
./bin/sandbox-cli run --allow registry.npmjs.org --runtime runsc -- \
  curl -s -o /dev/null -w '%{http_code}\n' https://registry.npmjs.org

Expect the backend line, the no-conntrack notice, and 200. Then the same
without --runtime runsc to confirm an ordinary runc run is untouched — no
notice, and the INPUT chain still default-deny.

gVisor provides no connection tracking — neither `-m conntrack` nor the older
`-m state` — so the allowlist could not be programmed there and a run asking for
both a stronger runtime and an allowlist refused. That made prod and gVisor
mutually exclusive.

It turns out the allowlist does not need connection state in principle, only in
the way it was written. Measured on Rocky Linux 10.2 with gVisor: with OUTPUT at
default-DROP and no conntrack anywhere, a single `-m owner --uid-owner` rule
carried a complete HTTPS request — DNS, handshake, TLS, response. Kernel-
generated packets match owner there, which was the objection most likely to sink
this.

So the conntrack rules are now conditional, detected by trying them on a scratch
chain rather than inferred from the runtime name. On an ordinary host nothing
changes.

Where they are unavailable, outbound filtering is unchanged: the proxy's uid is
what admits its traffic, the guest's 80/443 is still redirected into it, and the
redirected packets are loopback-bound and already accepted. What is lost is
INBOUND filtering, because a stateless chain cannot distinguish a reply from an
unsolicited connection — denying both breaks every allowed request, and
admitting both is what a default-ACCEPT INPUT does anyway. The run says so
plainly rather than leaving it to be discovered.

The containment argument for that, which is why this is a limitation rather than
a hole: nothing inside the container can answer an inbound connection, because
the answer would leave from a uid the OUTPUT chain denies, so the handshake
never completes. That argument is reasoning, not yet a measurement, and
docs/roadmap/task-3-gvisor-egress.md says which parts are which.

The IPv6 accept-replies rules go the same way and lose nothing: IPv6 is rejected
in both directions regardless, so nothing could ever have been established.
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sandbox-cli Ready Ready Preview Aug 10, 2026 9:59am

The first version probed conntrack and shipped. The next run on the gVisor host
got past it and hit the wall behind it:

    Warning: Extension limit revision 0 not supported, missing kernel module?
    Warning: Extension LOG revision 0 not supported, missing kernel module?
    iptables: Invalid argument.
    sandbox-cli: egress firewall setup failed; refusing to run without the requested allowlist

which is the mistake this project already names: characterise the surface once
rather than fix one error at a time. The earlier probe answered conntrack,
owner, nat and REDIRECT, and never asked about `-m limit`, `-j LOG` or REJECT.

So the probe is now general — rule_ok tries any rule on a scratch chain — and
three things are asked before the first real rule is written:

- conntrack, as before;
- `-m limit` + `-j LOG`, which is the rate-limited record of what was refused.
  Diagnostics rather than enforcement: without it the rules are identical and
  only the explanation is missing, so it degrades. Worth knowing that denial
  counts go with it;
- REJECT, which answers a blocked client with an ICMP error so it fails at once
  instead of hanging to its timeout. Where it is missing, DROP denies exactly as
  much — the difference is the courtesy, not the boundary.

Each degradation is announced when it applies, so a run on such a kernel says
what it is not doing rather than leaving it to be found. Nothing changes on an
ordinary host, where all three are present.
The feature probes ran against $IPTABLES and their answers were then applied to
the ip6tables chains. The two families are resolved independently — a host can
serve one from legacy and the other from nft — so a capability measured on v4 is
not evidence about v6.

It is the required branch that makes this bite. Where the container has a global
IPv6 address the rules carry no `|| true`, deliberately, because IPv6 left
unfiltered while sandbox-cli reports an allowlist is the one failure this block
exists to prevent. So a v4 REJECT probe standing in for v6 on a backend without
the target aborts the whole setup under set -e, and the run refuses over a
target nobody asked about.

probe6 asks $IP6TABLES itself, after it is resolved, and sets DENY6/CONNTRACK6
from the answer. The no-global-address branch is gated the same way: everything
there is `|| true` so an ungated conntrack rule would only fail quietly, but two
branches writing one chain from different premises is how one of them ends up
wrong later.
…ublish loses

Two things the stateless path got wrong.

`[ "$CONNTRACK6" = 1 ] && rule || true` was used inside the *required* IPv6
branch, where every other rule deliberately has no `|| true` because IPv6 left
unfiltered under a claimed allowlist is the failure that block exists to
prevent. The `|| true` is there to survive a false condition, but it swallows a
failed rule just as happily. if/fi tolerates only the case meant to be
tolerated.

And the INPUT fallback opened the chain without flushing it, and without
mentioning what changed. --publish is the casualty: the published service runs
as the sandbox user, OUTPUT admits only the proxy's uid, and with no
ESTABLISHED,RELATED rule there is nothing under which its replies may leave. The
port is reachable and the answer never arrives. That is the containment argument
working exactly as written — but the carve-out above exists precisely so a dev
server does not silently stop answering when someone adds --allow, and a
stateless kernel brings that back by another route. Warned when ports were
actually published, since otherwise there is nothing to lose.
@Amitgb14
Amitgb14 merged commit cd70cf5 into main Aug 12, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant