Safeguard
Container Security

An Egress Allowlist You Can Enforce, Not Just Record

A proxy the workload can decline to use is a log, not a control. Why environment-variable proxies and host firewall rules both fail for untrusted code, and the internal-network plus gateway-container shape that does not.

Tomas Lindgren
Platform Engineer
6 min read

An allowlist that logs what a workload connected to is not an allowlist. It is a record of things that already happened.

This distinction gets lost because both are configured the same way, both produce a list of allowed destinations, and both look correct in a code review. Only one of them stops a connection. If you run untrusted code, whether that is customer submissions, a build step, or an AI agent with tool access, this is the difference between a control and a report.

This post is how to build the enforcing version, what does not work, and why the obvious approaches fail. For platform engineers running untrusted workloads in containers.

Why the obvious approaches do not work

An HTTP proxy with an allowlist. Set HTTP_PROXY in the environment, point it at something that filters by hostname, done.

Except the workload chooses whether to use it. Environment variables are honoured by well-behaved HTTP clients and by nothing else. A raw socket, a DNS query, a language runtime that ignores proxy variables, or code that simply unsets them all bypass it completely. As a control against untrusted code it is worth close to nothing, because it depends on the untrusted code cooperating.

It is still useful. It gives you hostname-level policy and readable logs for code that is merely careless rather than hostile. Just do not count it as containment.

Firewall rules on the host. Now you are filtering all traffic including your own, the rules are global rather than per workload, and the rule set has to be mutated every time a job starts with a different allowlist. Concurrent jobs with different policies cannot be expressed at all. It breaks the moment two things run at once.

Network policies in Kubernetes. These work, and they are the right answer if you are on Kubernetes. They are enforced at the CNI layer, per pod, by selector. If that is your platform, use them and stop reading.

The rest of this is for the case where you are not: containers on a single host, per job, with a policy that differs per job.

The shape that works

Two containers and a network they share.

  • An internal network with no route out. In Docker terms, a user-defined bridge created with --internal. Containers on it can talk to each other and nothing else. This is the part that makes the control real: there is no path to the internet to bypass, because the interface does not have one.
  • A gateway container attached to both that internal network and a normal one. It holds NET_ADMIN, runs the filtering rules, and forwards only what the policy permits.
  • The workload attached only to the internal network, with the gateway as its default route.

The workload cannot opt out. Its only path to anything is through the gateway, and that is a property of the network topology rather than of its configuration. Unset every environment variable you like: the packets still have exactly one way to go.

docker network create --internal sandbox-net
docker network create sandbox-egress

docker run -d --name gw --cap-add NET_ADMIN \
  --network sandbox-net --sysctl net.ipv4.ip_forward=1 gateway:latest
docker network connect sandbox-egress gw

docker run --rm --network sandbox-net \
  --dns 10.x.x.x \                      # the gateway, not a public resolver
  --sysctl net.ipv4.conf.all.route_localnet=0 \
  workload:untrusted

The gateway then permits the allowlist and rejects the rest. Reject rather than drop: a rejected connection fails immediately with a clear error, while a dropped one hangs until timeout, and the difference between those two is an hour of somebody debugging a build that appears to be slow.

The four details that break it

DNS is egress. If the workload can reach a public resolver, it has a channel, and DNS tunnelling is a mature technique rather than a theoretical one. Point it at a resolver inside the gateway that answers only for allowlisted names.

Allowlisting by hostname means resolving it. Firewall rules match addresses. A name resolved once at start time goes stale, and any large service sits behind addresses that rotate. Either re-resolve on a schedule and update the rule set, or terminate at a filtering proxy inside the gateway so the decision happens at the name. The second is more work and it is the one that holds.

Link-local and metadata addresses. 169.254.169.254 is the cloud metadata endpoint and reaching it can return credentials. Block the whole link-local range explicitly. It is not covered by "deny the internet" because it is not the internet.

IPv6 exists. A rule set that covers IPv4 only, on a host with IPv6 enabled, is a rule set with a second unfiltered path through it. Configure both families or disable IPv6 on the workload.

Verify it by trying to break out

A policy is not in place because you wrote it. Test from inside the workload, and test the paths that bypass your assumptions rather than the one you designed:

curl -sS --max-time 5 https://allowed.example.com   # expect success
curl -sS --max-time 5 https://example.org           # expect immediate refusal
getent hosts example.org                            # expect no answer
curl -sS --max-time 5 http://169.254.169.254/       # expect refusal
curl -sS --max-time 5 -6 https://example.org        # expect refusal
nc -w 5 -z 1.1.1.1 53                               # raw socket, expect refusal
env -u HTTP_PROXY -u HTTPS_PROXY \
  curl -sS --max-time 5 https://example.org         # expect refusal

That last one is the test that distinguishes a real control from a cooperative one. If it succeeds, you have a proxy, not an allowlist, whatever the configuration file is called.

The concession

This is more infrastructure than a proxy, and for many workloads it is more than the risk justifies. Code you wrote, running your build, is not untrusted in the sense that matters here, and a gateway container per job has a real cost in startup time and complexity.

The line worth drawing: if the code was written by someone outside your organisation, or generated, or supplied by a user, or is an agent choosing its own actions, the cooperative version is not a control. If you wrote it and you are defending against mistakes rather than intent, a proxy is a reasonable tool and the honest thing is to call it what it is.

The implication

The useful question about any egress control is not what it permits. It is what happens when the workload declines to participate.

Ask it of whatever you have now. If the answer is that the workload could simply not use the proxy, you have a log, and logs are valuable. Just do not write it down as containment on the architecture diagram, because somebody will later make a decision based on that word.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.