Every SSRF bug starts the same innocent way: a product manager asks for a feature that "just" fetches a URL. Import a profile picture from a link. Generate a PDF from a webpage. Ping a webhook to confirm it's alive. Pull an OpenGraph preview for a pasted link. Somewhere, a developer writes fetch(userSuppliedUrl), ships it, and moves on with their life.
Six months later, someone points that same feature at http://169.254.169.254/latest/meta-data/iam/security-credentials/ and walks out with your AWS IAM role's temporary credentials. No SQL injection, no XSS, no password cracking. Your server just... made a request. It was very polite about it.
That's Server-Side Request Forgery, and it's quietly one of the most consequential bug classes in modern web apps — precisely because it doesn't feel dangerous while you're writing it.
Why "It's Just a Fetch" Is the Trap
The mental model that gets developers in trouble is treating server-side HTTP requests like client-side ones. In a browser, if JavaScript tries to fetch http://192.168.1.1/admin, it's still bound by the user's network position — usually irrelevant to an attacker sitting elsewhere on the internet. But when your server makes that request, it's using the server's network position — inside your VPC, behind your firewall, with a straight line to your cloud metadata service, your internal admin panels, your Redis instance with no auth, and every other thing you assumed was safe because "it's not exposed to the internet."
SSRF turns your own infrastructure into the attacker's proxy. The request looks completely legitimate from the receiving end, because it is legitimate — it's coming from a service that's supposed to be there.
The classic target is the cloud metadata endpoint, and it's still depressingly effective in 2026:
GET /latest/meta-data/iam/security-credentials/your-role-name HTTP/1.1
Host: 169.254.169.254
Point a vulnerable "fetch this image URL" feature at that address, reflect the response back to the user (even partially — a PDF renderer or screenshot service counts), and you've just exfiltrated short-lived AWS credentials scoped to whatever your EC2 instance or Lambda can do. If that role can read S3 buckets or assume other roles, congratulations, the blast radius just grew.
The 2026 Wrinkle: Every "Agent" Is a New SSRF Surface
Here's the part that's made this bug class worse lately, not better: the current wave of LLM-powered tools that fetch URLs, scrape pages, or hit webhooks on a user's behalf are, structurally, SSRF-shaped features. "Give the agent a browsing tool" and "give the agent an HTTP client" are the same sentence security-wise as the profile-picture-import feature from ten years ago — except now the input isn't a form field, it's a prompt, and the thing deciding which URL to fetch is a model instead of a regex. I've seen internal tooling at more than one company where an AI assistant with a "fetch a webpage and summarize it" tool had zero awareness that http://localhost:6379 or an internal .svc.cluster.local address was any different from a public URL. Same vulnerability, new shiny wrapper.
The lesson doesn't change just because the caller got smarter: if something inside your infrastructure can be told to make an outbound request based on user-influenced input, that's an SSRF surface, full stop — whether "something" is a webhook validator, a PDF generator, or an agent with a browsing tool.
Where Naive Defenses Fall Apart
The instinctive fix is a blocklist — reject anything pointing at 127.0.0.1, 169.254.169.254, 10.*, 192.168.*. It feels thorough and it is almost always bypassable, because DNS and IP parsing give attackers a genuinely enormous bag of tricks:
// The "obviously fine" check most people ship
function isUrlSafe(url) {
const hostname = new URL(url).hostname;
const blocked = ['localhost', '127.0.0.1', '169.254.169.254'];
return !blocked.includes(hostname);
}
// All of these sail right through it:
// http://0.0.0.0/ -> unspecified address, often routes to localhost
// http://2130706433/ -> decimal encoding of 127.0.0.1
// http://0x7f.0.0.1/ -> hex encoding
// http://127.1/ -> shorthand IP notation
// http://[::ffff:127.0.0.1]/ -> IPv6-mapped IPv4
// http://attacker.com/redirect -> passes the check, then 302s to 169.254.169.254
That last one is the real killer: DNS rebinding and open redirects turn a hostname check into a race condition. The URL resolves to a safe, public IP when you validate it, then either resolves to something else on the actual request (DNS rebinding, since nothing forces the two lookups to agree) or redirects there after your check already passed. A blocklist checked once, before the request, tells you nothing about where the request actually ends up landing.
What Actually Works
Deny-by-default beats blocklists every time. Don't ask "is this destination bad" — ask "is this destination on the short list of things we explicitly allow," and reject everything else:
const ALLOWED_HOSTS = new Set(['api.stripe.com', 'cdn.example-partner.com']);
async function safeFetch(url) {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') throw new Error('HTTPS only');
if (!ALLOWED_HOSTS.has(parsed.hostname)) throw new Error('Host not allowlisted');
// Resolve DNS yourself, validate the *actual* IP, then connect to that IP directly
// (with the Host header set) instead of letting a second lookup happen at request time.
const { address } = await dns.promises.lookup(parsed.hostname);
if (isPrivateOrReservedIp(address)) throw new Error('Resolved to internal IP');
return fetch(parsed, { agent: pinnedIpAgent(address) });
}
The IP pinning step matters more than people expect — resolving once for validation and again for the actual connection is exactly the gap DNS rebinding lives in. If an allowlist genuinely isn't feasible (you're building the "fetch any URL the user gives us" feature by design — image proxies, webhook testers, link previews), then the fetch has to happen from network-isolated infrastructure with no route to your internal services or metadata endpoints, no exceptions, and response bodies should never be trusted or reflected back without sanitization.
At Cubet, when we built an internal service that had to hit customer-supplied webhook URLs to confirm delivery, this was the one review comment that mattered more than anything else on the PR: not "does it work," but "what can this thing reach that it shouldn't be able to." We ended up running that fetcher in its own network segment with an explicit egress allowlist, specifically because the alternative — trusting a hostname regex to hold forever against arbitrary customer input — wasn't a bet worth making.
The Takeaway
SSRF is unusual among web vulnerabilities because the exploit isn't clever — it's just your own server, doing exactly what it was built to do, pointed somewhere you didn't intend. Any feature that turns a URL into an outbound request — image imports, webhook pingers, PDF renderers, link unfurlers, and now AI agent tools — deserves the same question up front: what is this allowed to reach, and who decided that? If the answer is "whatever the URL says," you don't have a feature, you have an open proxy into your own network with someone else's finger on the trigger.
Got an SSRF war story, or a defense-in-depth trick I didn't cover? Find me on Twitter/X, GitHub, or LinkedIn — always up for comparing notes on the ways servers betray us.