The moment you expose a tool like this over MCP, the URL stops being yours:
@mcp.tool()
async def call_api(url: str, method: str = "GET") -> str:
"""Call a REST API and return the response body."""
async with httpx.AsyncClient() as client:
r = await client.request(method, url)
return r.text
The model fills in url. Your server makes the request, from inside your network, with whatever egress your process has. That is a request forwarder with a language model at the wheel, which is the classic shape of a server-side request forgery bug. The difference from a normal SSRF is that the attacker input does not have to arrive in an HTTP parameter. It can arrive in a document the model read, a webpage it fetched a second ago, or a support ticket it was asked to summarize.
So you add an allowlist. Here is the one I wrote for my own server, and what I had to stop it from doing.
The string check that does not hold
The first instinct is to check the text of the URL:
if not url.startswith("https://api.example.com"):
raise ValueError("host not allowed")
Three URLs pass that check and none of them go where you think.
https://api.example.com.attacker.tld/collect
https://api.example.com@attacker.tld/collect
https://api.example.com.attacker.tld/../../whatever
The first is a suffix trick: api.example.com.attacker.tld is a hostname under attacker.tld, and it starts with your allowed string. The second is the userinfo field. Everything before the @ in the authority is a username, so the host is attacker.tld and api.example.com is just a decorative login name. A "api.example.com" in url check is worse again, because the substring can live in the query string of a completely different URL.
The fix is to stop reasoning about the URL as text and let a parser tell you what the host actually is.
Parse, then compare
from urllib.parse import urlsplit
ALLOWED_HOSTS = {"api.example.com", "api.internal-billing.example.com"}
ALLOWED_SCHEMES = {"https"}
def check_url(url: str) -> str:
parts = urlsplit(url)
if parts.scheme not in ALLOWED_SCHEMES:
raise ValueError(f"scheme not allowed: {parts.scheme!r}")
host = parts.hostname
if host is None or host not in ALLOWED_HOSTS:
raise ValueError(f"host not allowed: {host!r}")
return url
urlsplit(...).hostname is doing real work here. It strips the userinfo, strips the port, and lowercases the result, so HTTPS://API.EXAMPLE.COM@evil.tld/ gives you evil.tld and the check fails where it should.
The scheme check matters as much as the host check. Without it, file:///etc/shadow has no host at all, and depending on your client stack a scheme you never considered can still open something. Allow the schemes you actually use and reject the rest by default.
Set membership is an exact match, which is the behaviour you want. If you need subdomains, write the rule out rather than reaching for a substring:
def host_allowed(host: str, allowed: set[str]) -> bool:
return any(host == a or host.endswith("." + a) for a in allowed)
Be honest about what that widens to. Anyone who can create a subdomain on that parent domain is now inside your allowlist, and on a large SaaS provider that can be anyone with an account.
Layers you can add on top
The host allowlist is the boundary. A few standard-library techniques harden the path around it, and you can bolt each of these on independently.
Reject non-public addresses. If your allowlist is generated from config and someone adds a wildcard, or you allow a host you do not control, resolving before you connect catches the address ranges that hurt: loopback, private ranges, and the link-local range where cloud instance metadata lives.
import ipaddress
import socket
def check_resolves_public(host: str) -> None:
for *_, sockaddr in socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP):
ip = ipaddress.ip_address(sockaddr[0])
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast):
raise ValueError(f"{host} resolves to a non-public address: {ip}")
ipaddress handles the encodings by hand-rolled checks miss. IPv4 written in decimal, IPv6, and IPv4-mapped IPv6 all parse into an object whose is_private and is_loopback properties tell you the truth.
Do not follow redirects blindly. An allowed host that returns a redirect can hand the request to a host you never allowed, and most HTTP clients will follow it for you by default. Turn that off and re-run the check on every hop:
async def fetch(client, url: str, max_hops: int):
hops = 0
while True:
check_url(url)
r = await client.request("GET", url)
if not r.is_redirect:
return r
hops += 1
if hops > max_hops:
raise ValueError("redirect limit exceeded")
url = str(r.next_request.url)
The client is constructed with follow_redirects=False so the loop above is the only thing moving between hops.
Keep credentials out of the model's reach. If the tool signature takes a headers dict, the model can set Authorization on a request to any allowed host, and it can also fail to set it. Injecting auth server side, keyed on the resolved host, means the token never appears in a tool argument and never appears in a transcript.
What this does not solve
A host allowlist controls where the request goes. It controls nothing else, and the gaps are worth naming.
It does not close the gap between the check and the connection. You resolve a name, you decide it is fine, and then the HTTP client resolves it again when it opens the socket. A DNS record with a short TTL can answer differently on the second lookup, which is DNS rebinding. Closing that means pinning the address you validated and connecting to that address directly, which is a transport-level change, not a text-level one.
It does not stop exfiltration to an allowed host. If your allowlist includes an API that echoes a path or accepts arbitrary POST bodies, data can leave through it. Path and method restrictions are a separate control.
It does not make the response safe. Whatever comes back is going into the model's context, and an allowed host serving attacker-influenced content is a prompt injection channel. Treat tool output as untrusted input to the next turn.
And it is the wrong tool if your product genuinely needs to fetch user-supplied URLs across the open internet. An allowlist cannot enumerate the internet. That case belongs to an egress proxy or a network policy, with the fetcher running somewhere that cannot reach anything internal in the first place.
Test the denials
The check above has one job and it is a refusal. Write the test file as the list of things that must be rejected: the suffix host, the userinfo host, the non-HTTPS scheme, the file:// URL, the redirect to an off-list host. A passing request proves very little. A rejected one proves the boundary exists.
The MCP server I built and run packages this host allowlist together with a read-only SQL guard and a path-traversal sandbox as a Python template you can clone: https://fulcrumenterprises.tech/go/mcp-starter-kit/?c=devto
Top comments (0)