Skip to main content
intermediatePart 7

Firewall an agent container so it can reach one API and nothing else

· 21 min read
Rafael Fernandes
NLP Engineer & Tech Writer at WiLine
Share:
+

Agent orchestration part 4 split a toolbox so the scheduler could not issue refunds. Part 5 gave the agent an identity at the gateway. Part 6 put a JWT verifier in front of the tools server so it refuses anonymous callers.

Every one of those controls what the agent may call. Not one of them controls where the agent may go.

That distinction is the whole of this post. Exfiltration does not need a tool. It needs a socket.

Reproducibility

One WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Ubuntu 22.04, kernel 5.15. Docker 29.1.3 with firewall backend iptables — which on this distribution is iptables-nft v1.8.7, a shim writing into nftables. Sandbox container is alpine:3.20. UFW is active.

Every status code, error string and timing in this post is from the run described. Plain HTTP on a private LAN where the LAN appears, as throughout this series.

Two directions, and only one is usually guarded​

A firewall decides which packets may cross a boundary. Most of the time people mean it in one direction — ingress, who out there may reach in to your service. That is what publishing a port does, and what part 1 of this series was about.

Egress is the other direction: where your service may reach out to. Which addresses it may open a connection to, and which it may not.

Hardly anyone configures egress, and for an ordinary web application that is a reasonable choice. It talks to its database and a payment provider, it does what its code says, and its code does not change between deployments.

An agent is not that. An agent decides what to do at runtime, from text it has just read — an email, a support ticket, a web page, a document somebody else wrote. If an attacker controls that text, they influence what the agent does next. And the thing you do not want it doing next is exfiltration: sending your data somewhere it should not go.

Exfiltration needs no special tool. Any process that can open a socket can send anything it can read. Which is why the direction nobody guards is the one that matters here.

What the standards actually say​

The leading reference for this class of problem is OWASP's LLM06:2025 Excessive Agency. Its prevention section lists, in order: minimize extensions, minimize extension functionality, avoid open-ended extensions, and further items about permissions and human approval.

Read the page looking for the network and it is not there. Not "egress", not "firewall", not "network", not "isolation", not "sandbox" — the words do not appear in the entry at all.

The OWASP LLM06 Excessive Agency page showing its prevention and mitigation strategies, all concerning tools and extensions

Every mitigation on that list operates on tools. Remove the extension, narrow the extension, avoid the open-ended extension. All good advice. None of it changes a single byte of what a compromised process can do with a TCP socket.

There is an argument that this is a gap rather than an oversight, and it is being made inside OWASP's own repository. An open issue proposing a runtime-enforcement mapping for the Agentic Top 10 states it plainly:

The enforcement pipeline assumes agents cannot reach tools without passing through the proxy. If tools are directly reachable, runtime enforcement collapses.

and concludes that "Network-level controls (e.g., Kubernetes NetworkPolicy, Docker network isolation) are required to close this gap in production."

Note what that is: an open issue, filed by a contributor, with its mapping marked (Proposed). It is not the standard. It is someone arguing the standard should say this.

And the reason this matters is not theoretical. EchoLeak (CVE-2025-32711) is a zero-click indirect prompt injection against Microsoft 365 Copilot, recorded in NVD as "Ai command injection in M365 Copilot allows an unauthorized attacker to disclose information over a network." Over a network. The tool list was never the thing that failed.

Prerequisites​

  • Docker on Linux with the iptables firewall backend — the default. Check with docker info | grep -i "Firewall Backend"
  • sudo, and a willingness to write firewall rules on a box you can still reach
  • Nothing from the earlier parts. This one stands alone.
Read this before you touch DOCKER-USER

DOCKER-USER is real firewall state on your host. A rule that matches more than you intended will take services offline. Every rule in this post is scoped with -i br-agent, an interface that belongs to one purpose-built network — which is exactly why we name that bridge by hand in step 1 instead of letting Docker generate one.

If you are working over SSH, none of these rules touch the INPUT chain, so your session is not at risk. Confirm that for yourself before you believe it.

Step 1 — Prove the gap​

A network, with the bridge interface named explicitly. Docker documents the option as "Interface name to use when creating the Linux bridge":

