(Spine Ch. 42.)
“All of [the gateway]’s protection has, by design, left the internal AT&T machines untested: a sort of crunchy shell around a soft, chewy center.” Bill Cheswick, The Design of a Secure Internet Gateway, Summer USENIX Conference (1990)
On 11 August 2026 a reviewer stood up two Python servers on my
loopback interface. One at 127.0.0.1:9932 served a single
string, INTERNAL_METADATA_SECRET. The other served a 302
pointing at it. Then he typed both URLs into the public free-report form
on airank, the one any stranger on the internet can use, and hit submit.
The first one my guard blocked in about a millisecond, exactly as
designed, exactly as its three green tests promised. The second one took
a little longer to come back. I watched the response render and I
already knew, the way you know when the modem noise stops early.
Now run the same move at my house, on paper. A customer pastes a
support ticket with a URL in it. Your agent has a fetch tool, because it
needs to read the pages people send it. It fetches. The URL 302s. The
redirect points at
http://192.168.1.10/api/v2.0/auth/generate_token, my
TrueNAS box, on a LAN that has never once been asked to authenticate
anything coming from inside the house. The agent gets a JSON blob back,
decides it is context, and summarizes it into a reply. Nothing crashed.
Nothing logged an error. Your monitoring is green.
Bottom line: a fetch tool turns your agent into a proxy that lives inside your network perimeter and takes routing instructions from strangers. Server-Side Request Forgery is OWASP A10 in the Top 10:2021, it is how Capital One got opened in 2019 and drew an $80 million OCC penalty for it, and agents did not invent it. Agents just handed the steering wheel to whoever writes the input. The guard you wrote almost certainly checks the first URL and then lets the HTTP client follow a redirect anywhere it wants: exactly how CVE-2026-45401 works in Open WebUI, and exactly how my own SSRF guard failed a review on 11 August 2026.
127.0.0.1 and
192.168.0.0/16 on the URL the user typed. Then Guzzle (or
requests, or curl with -L) follows a 302 to that exact
address without re-checking.169.254.169.254, the metadata endpoint, which hands out IAM
credentials to anything that asks politely.SSRF is old and boring, which is why nobody on your team is looking for it. OWASP put it at A10 in the Top 10:2021. The mechanic has not changed in fifteen years: an application makes an outbound request on a user’s behalf, the user influences the destination, and the request inherits the application’s trust on the network.
Agents make three things worse at once.
One: the input surface is everything. A traditional web app takes a URL from a form field, and you can find that form field. An agent takes URLs from ticket bodies, retrieved documents, tool outputs, other agents, and pages it fetched two hops ago in the same loop. The attacker does not need an account.
Two: the guard runs once and the client keeps going.
The actual bug in almost every implementation I have seen, mine
included. You write validate_url(), it resolves the host,
checks a private-IP blocklist, returns BLOCKED for anything internal.
Then you hand the URL to an HTTP client whose default is
allow_redirects => true. The client gets a 302, follows
it, and never calls your guard again. CVE-2026-45401 in Open WebUI (CVSS
8.5) is precisely this.
Three: cloud metadata endpoints are still sitting there. CVE-2026-64849 in MLflow (CVSS 9.3) is unauthenticated SSRF that reaches them. Per The Hacker News citing watchTowr, exploitation was in the wild within hours of the 17-18 August 2026 assignment; CISA added it to the Known Exploited Vulnerabilities catalog on 19 August, Federal patch deadline 2 September. If your ML platform takes a URL parameter, somebody has already pointed a scanner at it.
The home-LAN version has no CVE and no vendor advisory because there is no vendor. My TrueNAS at 192.168.1.10 was threat-modeled against the internet, which cannot route to it. It was never threat-modeled against a language model on the same subnet that reads email. The agent can route to it. Now the thing with an IP on my subnet is a process that reads strangers’ mail for a living.
airank, 11 August 2026. A council review of the free-report feature. Reviewer’s job: break the SSRF guard.
Direct internal URL: BLOCKED. Correct.
Redirect URL: allowed, because the outer host looked fine. Guzzle
follows the 302 to 127.0.0.1:9932, gets a 200, and hands
back the body with INTERNAL_METADATA_SECRET in it. The
guard was called exactly once, on the visitor’s input, never again on
the Location header.
The fix was allow_redirects => false and re-running
the guard on every Location header, in a loop with a hop
limit. Two lines. Jeremy Schoemaker, Lead Linux Security Engineer at
Wells Fargo Financial for three years, wrote an SSRF filter that stops
one request and waves through the second one because it came in wearing
a hat. My three tests all passed. They tested the only case I had
already thought of, which is a hell of a thing to call a test suite.
The same review turned up APP_DEBUG=true on public
production routes, leaking stack traces. SSRF is rarely the whole
breach; it is the delivery mechanism for whatever else you left on.
Capital One, 2019, for the version with a dollar figure attached. Paige Thompson, a former AWS engineer, hit a misconfigured WAF with an overly permissive IAM role. One SSRF request to the EC2 metadata service returned temporary AWS credentials, which opened an S3 bucket holding credit card application data for 106 million US applicants and 6 million Canadians. Breach 22-23 March 2019, discovered 19 July on an outside tip, $80 million OCC penalty. Bigger totals circulate; no filing I found says one out loud, so $80 million is the only figure I print.
Now put an agent where that WAF was and let anyone on the internet write its input.
One honesty note about my own house. As far as I know, no agent-fetched URL has ever landed on the TrueNAS box at 192.168.1.10, on Forgejo at port 3030, or on Galera at 192.168.1.3. Blog posts, commits, incident notes: nothing. That is either good hygiene or bad logging, and if you make me bet my own money I am taking bad logging.
Every internal hit I can actually prove happened on a bench, on
purpose, put there by me. The one that still bothers me is in my own
guard notes,
~/.claude/skills/agent-fetch-tool-ssrf-guard/SKILL.md, 6
August 2026. PHP’s FILTER_FLAG_NO_PRIV_RANGE covers
RFC1918, loopback, and link-local, so a guard built on it blocks
192.168.1.10 and feels finished. It does not cover
100.64.0.0/10, carrier-grade NAT, which is exactly the
range Tailscale hands out, and my fleet runs on Tailscale. The one block
that routes to every machine I own is the one block the flag’s name
talked me out of checking. It also waves through
::ffff:192.168.1.10, the same NAS in IPv4-mapped v6,
because the v6 filter cannot see the octets inside it.
Bench, not production. Read the LAN walkthrough as a thing I am certain is possible on my subnet, not something I can prove happened on it.
The loud failure is credential theft: somebody pulls an IAM token off a metadata endpoint and you find out from a bill or an FBI phone call.
The quiet failure:
The agent fetches something internal, gets a 200, and treats the response as context.
No exfiltration event. No credential in a log. The internal page content becomes part of the model’s working set, gets summarized, and lands in a reply to the person who planted the URL. They do not get your database dump. They get your agent’s tasteful three-sentence summary of it.
Second quiet failure: your guard passes its own test
suite. Every SSRF test written by the guard’s own author tests
direct internal URLs: 127.0.0.1, 192.168.1.1,
169.254.169.254, localhost. All blocked,
green, ship it. That is not a test suite, that is a n00b writing a
flattering quiz for himself and grading it in pen. Nobody writes the
redirect test because that means standing up a second server, which
feels like more work than the bug is worth.
I proved that on myself first. Building the fetch_url
tool in paider on 6 August 2026, I had six tests asserting no request
was made. Six green. All six would have passed with the guard deleted:
the helper handed back Guzzle’s history container by
value, so the array I asserted on was permanently empty and
could never be anything else. The only honest number that day came from
stubbing isPublic() to return true and
watching eight tests go red.
Third: DNS rebinding, which your blocklist cannot
see. You resolve the hostname, it comes back as a public IP,
you allow it. The client resolves it again a second later and gets
192.168.1.10, because the attacker controls the DNS record
and set the TTL to zero. Blocklists on hostnames are theater.
Do
allow_redirects => false on every HTTP client an
agent can reach, then follow redirects yourself with a hop cap,
re-running the guard on each Location header. This is the
fix; everything else is secondary.127.0.0.0/8,
10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16, 169.254.0.0/16 (metadata),
100.64.0.0/10 (CGNAT, and Tailscale), plus IPv6 loopback,
link-local, and IPv4-mapped forms. Block 2130706433 too:
that is 127.0.0.1 in decimal and your regex does not know
that.Don’t
Ch. 41 is the input problem: untrusted text arrives and the model cannot tell it from your instructions. This chapter is where that becomes a network problem, because the injected instruction is a routing instruction and your infrastructure obeys it. Ch. 43 is the credentials the agent carries when it arrives, the other half of the Capital One chain: the SSRF request was harmless until the IAM role attached to it was not.
Thesis is Jeremy’s (a fetch tool is a proxy inside your perimeter that takes routing orders from strangers): argument, not citation.
Verified:
validate_url() checks only
the initial URL while the HTTP client follows redirects. airank blog, 11
August 2026:
~/Projects/airank/blog/2026-08-11-the-report-that-reviewed-itself.md127.0.0.1:9932 blocked direct, Guzzle follows 302
to it and returns INTERNAL_METADATA_SECRET; fix
allow_redirects => false plus per-hop re-validation.
Same file as above.APP_DEBUG=true on public production routes leaking
stack traces. Same airank review, 11 August 2026.FILTER_FLAG_NO_PRIV_RANGE misses
100.64.0.0/10 (CGNAT, the range Tailscale assigns) and
IPv4-mapped IPv6 (::ffff:192.168.1.10); fix is explicit
range checks plus CURLOPT_RESOLVE pinning. Guard notes, 6
August 2026:
~/.claude/skills/agent-fetch-tool-ssrf-guard/SKILL.md.
Fleet on Tailscale: airank, 12 August 2026,
~/Projects/airank/blog/2026-08-12-the-council-and-the-cutover.mdisPublic() to return true failed eight tests.
paider, 6 August 2026:
~/Projects/paider/blog/2026-08-06-paider.mdWhat I could not verify:
allow_redirects defaults across urllib, requests, curl, and
Node fetch before publishing the Do list as universal.