Skip to content
back to writing

Closing the SSRF gap nobody's fetch server closes

Why I built Safe-Fetch, an MCP fetch server with SSRF defense as the entire point, not an afterthought.

#mcp#security#typescript

The most-used reference MCP fetch server ships with no SSRF protection, by its own README’s admission. I read that line and couldn’t stop thinking about it. Any agent with fetch access is one crafted URL away from reaching your cloud metadata endpoint, an internal admin panel, or a service that never expected to be reachable from outside its network.

So I built Safe-Fetch: an MCP fetch server where SSRF defense isn’t a feature, it’s the entire point.

One pipeline, no exceptions

Every request, including every redirect the server follows, runs through the same four checks. There’s no separate, looser path for redirects, which is exactly where most fetch servers quietly lose their guarantees.

Pipeline

1
Zod validation
Input integrity
2
URL policy
Scheme + credential checks
3
resolveAndPin
Resolve once, pin IP
4
Redirect re-check
Same guards, every hop

That last stage is the one worth sitting with. It’s common to validate the initial URL carefully and then follow redirects through a lighter, separate check, on the theory that the first check already proved the request was safe. It didn’t. The IP a hostname resolves to right now says nothing about what it’ll resolve to on the next lookup.

Where the naive version breaks

DNS rebinding is the attack this pipeline exists to close. The idea: an attacker points a domain at a normal, external IP long enough for your server to validate it, then swaps the DNS record to something internal before the actual connection happens.

Naive fetch
1Resolve api.example.com → 93.184.x.x
2Validate the IP. Looks external, allow it
3TTL is 0. Attacker swaps the record before connect
4Socket opens to 169.254.169.254 instead
safe-fetch
1Resolve api.example.com → 93.184.x.x
2Validate the IP, then pin the connection to it
3The DNS record changes underneath. Doesn’t matter
4Socket opens to the pinned 93.184.x.x, every time

Pinning the connection to a resolved IP, rather than re-resolving at connect time, closes the window entirely. It’s a small mechanical difference with an outsized effect on what the server can be tricked into doing.

What a blocked request actually looks like

The default threat matrix blocks cloud metadata addresses, RFC-1918 private ranges, loopback, and IPv4-mapped IPv6 loopback, along with non-HTTP(S) schemes and URLs carrying embedded credentials.

$ safe-fetch fetch –url “http://169.254.169.254/latest/meta-data/
✕ blocked: cloud-metadata-address
reason: 169.254.169.254 matches the cloud metadata denylist
stage: URL policy enforcement
$ safe-fetch fetch –url “https://example.com/docs
✓ resolved 93.184.216.34, pinned
200 OK · 4.2kb markdown returned

Successful responses are also explicitly framed as untrusted data before they reach the agent, so fetched content gets treated as data to reason about, not instructions to follow.

What I’d tell someone building the next one

Correct SSRF defense is genuinely hard to get right, and doing it right, and being able to prove it, was the whole point of this project. The pipeline design forced a discipline I wouldn’t have gotten from a checklist: if a request or a redirect can’t name which of the four stages it passed through, it doesn’t go out. Sixty-two unit tests cover the threat matrix, and the project has been checked against the OWASP MCP Top 10 via independent scanning.

If you’re wiring fetch access into an agent, read your fetch server’s SSRF section before you read anything else about it. If it doesn’t have one, that’s the answer.

Code’s on GitHub.