docker network create --driver bridge \
-o com.docker.network.bridge.name=br-agent agent-egress
ip -br link show br-agent
br-agent DOWN 8a:77:65:54:9b:0d <NO-CARRIER,BROADCAST,MULTICAST,UP>

DOWN, because nothing is plugged into it yet. A Docker network is an interface that does not come up until a container attaches, and a rule matching -i br-agent on an idle network silently matches nothing. Worth knowing before you spend an hour debugging a policy that was never exercised.

The sandbox — pinned, not latest:

docker run -d --name agent-sandbox --network agent-egress alpine:3.20 sleep infinity
docker exec agent-sandbox apk add --no-cache curl bind-tools

Now four destinations. Note what is not in this container: no agent, no framework, no MCP client, no tools. Tool scoping is not being bypassed here — there is nothing to bypass.

docker exec agent-sandbox curl -sS -m 8 -o /dev/null -w 'inference API HTTP %{http_code}\n' https://inference.wiline.com/v1/models
docker exec agent-sandbox curl -sS -m 8 -o /dev/null -w 'arbitrary host HTTP %{http_code}\n' https://example.com
docker exec agent-sandbox curl -sS -m 8 -o /dev/null -w 'authentik LAN HTTP %{http_code}\n' http://10.80.4.212:9100/
docker exec agent-sandbox curl -sS -m 8 -o /dev/null -w 'other network exit=%{exitcode} %{errormsg}\n' http://172.21.0.2:5432/
inference API HTTP 401
arbitrary host HTTP 200
authentik LAN HTTP 302
other network exit=28 Connection timed out after 5002 milliseconds

Four probes from the sandbox container: the inference API answering 401, example.com answering 200, authentik answering 302, and a cross-network connection timing out

The 401 is the useful one to understand. No API key was sent, so the request never reaches anything that could charge for it — but a 401 is an HTTP response, which means the TCP handshake completed, TLS negotiated, and a real server answered. Reachability, proven, for free.

The fourth line is the one that keeps this honest. That is a Postgres container on a different Docker network, and it times out — part 1 of this series established that separate bridge networks genuinely isolate, and this confirms it.

So the shape of the hole is precise rather than vague:

DestinationResult
Another Docker networktimeoutDocker already handles this
Your own LAN, including the identity provider302open
The open internet200open
The one API it actually needs401open

Docker isolates containers from each other and stops there. The two directions that matter for exfiltration — your network, and the internet — are wide open, and no Docker guide frames that as a gap because, for a web application, it isn't one.

Step 2 — The floor​

One rule. Note -A rather than -I: the deny goes at the bottom of the chain and stays there, and every allow added later stacks above it. That ordering is the policy.

sudo iptables -A DOCKER-USER -i br-agent -j DROP

DOCKER-USER is where this belongs, and Docker's documentation is explicit about why:

Rules appended to the FORWARD chain will be processed after Docker's rules.

DOCKER-USER is processed before them — the docs describe it as "a placeholder for user-defined rules that will be processed before rules in the DOCKER-FORWARD and DOCKER chains." Anywhere else and Docker's own ACCEPT gets there first.

Read that left to right and the placement explains itself. DOCKER-FORWARD holds one unconditional ACCEPT per bridge — that is what makes container egress work at all, and it is why a rule appended to FORWARD never fires. DOCKER-USER is the only hook that sits upstream of it.

Re-run the same four probes:

inference API exit=28 Resolving timed out after 8000 milliseconds
arbitrary host exit=28 Resolving timed out after 8002 milliseconds
authentik LAN exit=28 Connection timed out after 8003 milliseconds
other network exit=28 Connection timed out after 8002 milliseconds

The single DROP rule listed, and all four probes failing with two distinct error messages

Read the error text, not the failure. Two different messages, and the difference is free diagnostic information:

  • "Resolving timed out" — DNS itself was blocked. We never learned an address.
  • "Connection timed out" — the address was already known, the packet left, the drop caught it.

The two by-name probes died at resolution; the two by-IP probes died at the socket. Which tells us something not obvious: Docker's embedded resolver forwards its upstream queries across DOCKER-USER. The resolver lives at 127.0.0.11 inside the container's namespace, and it is tempting to assume that traffic never touches the host's forward path. It does.

