If you give an AI agent a tool that fetches URLs, you've given it a tool that
can be pointed at your own infrastructure. That's not a hypothetical — it's
one of the most common real vulnerability classes showing up in MCP servers
right now, and the fixes that "obviously" work keep quietly failing on the
same handful of edge cases.
I spent the last few weeks building safe-fetch-mcp-server,
an MCP server whose entire job is fetching a URL and returning clean
markdown — and whose entire point is refusing to do that when the URL
points somewhere it shouldn't. This is the story of what actually made that
hard, a bug that only showed up once I stopped trusting my own test suite,
and how I tried to prove the thing works instead of just asserting it.
The problem in one sentence
An agent that can fetch arbitrary URLs on your behalf can be tricked into
fetching http://169.254.169.254/latest/meta-data/ — the cloud metadata
endpoint every major provider exposes — and handing your instance
credentials to whoever wrote the page that told it to. The most widely used
reference fetch server ships with no SSRF protection at all, by its own
README's admission. Several of the "secure" community alternatives have
shipped real CVEs anyway: an IPv6 check that missed the IPv4-mapped loopback
form (::ffff:127.0.0.1), a background poller that re-fetched a URL through
a code path the original SSRF guard never touched.
That second one stuck with me. It's not a hard bug to imagine — you write the
guard, you wire it into the code path you're looking at, and eighteen months
later someone adds a "refresh this URL periodically" feature that calls a
slightly different function three files away. The guard was never wrong. It
just wasn't everywhere.
The principle that actually holds up
Hostname-string checks fail for two structural reasons: IP address encodings
are effectively infinite (2130706433, 0x7f000001, 0177.0.0.1, and
127.0.0.1 are the same address), and a hostname can legitimately resolve to
something completely different on the next lookup — DNS rebinding, where an
attacker's domain answers with a public IP the first time your validator
checks it, then a private one the moment you actually connect.
The only approach that closes both holes: validate the resolved IP, never
the hostname string, and pin the connection to that exact IP. Resolve
once. Check the address you got back against explicit blocked ranges — not a
library's opinion of what's "private," explicit ranges you wrote yourself,
because the most popular IP-classification package on npm has itself shipped
an SSRF-bypass CVE. Then connect to that address, not to "whatever this
hostname resolves to right now."
That last part is the one people skip, because it's the one Node makes
inconvenient.
The bug that only showed up in production
Node's global fetch — and the http/https clients by default — will
re-resolve DNS at connection time regardless of what you validated a moment
earlier. So the fetch server doesn't use them. It resolves the hostname
itself, validates the result, and passes a custom DNS lookup function into
http.request() that always answers with that one already-validated
address, no matter what hostname it's asked about. The socket physically
cannot connect anywhere else.
I wrote sixty-two tests for this. Every threat-matrix row had a green check
mark. And the very first time I drove a real HTTPS request through a real
MCP client instead of my own test fixtures, it failed with:
Invalid IP address: undefined
...four stack frames deep inside Node's TLS socket code, nowhere near
anything I'd written. It took a debugging session to work out why: Node
enables Happy Eyeballs (dual-stack connection racing) by default, and when
it's active, the HTTP client calls your custom lookup function with
{ all: true } and expects an array of addresses back — not the single
string every piece of documentation I'd read implied. My function always
returned a bare string. Node silently misread it, and the failure surfaced
somewhere completely unrelated to the actual bug.
The fix was three lines — branch on options.all, return the shape the
caller actually asked for. The lesson was bigger: a mocked test suite proves
your logic is internally consistent. It doesn't prove Node's real networking
stack agrees with your assumptions about its own API. I only found this by
refusing to ship until I'd watched a real fetch succeed against a real HTTPS
site, not just a local fixture server standing in for one.
Proving it, not asserting it
"Secure by design" is a claim anyone can make. I wanted evidence, so once
the core was built I ran agent-audit-kit —
an independent, 276-rule static scanner for MCP servers — against the whole
repository.
First run: 13 findings. Two critical.
That's a real number, and I'm not going to pretend it wasn't a gut-check
moment for a project whose whole pitch is "provably correct." But the
interesting part wasn't the count — it was what the findings actually were
once I read the scanner's own rule source instead of just its output
message.
The critical one turned out to be a false positive with a genuinely
interesting cause: the rule pattern-matches for literal tokens like
allowedHosts: in your source to confirm you're guarding against DNS
rebinding on the HTTP transport. I was guarding against it — through the
MCP SDK's newer createMcpExpressApp() helper, which handles Host-header
validation internally and never requires you to write that literal token
yourself. The scanner's rule predates that API. Rather than just noting
"false positive" and moving on, I made the code strictly better anyway:
passed the allow-list explicitly instead of relying on the SDK's implicit
host-based auto-detection, which closed a real edge case (the protection
used to silently disable itself if you ever pointed the server at a
non-default host) and satisfied the scanner honestly, because now the
protection really was explicit.
Two more findings were genuinely real: the npm dependency was pinned to a
version range wide enough to include builds from before the DNS-rebinding
fix landed upstream, and the HTTP transport had no rate limiting at all — a
real gap, fixed with express-rate-limit and a test proving a 429 on the
third request over a two-request limit.
The rest were legitimate false positives — a scanner keyword-matching
169.254.169.254 and the word "bypassed" inside my own security
documentation, which describes those exact things because that's what the
documentation is for. I documented every exclusion with a written
justification rather than silently suppressing them, because "trust me, it's
fine" is exactly the failure mode a scanner exists to catch.
Final result: 2 findings, zero critical or high. The two that remain are
a dependency-count threshold (normal for any TypeScript project with a real
dev toolchain) and a generic "audit your SDK's scope" nudge that fires for
any project depending on the MCP SDK at all. Both documented, both accepted,
neither hideable.
What I'd tell someone building one of these
- Don't trust hostname strings, ever — not even after you've "validated" them. The only thing worth checking is the resolved IP, and the only way to make that check mean anything is to connect to the exact address you checked.
- Mocked tests prove your logic. They don't prove your assumptions about the runtime. Drive at least one real request through a real client before you believe your own test suite.
- Run an external scanner and read its source, not just its output. A 13-to-2 story is more credible than a 0-finding scan on day one would have been, because it shows the process, not just the destination.
- A guard that isn't in exactly one code path will eventually be bypassed. The single most common real-world SSRF regression is a second fetch path someone adds later without realizing the first one was ever guarded at all.
Try it
{
"mcpServers": {
"safe-fetch": {
"command": "npx",
"args": ["-y", "safe-fetch-mcp-server"]
}
}
}
- npm: safe-fetch-mcp-server
- Source: github.com/Sanoy24/safe-fetch-mcp-server
- MCP Registry:
io.github.Sanoy24/safe-fetch - Full security write-up, threat matrix, and scan evidence:
SECURITY.md
MIT licensed. Issues and PRs welcome — especially if you find the next edge
case I didn't.
Top comments (0)