#2659·instructor

SSRF filter bypass: is_global misses IPv6 transition addresses (NAT64/IPv4-compat) → cloud metadata

Author: Koustav-githubCreated Sep 17, 2026Updated Sep 17, 2026
  • This is actually a bug report.
  • I am not getting good LLM Results
  • I have tried asking for help in the community on discord or discussions and have not received a response.
  • I have tried searching the documentation and have not found an answer.

What Model are you using?

  • Other: N/A — provider-agnostic. The bug is in the remote-media SSRF guard (instructor/v2/core/remote.py), independent of the model/provider.

Describe the bug

The remote-media fetch guard added in #2569 validates addresses with ipaddress.is_global in _validate_public_address:

if not parsed.is_global or parsed.is_multicast:
    raise RemoteFetchError(...)

CPython classifies IPv6 transition addresses that embed an internal IPv4 as globally routable, so the guard passes them. On a host with NAT64/DNS64 routing (IPv6-only AWS/GCP subnets, many mobile/corporate networks) or dual-stack, the request is delivered to the embedded IPv4 — loopback, RFC1918, or the cloud metadata endpoint 169.254.169.254 — enabling SSRF and IAM-credential theft.

Host Really targets Current guard
64:ff9b::a9fe:a9fe 169.254.169.254 (metadata) ❌ accepted
64:ff9b::7f00:1 127.0.0.1 ❌ accepted
64:ff9b::a00:1 10.0.0.1 ❌ accepted
::a9fe:a9fe 169.254.169.254 ❌ accepted
::ffff:169.254.169.254 169.254.169.254 ✅ blocked
::1 / 127.0.0.1 loopback ✅ blocked

The most important vector is the 64:ff9b::/96 NAT64 well-known prefix, which real DNS64/NAT64 deployments use. The existing tests cover ::1 but never the transition prefixes. (Same class as pydantic-ai CVE-2026-46678 and the fixes in ragflow/lightrag/homebox.)

To Reproduce

python
from instructor.v2.core import remote
remote._validate_public_url("http://[64:ff9b::a9fe:a9fe]/latest/meta-data/")
# -> returns None (accepted) instead of raising RemoteFetchError

Reachable via public API

python
from instructor.v2.core.multimodal import Image
img = Image.from_url("http://[64:ff9b::a9fe:a9fe]/x.png")  # accepted
# On a NAT64 host, img.to_anthropic()/.to_genai() then fetch the metadata IP.

Expected behavior

The guard should judge an IPv6 transition address by the IPv4 it embeds, not by the wrapper. Unwrap IPv4-mapped, 6to4, Teredo, NAT64 (64:ff9b::/96, 64:ff9b:1::/48) and the deprecated IPv4-compatible ::/96 form, and reject the request when the embedded IPv4 is non-global. A NAT64 wrapper around a public IPv4 must stay allowed so IPv6-only egress keeps working.

Screenshots N/A