Also note that every failure took the full eight seconds. A DROP produces timeouts; a REJECT fails instantly. Stealth against debuggability, and this post chooses stealth — but if you are still iterating on a policy, REJECT will save you a great deal of waiting.

Step 3 — The allowlist​

Before writing a rule for the resolver, find out which resolver the container actually uses:

docker exec agent-sandbox cat /etc/resolv.conf
nameserver 127.0.0.11
search corp.internal internal mesh.example.com
options edns0 trust-ad ndots:0

# Based on host file: '/etc/resolv.conf' (internal resolver)
# ExtServers: [8.8.8.8 8.8.4.4]

ExtServers: [8.8.8.8 8.8.4.4]. The container is not using this network's DNS. The host resolves through systemd-resolved on 127.0.0.53, which is a loopback address and therefore useless inside a container's namespace, so Docker substituted Google Public DNS without mentioning it.

Look at the inherited search domains in the same block (redacted here, but real on your box). Every internal hostname that container looks up is being asked of 8.8.8.8, suffixed with the private domains it inherited from the host. Nobody chose that, and nothing surfaces it except reading this file.

It also decides the rule: allow the resolver the container has, not the one you assumed.

getent hosts inference.wiline.com
67.207.107.229 inference.wiline.com
sudo iptables -I DOCKER-USER -i br-agent -p udp --dport 53 -d 8.8.8.8,8.8.4.4 -j ACCEPT
sudo iptables -I DOCKER-USER -i br-agent -p tcp --dport 53 -d 8.8.8.8,8.8.4.4 -j ACCEPT
sudo iptables -I DOCKER-USER -i br-agent -p tcp --dport 443 -d 67.207.107.229 -j ACCEPT
sudo iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -d 67.207.107.229/32 -i br-agent -p tcp -m tcp --dport 443 -j ACCEPT
-A DOCKER-USER -d 8.8.4.4/32 -i br-agent -p tcp -m tcp --dport 53 -j ACCEPT
-A DOCKER-USER -d 8.8.8.8/32 -i br-agent -p tcp -m tcp --dport 53 -j ACCEPT
-A DOCKER-USER -d 8.8.4.4/32 -i br-agent -p udp -m udp --dport 53 -j ACCEPT
-A DOCKER-USER -d 8.8.8.8/32 -i br-agent -p udp -m udp --dport 53 -j ACCEPT
-A DOCKER-USER -i br-agent -j DROP

Five allows on one deny. The same four probes, a third time:

inference API HTTP 401 exit=0
arbitrary host exit=28 Connection timed out after 8002 milliseconds
authentik LAN exit=28 Connection timed out after 8003 milliseconds
other network exit=28 Connection timed out after 8001 milliseconds

The four probes after the allowlist: the inference API answering 401, and the other three destinations timing out at the connection stage rather than at DNS

Look at line two. It says "Connection timed out", not "Resolving timed out". DNS is open, so example.com resolved to a real address — and then the packet died. The lookup succeeded and the socket did not.

That is the whole post in one line. A compromised process inside that container can discover where to send your data and cannot send it.

The conntrack rule this doesn't need

Every guide on this subject tells you to accept RELATED,ESTABLISHED traffic first, and Docker's documentation shows the rule. This policy has no such rule, and the 401 above proves the full TLS handshake and HTTP response came back anyway.

The reason is the direction. Our drop matches -i br-agent — traffic arriving from the container. Return packets carry -o br-agent, never match it, pass through DOCKER-USER untouched, and are accepted downstream by Docker's own connection-tracking rules.

You need the conntrack accept when your drop can match the return direction, which is the case in the ingress examples that advice comes from. A direction-specific egress drop does not. Add it anyway if you like — it costs nothing — but understand that copying it without understanding it is how people conclude firewall rules are black magic.

Step 4 — Measuring the cost, twice​

A policy nobody measures is a policy someone will disable under load. So: what does six rules cost?

The obvious approach, twenty connects with the rules on, then twenty with them off:

