# Pydantic AI's DNS-Rebind Bypass of Its Egress Allowlist

> Pydantic AI's GHSA-5ppx-9jq6-6pmw let a DNS rebind beat its egress allowlist. A runnable demo shows why the fix belongs in a gateway, not app code.

- Canonical URL: https://webofmike.com/pydantic-ai-egress-dns-rebind/
- Author: Mike Moore (https://webofmike.com/about/)
- Published: 2026-11-06
- Last modified: 2026-11-06
- Tags: AI Gateways, Security, AI Agents, MCP
- Cite as: Mike Moore, "Pydantic AI's DNS-Rebind Bypass of Its Egress Allowlist", Web of Mike (webofmike.com), 2026-11-06. https://webofmike.com/pydantic-ai-egress-dns-rebind/


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](https://github.com/pydantic/pydantic-ai/releases/tag/v2.66.0). 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](https://github.com/pydantic/pydantic-ai/security/advisories/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](https://github.com/pydantic/pydantic-ai/security/advisories/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](https://github.com/themsquared/pydantic-ai-egress-bypass-demo).

*Disclosure: I work at Solo.io, which makes [agentgateway](https://agentgateway.dev). 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:

```python
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:

```python
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.

```python
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](/agentgateway-egress-proxy/) and [tested against proxy-variable bypasses](/agent-egress-control-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:

```bash
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](https://github.com/themsquared/pydantic-ai-egress-bypass-demo) if you want to run the rebind yourself.

