CVE-2026-107289: every encoding was decoded, and the bypass was not an encoding
Pydantic AI's cloud-metadata blocklist had two published follow-ups, both teaching it to unwrap an IPv4 address hidden inside IPv6. I went looking for a third on the assumption that the next one would sit beside those two fixes, not inside them. It did: four characters of IPv6 zone identifier, which Python counts as part of an address's identity and the kernel throws away. Fixed in 1.107.6 and 2.44.0; the identifier arrived three weeks later, one of nine reserved in the same minute.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every request in it went to a listener I bound inside a throwaway container on my own machine; no real cloud metadata endpoint, and no host I do not own, was ever contacted. The issue was reported privately through the project’s own security advisory process, and is written up here after the advisory, the fixed releases and the CVE record were public.
Most of the targets I work on are chosen by reading code. This one was chosen by reading a list of fixes. In September, Pydantic AI had ten published security advisories, and two of them were the same bug: a way to reach a cloud metadata service through a URL the library had promised to refuse. Both had been fixed. Both fixes were follow-ups to the same original advisory. A function that has been corrected twice for one mistake is telling you where its authors’ attention has been, and, by subtraction, where it has not.
The promise, and the two times it broke
Pydantic AI can download the files a model works with: images, audio, documents, anything wrapped in a FileUrl. In February, CVE-2026-25580 closed the obvious hole in that, and 1.56.0 started refusing private and internal addresses by default. It also made a second, stronger promise. An application can opt a URL back into local access with force_download='allow-local', but even then, cloud metadata endpoints stay blocked. They are the addresses that hand out short-lived cloud credentials to anything that asks from inside the instance, and the library keeps them on a list that no flag turns off.
That list has two halves, and so does the function that consults it:
def is_cloud_metadata_ip(ip_str: str) -> bool:
"""Check if an IP address is a cloud metadata/credential endpoint.
These are always blocked for security reasons, even with allow_local=True. IPv6
transition forms are decoded so a metadata IP cannot be smuggled in as IPv6.
"""
...
if isinstance(ip, ipaddress.IPv4Address):
return ip in _CLOUD_METADATA_IPV4
if ip in _CLOUD_METADATA_IPV6:
return True
return any(candidate in _CLOUD_METADATA_IPV4 for candidate in _embedded_ipv4s(ip, exhaustive=True))
Three branches. An IPv4 address is looked up in the IPv4 list. An IPv6 address is looked up in the IPv6 list: the AWS, GCP and Scaleway endpoints that live at addresses like fd00:ec2::254. Failing that, the IPv6 address is searched for an IPv4 address hidden inside it, and each candidate is looked up in the IPv4 list.
In May the promise broke twice, both times in the third branch. CVE-2026-46678 wrapped 169.254.169.254 in IPv4-mapped IPv6, 6to4 and NAT64 forms, which dual-stack and translating networks deliver to the IPv4 address underneath. Three days later CVE-2026-48782 found the encodings the first fix had not decoded: IPv4-compatible addresses, the NAT64 local-use prefix, operator-chosen NAT64 prefixes and ISATAP. Each fix taught _embedded_ipv4s more ways an IPv4 address can be written inside an IPv6 one. By the time I arrived, the docstring’s claim, that transition forms are decoded, was true and exhaustive. It even takes an exhaustive=True argument.
Where I decided not to look
Before reading the code closely, I wrote down what I expected. If the second fix had turned the decoder into a single chokepoint that every path went through, then hunting for a call site that skipped it would come up empty, and the productive question would be about what goes into the chokepoint. The code bore that out. There was one function, and both public entry points reached it. The decoder had been fixed twice and was now thorough.
So I stopped looking for a fourth way to hide an IPv4 address. Two reports had already earned identifiers for that line of thought, and the maintainers had closed it carefully behind them. The branch neither fix had touched was the one directly above: the plain lookup of an IPv6 address in the IPv6 list, unchanged since 1.56.0. It had never been the subject of a report, because nothing about it involves decoding. It just asks whether this address is one of these four.
A suffix the decoder never sees
An IPv6 literal can carry a zone identifier: fe80::1%eth0, or fe80::1%2, naming which interface a link-local address is meant on (RFC 4007). It is a suffix on the literal, not an encoding of anything. There is no IPv4 address inside it to find, so the decoding branch has nothing to say about it.
Python’s ipaddress module parses the zone and keeps it, and it keeps it in the address’s identity:
>>> a = ipaddress.ip_address('fd00:ec2::254')
>>> b = ipaddress.ip_address('fd00:ec2::254%251')
>>> a == b
False
>>> b in frozenset({a})
False
>>> b in ipaddress.ip_network('fd00:ec2::/32')
True
>>> a.packed == b.packed
True
Equality and hashing include the zone, so fd00:ec2::254%251 is not a member of a set containing fd00:ec2::254. The second branch of is_cloud_metadata_ip is exactly that membership test. The kernel, for its part, uses a zone to pick an interface for a link-local destination and ignores it for anything else. fd00:ec2::254 is not link-local, so the zone goes nowhere, and the packet goes to the metadata address.
The other two branches were already safe. IPv4 has no zones, and _embedded_ipv4s works on .packed, which the zone does not touch. So does the private-network check nineteen lines further down, which tests containment in a network rather than membership in a set. Out of every comparison in the module, the lookup in the second branch was the only one done by identity.
Four characters
I ran it against pydantic-ai-slim==2.43.0 from PyPI, the current release at the time. The target was a listener of my own inside a --rm container, with fd00:ec2::254 added to its loopback and a fake credential blob as the response. Every line below is one run, through the library’s own URL validation, with local access allowed except where stated:
--- NEGATIVE CONTROLS: forms that must be refused ---
[neg-1 loopback, default mode] REFUSED http://[::1]/x
[neg-2 IMDS v4, allow_local] REFUSED http://169.254.169.254/latest/meta-data/
[neg-3 IMDSv6 bare, allow_local] REFUSED http://[fd00:ec2::254]/latest/meta-data/iam/security-credentials/x
[neg-4 v4-mapped IMDS, allow_local] REFUSED http://[::ffff:169.254.169.254]/latest/
[neg-5 IMDSv6 zone, DEFAULT mode] REFUSED http://[fd00:ec2::254%25lo]/... (private-IP guard still holds)
--- CANDIDATE BYPASS: same endpoint, zone id appended, allow_local ---
[bypass %251] REACHED http://[fd00:ec2::254%251]/latest/meta-data/iam/security-credentials/x
status=200 body=b'{"AccessKeyId":"ASIA-FAKE-LOCAL-CONTROLLED","Token":"this-is-my-own-listener"}'
neg-3 and the bypass are the same address, the same path and the same call. They differ by %251, which is how a zone of 1 is spelled inside a URL. neg-4 is the first fix holding. neg-5 sets the boundary: without the opt-in, the private-network check catches the zoned form as well, so this is a hole in the always-on promise and not in the default.
Counting instead of guessing
A bypass found by trying one address leaves open whether there are others. So I ran the comparison over every entry in every table the module has, bare against zoned:
every IPv6 entry in _PRIVATE_NETWORKS (8) -> divergences: 0
every IPv4 metadata entry (7) x every transition form (4) = 28 -> divergences: 0
every IPv6 metadata entry (4) -> divergences: 4
Four divergences in the whole classifier, all four from the one membership test. That number went into the report, because it tells the maintainer that the fix is one line and not a hunt.
The affected range was measured the same way. I imported is_cloud_metadata_ip out of each released wheel and asked it about the bare and zoned addresses. 1.55.0 has no guard at all. From 1.56.0 through 2.43.0, including the two releases that fixed the earlier advisories, every version blocks the bare address and passes the zoned one. git log -S 'fd00:ec2::254' finds a single commit, the one that fixed the February advisory. The IPv6 entries and the gap arrived together.
Two leads that went nowhere
Two other ideas died during the work, and both went into the report as negatives so nobody would spend time on them again.
A named zone, %25lo, does not connect. Only numeric zone indices route, but any numeric index does, so an attacker does not need to know anything about the target’s interfaces. And DNS rebinding, which I had expected to be the sharpest question, is not available: the library resolves the hostname once, validates the answer and pins the connection to that address.
The fix went further than mine
I suggested dropping the zone right after parsing, inside is_cloud_metadata_ip, and mentioned a stricter option without recommending it: refuse any URL host that carries a zone at all.
Douwe Maan did neither exactly. Commit 02157e1b adds a _parse_ip helper that both guards now use, and its docstring explains the bug better than my summary did:
A zone is only meaningful for a link-local destination — the kernel ignores it for anything else and delivers the request to the address regardless — so it must never change how a guard classifies the address. Dropping it once, here, keeps every guard comparing the address itself, whether it compares by set membership or by network containment.
That handles the private-network check too, which was not broken but would have been the next place for someone to add a set. It also drops the zone only for the classification. A test pins down that a legitimate zoned link-local fetch under local access still carries its zone and still works. The stricter option would have broken that, and this is the reason it was the wrong one to ship.
The tests cover more than I had asked for. They include every metadata address with a zone, a named zone that does not route, a zone on an IPv4-mapped form, and one case I had not tested: a hostname whose DNS answer is a zoned metadata address. The same change was backported to the 1.x line as 4da70591.
The advisory changed in three ways. I had filed one range, on one package. The published version splits it into >= 1.56.0, < 1.107.6 and >= 2.0.0b1, < 2.44.0, and adds the pydantic-ai meta-package beside pydantic-ai-slim. It also took the scoping I had raised. Neither earlier advisory had mentioned the second way in, web_fetch_tool(allow_local_urls=True), where the model chooses the URL and per-hop redirect validation means one innocuous public page is enough to reach the next. The published Who Is Affected section lists it beside FileUrl, and then narrows the audience in a way I could not have. The bundled integrations, Agent.to_web, VercelAIAdapter and the AG-UI adapter, never pass force_download through from external data, so applications that only use those are not affected through that path.
The severity is the one thing nobody changed. I filed CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:N/A:N, 6.8, copied verbatim from both earlier advisories rather than argued for. Two differences pull in opposite directions. This variant needs no NAT64 or ISATAP network, unlike the second, but it reaches only the IPv6 endpoints, unlike the first. That was the score the advisory published with.
From the report on 12 September to 2.44.0 on PyPI took five days. The fix merged on the 16th and the advisory went public at 04:14 UTC on the 17th.
Nine numbers in one minute
The identifier took longer than the fix. Douwe requested a CVE on 16 September, about two hours before merging the fix, and the advisory published the next morning without one. On 7 October at 15:53 UTC, GitHub reserved CVE-2026-107286 through CVE-2026-107294: nine consecutive identifiers for nine Pydantic AI advisories, all in the same minute. The oldest of those advisories had been public since 8 August. They were published over about two hours the next afternoon. Mine was issued at 15:19 and its record went live a minute later.
The record carries the title I filed, with “incomplete fix for CVE-2026-46678 and CVE-2026-48782” still on it, where the advisory now uses the maintainer’s shorter version. Like the other eight, it has no credits field. When GitHub is the numbering authority, the reporter’s name stays on the advisory and does not reach the record. I have written about that gap before and will not repeat the argument. What is new here is the batch: one vendor’s whole backlog, cleared in a single reservation, on GitHub’s schedule rather than in the order the requests were made.
What one data point is worth
The method that found this is a hypothesis about people, not code: maintainers fix what was reported, and they fix it thoroughly. That pushes the next bug just outside the reported area. Here it held. Both fixes had made the decoder exhaustive, and the gap sat one branch away, in a comparison that never needed decoding. That is one confirmation, and it does not make the method reliable. What it does give is a cheaper way to choose where to read. The list of a project’s own fixes is public, and you can read it before opening any code.
Advisory GHSA-vmxc-h2x2-jmf3 · CVE CVE-2026-107289 · Fix 02157e1b (2.x), 4da70591 (1.x) · Parents CVE-2026-46678, CVE-2026-48782, CVE-2026-25580 · Affected >= 1.56.0, < 1.107.6 and >= 2.0.0b1, < 2.44.0, fixed in 1.107.6 and 2.44.0
Reported 12 September · accepted 14 September · fixed and advisory published 17 September · CVE reserved 7 October, issued and record published 8 October, crediting me as reporter on the advisory.
Eurico Nicacio — @h3llh0und