for i in $(seq 20); do docker exec agent-sandbox curl -sS -o /dev/null -w '%{time_connect}\n' -m 8 https://inference.wiline.com/v1/models; done | sort -n | awk '{a[NR]=$1} END{printf "rules ON — median %.4fs min %.4fs max %.4fs (n=%d)\n", a[int(NR/2)+1], a[1], a[NR], NR}'
sudo iptables -F DOCKER-USER
rules ON — median 0.1573s min 0.0940s max 0.3768s (n=20)
rules OFF — median 0.0936s min 0.0911s max 0.1576s (n=20)

The naive measurement showing a 64 millisecond median difference between rules on and rules off

64 milliseconds. Which would be a serious finding, if it were true.

It is not. Six rules of packet matching is microseconds of kernel work, and the minimums give the game away: 0.0940 against 0.0911, near-identical. The fast path is the same in both runs. The entire difference lives in the tail.

curl's time_connect is measured from the start of the request and therefore includes name resolution. The first run paid a full round trip to 8.8.8.8. By the second, the resolver had the answer cached. We measured the order we ran them in.

Measure the TCP connect alone, by subtracting the lookup:

for i in $(seq 30); do docker exec agent-sandbox curl -sS -o /dev/null -w '%{time_namelookup} %{time_connect}\n' -m 8 https://inference.wiline.com/v1/models; done | awk '{print ($2-$1)}' | sort -n | awk '{a[NR]=$1} END{printf "connect-only median %.5fs min %.5fs max %.5fs (n=%d)\n", a[int(NR/2)+1], a[1], a[NR], NR}'
rules ON — connect-only median 0.07098s min 0.06999s max 0.08607s (n=30)
rules OFF — connect-only median 0.07102s min 0.06996s max 0.07214s (n=30)

The corrected measurement showing a median difference of four hundredths of a millisecond, with the firewalled path nominally faster

A median difference of −0.04 ms. Negative: the firewalled path measured marginally faster, which is what noise looks like. Everything sits inside about ±40 µs.

So the honest answer is not a number with a plus sign in front of it. It is that the cost of this policy is below what this method can resolve — and that the naive version of the same measurement was wrong by three orders of magnitude, in the direction that would have talked you out of deploying it.

Step 5 — Surviving a reboot​

iptables rules live in memory. That -F in the last step did to the policy exactly what a reboot does.

The reflex is iptables-persistent, and it is the wrong tool here: it saves the whole table including every rule Docker generated, and Docker regenerates those itself at start. You get duplicates and an ordering nobody chose.

What you want is a unit that owns only your rules and runs after Docker:

/usr/local/sbin/agent-egress.sh
#!/usr/bin/env bash
# Egress allowlist for the agent bridge. Re-applied after Docker starts.
set -euo pipefail

BR=br-agent
DNS="8.8.8.8 8.8.4.4"
API=$(getent hosts inference.wiline.com | awk '{print $1; exit}')

# Remove only our own rules, so this is safe to run twice.
# `|| true` matters: on a clean chain grep exits 1 and would abort the script.
existing=$(iptables -S DOCKER-USER | grep " -i ${BR} " || true)
if [ -n "${existing}" ]; then
printf '%s\n' "${existing}" | sed 's/^-A /-D /' |
while read -r rule; do iptables ${rule} || true; done
fi

for d in ${DNS}; do
iptables -I DOCKER-USER -i "${BR}" -p udp --dport 53 -d "${d}" -j ACCEPT
iptables -I DOCKER-USER -i "${BR}" -p tcp --dport 53 -d "${d}" -j ACCEPT
done
iptables -I DOCKER-USER -i "${BR}" -p tcp --dport 443 -d "${API}" -j ACCEPT
iptables -A DOCKER-USER -i "${BR}" -j DROP
/etc/systemd/system/agent-egress.service
[Unit]
Description=Egress allowlist for the agent bridge
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/agent-egress.sh

[Install]
WantedBy=multi-user.target
sudo chmod +x /usr/local/sbin/agent-egress.sh
sudo systemctl daemon-reload && sudo systemctl enable --now agent-egress.service

Prove it is idempotent by restarting it and comparing the chain to itself:

sudo systemctl restart agent-egress.service && sudo iptables -S DOCKER-USER

The same six rules listed after two consecutive restarts of the unit, identical both times

Six rules, twice, no duplicates.

