A DNS record that changes between the moment code checks it and the moment code uses it can beat an egress allowlist that resolves a hostname twice. Pydantic AI patched exactly that bug on 2026-11-04, across v2.66.0, v1.107.10, and pydantic-ai-harness 0.66.0, inside a batch of 10 security advisories disclosed at once
. Two of the ten are the same bug class I keep writing about on this site: an egress decision that lives in application code instead of the network.
GHSA-5ppx-9jq6-6pmw
(low): allowed_domains on the local web-fetch tool, and PlaywrightBrowser’s egress allowlists, resolved a hostname to check it against the allowlist, then connected to the hostname, which resolved it again. Two lookups, and nothing forced them to agree. GHSA-5fj9-5pjw-vpwc
(medium): the cloud-metadata blocklist that URL downloads check was missing Tencent Cloud’s instance-metadata address, 169.254.0.23, so someone who can hand the application a URL could have it fetch temporary role credentials from that address.
Neither bug is exotic. The first is DNS rebinding, a technique that’s been public for two decades. The second is just an incomplete list. What’s worth sitting with is where both checks lived: inside the agent framework, in the same process as the code an attacker is trying to redirect. I built a small, runnable repo that reproduces the shape of both bugs and shows the alternative: pydantic-ai-egress-bypass-demo .
Disclosure: I work at Solo.io, which makes agentgateway . This post names it as one option for where egress enforcement can live; it is not a claim that agentgateway is immune to its own bugs.
The bug: two lookups, one chance for them to disagree
Here’s the pattern GHSA-5ppx-9jq6-6pmw describes, reduced to its essence. A function checks whether a hostname is allowed by resolving it once:
def is_hostname_allowed(hostname: str, port: int) -> bool:
ip = socket.getaddrinfo(hostname, port)[0][4][0]
return ip not in BLOCKLIST_IPS
Then the code connects, separately:
def fetch(hostname: str, port: int, path: str) -> str:
if not is_hostname_allowed(hostname, port):
raise PermissionError(f"{hostname} resolved to a blocked IP")
sock = socket.create_connection((hostname, port), timeout=3)
...
socket.create_connection() takes a hostname, not an IP, so it resolves the hostname again, internally. If DNS answers differently on the second lookup than the first, the check and the connection are checking two different things. That’s not a hypothetical: a DNS record with a short TTL, under an attacker’s control, can return a safe-looking address on the first query and a forbidden one, like a cloud metadata IP, on the second.
My demo’s vulnerable_fetch.py reproduces exactly this. A mock resolver (rebind_resolver.py) answers the first lookup of a test hostname with an RFC 5737 documentation address and every lookup after that with the address where a fake metadata endpoint is listening. Running it:
$ python3 vulnerable_fetch.py
[check] resolved api.example-safe.test -> 203.0.113.10 for the allowlist check
[connect] connected to api.example-safe.test:8169 (second, independent resolution)
[result] response body: 'FAKE-ROLE-CREDENTIAL-STAND-IN-FOR-CLOUD-METADATA-DO-NOT-USE'
VULNERABLE: fetched the forbidden endpoint despite the allowlist check
The check passed. The fetch didn’t go where the check looked.
The app-level fix: resolve once, pin the IP
Pydantic AI’s actual fix reorders the check to run before resolution instead of after it. A functionally equivalent fix for this reduced version: resolve the hostname exactly once, check that single result, and connect to the IP you already resolved rather than letting the hostname resolve again.
def fetch(hostname: str, port: int, path: str) -> str:
ip = socket.getaddrinfo(hostname, port)[0][4][0] # resolve ONCE
if ip in BLOCKLIST_IPS:
raise PermissionError(f"{hostname} resolved to a blocked IP ({ip})")
sock = socket.create_connection((ip, port), timeout=3) # connect to the PINNED ip
...
$ python3 fixed_fetch.py
[check] resolved api.example-safe.test -> 127.0.0.1 for the allowlist check
BLOCKED: api.example-safe.test resolved to a blocked IP (127.0.0.1)
One resolution, one decision, one connection using that decision. This closes the specific bug. It does not close the category of bug, because the fix lives in exactly the same place the bug did: inside the application, reimplemented correctly this time, in this language binding, in this framework, this release. The next framework, or the next contributor who touches this code path, starts from zero again.
Moving the check out of the app entirely
The alternative is to stop deciding egress policy in the agent’s own process at all. A proxy in front of the agent can resolve a destination once and enforce the policy there, and it doesn’t matter whether the client behind it has a TOCTOU bug, an incomplete blocklist, or no check whatsoever.
egress_proxy.py in the demo is about 70 lines: it reads an absolute-URI HTTP request, resolves the target hostname exactly once, checks that IP against a blocklist, and either returns 403 or connects to the pinned IP and relays the response. fetch_through_proxy.py is a client with no allowlist logic of its own, standing in for “whatever bug hits the app’s own egress code next”:
$ python3 fetch_through_proxy.py
[proxy] GET http://api.example-safe.test:8169/ -> resolved api.example-safe.test -> 127.0.0.1
[proxy] BLOCKED: 127.0.0.1 is on the egress blocklist
[client] no allowlist check performed; proxy returned HTTP 403
[result] body: 'blocked by egress_proxy policy'
BLOCKED: the proxy enforced the policy even though the client never checked
The client never resolved anything, never compared an IP to a blocklist, never made a decision. It didn’t need to. The policy held because it lived somewhere the client’s own bugs can’t reach.
This is the argument for agentgateway as an egress policy enforcement point, which I built a standalone proof of
and tested against proxy-variable bypasses
back in September and October. A cloud-metadata blocklist, correctly maintained, has to enumerate every provider’s instance-metadata address and keep enumerating as providers add more. A gateway that authorizes the resolved destination at connect time, once, in one place, doesn’t need the enumeration to be perfect in every agent framework that might ever run behind it. It needs to be correct once.
Running it yourself
No dependencies beyond the Python 3 standard library, no sudo, no Docker:
git clone https://github.com/themsquared/pydantic-ai-egress-bypass-demo.git
cd pydantic-ai-egress-bypass-demo
bash demo.sh
demo.sh starts a fake metadata endpoint, runs the vulnerable fetch (expect VULNERABLE, exit 1), runs the fixed fetch (expect BLOCKED, exit 0), starts the egress proxy, and runs the no-check client through it (expect BLOCKED, exit 0). I ran it three times back to back before publishing this to make sure nothing depended on process-timing luck.
One thing worth naming if you adapt this: the first version of egress_proxy.py bound port 8888, and on my own machine that collided with an unrelated server I had running from a different project. socketserver.ThreadingTCPServer doesn’t set allow_reuse_address by default either, which meant a quick restart during testing hit OSError: [Errno 48] Address already in use from a TIME_WAIT socket, not a real conflict. Both are in the repo’s final version: a non-default port, and allow_reuse_address = True on the server classes.
What this demo doesn’t verify
It models the shape of GHSA-5ppx-9jq6-6pmw and GHSA-5fj9-5pjw-vpwc; it isn’t a reproduction of pydantic-ai’s actual code path, and it doesn’t run a real DNS rebind over the network with an external resolver, since the “DNS” here is an in-process mock built for determinism. egress_proxy.py is a teaching toy, not a production gateway: no TLS inspection, no SNI-based policy, no CONNECT tunneling.
Ten advisories in one batch isn’t a reason to single out Pydantic AI; disclosing ten at once instead of trickling them out is the responsible move. It’s a reason to notice where egress decisions keep ending up by default, which is wherever the request happens to originate, and to ask whether that’s actually where you want them. The repo is at pydantic-ai-egress-bypass-demo if you want to run the rebind yourself.