#5151·fastmcp

`ssrf_safe_fetch` tries resolved addresses in a random, per-process order

Author: cdanielsenCreated Sep 17, 2026Updated Sep 17, 2026
Labelsbug

What happened?

ssrf_safe_fetch resolves a hostname, then connects to each resolved address in turn until one succeeds. For a dual-stack hostname, the order it tries them in varies from process to process, even when getaddrinfo returns the addresses in a consistent order.

The order is stable within a process but differs between processes, so it does not present as intermittent: a given worker prefers IPv6 for its entire lifetime while an identical worker beside it prefers IPv4. On hosts without IPv6 egress, the workers that happen to draw IPv6 first open a doomed connection on every OAuth client metadata fetch, wait for it to fail, then fall back to IPv4 and succeed.

Expected: addresses are tried in the order the resolver returned them, so platform preferences (e.g. precedence ::ffff:0:0/96 100 in /etc/gai.conf) are honored.

Actual: that ordering is discarded. The MRE below pins getaddrinfo to a fixed IPv4-first order and the resolved order still flips between PYTHONHASHSEED values, which points at the addresses being deduplicated through a set in resolve_hostname

Example Code

python
# mre.py

import asyncio
import socket

from fastmcp.server.auth.ssrf import validate_url

# Stubbed so the repro needs no network, and so the order the resolver returns
# is fixed and known: IPv4 first, then IPv6.
IPV4, IPV6 = "160.79.104.10", "2607:6bc0::10"

socket.getaddrinfo = lambda *a, **kw: [
    (socket.AF_INET, socket.SOCK_STREAM, 6, "", (IPV4, 443)),
    (socket.AF_INET6, socket.SOCK_STREAM, 6, "", (IPV6, 443, 0, 0)),
]

validated = asyncio.run(validate_url("https://claude.ai/.well-known/oauth-client"))

print("getaddrinfo returned:", [IPV4, IPV6])
print("FastMCP will connect:", validated.resolved_ips)

Run it under two hash seeds (the exact seed-to-order mapping depends on the CPython build; what matters is that the order changes at all while the input is held fixed):

$ PYTHONHASHSEED=7 python mre.py
getaddrinfo returned: ['160.79.104.10', '2607:6bc0::10']
FastMCP will connect: ['160.79.104.10', '2607:6bc0::10']

$ PYTHONHASHSEED=2 python mre.py
getaddrinfo returned: ['160.79.104.10', '2607:6bc0::10']
FastMCP will connect: ['2607:6bc0::10', '160.79.104.10']

Unseeded, the order varies run to run (4 of 8 runs returned IPv6 first for me); within a single process it is constant across repeated calls.

Version Information

FastMCP version:                                       4.0.5
MCP version:                                              2.2.0
Python version:                                          3.14.7
Platform:          Linux-6.12.68-linuxkit-x86_64-with-glibc2.41

Originally observed on 3.4.7; reproduced on 4.0.5 as above. Also reproduces on Python 3.10-3.13 and on macOS/arm64.