Two things this buys quietly. getent runs on every start, so the API address is re-resolved at boot — not continuous, but the cheap majority of the moving-target problem with no extra daemon. And the delete loop strips only rules matching -i br-agent, so the unit never touches anything else in the chain.

Skill unlocked 🏅

You can put a default-deny egress policy in front of a container, allow exactly the destinations it needs, tell a DNS failure apart from a socket failure by reading the error, measure the cost without fooling yourself, and make the whole thing survive a reboot.

If you switch Docker to the nftables backend​

Docker 29 ships an nftables firewall backend alongside iptables. It is not the default and this post did not run it — the switch rebuilds every rule on the host, and verifying the full behaviour needs a reboot. What follows is from Docker's documentation, flagged as such.

Everything above stops applying, because:

In Docker's nftables implementation, there is no DOCKER-USER chain.

You create your own table instead, and the priority does the work DOCKER-USER used to:

If your rules need to run before Docker's rules, give the base chains a lower priority number than Docker's chain.

And a rule that would be final under iptables is not:

In nftables, an "accept" rule is not final. It terminates processing for its base chain, but the accepted packet will still be processed by other base chains, which may drop it.

Overriding Docker's drop then requires --bridge-accept-fwmark.

The trap worth knowing about is the transition, and it is quiet in both directions:

When starting the daemon with nftables after running with iptables, Docker will not remove the jump from the FORWARD chain to DOCKER-USER. So, rules created in DOCKER-USER will continue to run until the jump is removed or the host is rebooted. When starting with nftables, the daemon will not add the jump. So, unless there is an existing jump, rules in DOCKER-USER will be ignored.

Switch backends and your policy keeps working — until the next reboot, when it silently stops. iptables -S DOCKER-USER will still list every rule.

Troubleshooting — the errors this run actually produced​

The systemd unit fails on its first run and works on the second​

status=1/FAILURE with nothing else in the journal. The cleanup line is the cause: on an empty chain, grep finds no matching rules and exits 1, which set -euo pipefail turns into an abort before any rule is added. The idempotency guard breaks the one run that needs no idempotency. See the || true in step 5.

Everything times out, including things you allowed​

Check whether the failure says "Resolving" or "Connection". If it says Resolving, your DNS rule is the problem and the destination rule was never reached. Containers frequently do not use the resolver you expect — read /etc/resolv.conf inside the container, not on the host.

The rules are listed but nothing is filtered​

Check that the bridge is up: ip -br link show br-agent. A network with no attached container has a DOWN interface, and a rule matching -i on it matches nothing.

Everything takes exactly eight seconds to fail​

That is DROP behaving correctly. Use REJECT while iterating if you would rather fail fast.

The measurement shows tens of milliseconds of overhead​

You are measuring DNS. Use %{time_connect} minus %{time_namelookup}, and be suspicious of any result where the minimums match but the medians don't.

What this did and didn't buy you​

Done: a container that can reach exactly one destination and a resolver; an exfiltration attempt that fails after a successful DNS lookup rather than before it; a policy that reapplies itself after a reboot and re-resolves its target when it does; and a measured cost indistinguishable from zero.

Not done:

  • DNS is still an exfiltration channel. We allowed queries to 8.8.8.8, and data can be smuggled inside them. Closing that means a resolver you control and a rule pointing only at it — which is a different post.
  • The allowlist is an IP, and IP addresses move. The unit re-resolves at boot, which is not the same as following a CDN. A forward proxy filtering on TLS SNI is the honest answer for a destination that rotates; it is also considerably more machinery.
  • This protects containers. The agents in agent orchestration part 3 and part 4 run as host processes, where these rules do not apply. Moving them into a container is the prerequisite, and is worth doing on its own merits.
  • The host is unrestricted, and so is every other container on this box. Five other bridges still have an unconditional ACCEPT in DOCKER-FORWARD.
  • Nothing here stops the injection. It stops the payload leaving. Those are different jobs.
Finished this tutorial?
Mark it complete to earn Where the agent can go, not just what it can call on your skill path.

What's next​

The obvious sequel is the resolver: run one you control, point the container at it, allow port 53 only to that address, and the DNS channel closes along with the dependency on Google. It also sets up domain-based allowlisting properly, which is the piece of the moving-target problem this post left open.

Further reading​

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.