August 25, 2026
The URL Field That Owns Your Cloud Account — SSRF For Developers
Why “fetch this link for me” is the most dangerous feature you’ll ship this quarter

By Fuzzyy Duck
8 min read
Why "fetch this link for me" is the most dangerous feature you'll ship this quarter
The ticket is friendly: "Let users set a profile picture from a URL instead of uploading a file."
Twenty minutes of work.
app.post('/api/avatar', requireLogin, async (req, res) => {
const response = await fetch(req.body.imageUrl);
const buffer = await response.arrayBuffer();
await saveAvatar(req.user.id, buffer);
res.json({ ok: true });
});app.post('/api/avatar', requireLogin, async (req, res) => {
const response = await fetch(req.body.imageUrl);
const buffer = await response.arrayBuffer();
await saveAvatar(req.user.id, buffer);
res.json({ ok: true });
});You've thought about security here, a little. You made sure it needs a login. You'll probably add a file size cap. Maybe check the content type.
None of that matters, because the actual problem isn't what comes back. It's where the request goes.
Your server is not sitting on the public internet. It's sitting inside your network, with a security group that lets it reach your database, your Redis instance, your internal admin service, and if you're on a cloud provider a metadata endpoint that hands out credentials to anything that asks nicely.
You just built an HTTP client that runs there, and let anyone on the internet choose its destination.
What SSRF actually is
Server-Side Request Forgery: your application makes an HTTP request to a location the user controls, and that request inherits your server's network position.
That last clause is the entire vulnerability. The attacker can't reach 10.0.4.11 from their laptop. Your server can. So they don't attack your internal service they ask your server to do it, politely, through a feature you built on purpose.
Every firewall rule you wrote to trust your own application now works for the person filling in a URL field. Your network perimeter is intact and completely irrelevant.
The misconceptions
"I validate that it starts with http://." That's a check on the scheme. It says nothing about the destination. http:// prefixes internal addresses perfectly well.
"I block localhost and 127.0.0.1." You've block-listed two spellings of one address. There are many spellings, several address families, a whole link-local range, and DNS names that resolve wherever their owner points them. More on why this approach loses below.
"It's just a GET. Nothing gets modified." A GET against a cloud metadata service returns credentials. A GET against an internal admin endpoint that was built assuming "only our services can reach this" often does something. And plenty of internal tooling accepts state changes over GET because nobody expected hostile traffic.
"The response never reaches the user, so it's harmless." Blind SSRF is quieter, not safer. Response timing and error differences map your internal network, and any request with a side effect still has that side effect.
"We're not on AWS." Every major cloud has a metadata service. And metadata is only the flashiest target internal dashboards, message queues, admin ports, and service APIs are all reachable the same way.
Where it hides
The avatar example is the obvious one. These are the ones that surprise people, because nobody thinks of them as "URL fields":
- Webhooks. The user gives you a callback URL. That's the feature. The entire feature is "make a request wherever I say."
- Link previews and unfurling. Paste a link in a comment, the server fetches it for a title and thumbnail.
- HTML-to-PDF and screenshot services. Headless Chrome renders user-supplied HTML which can reference images, stylesheets, and iframes at any address. The fetch happens inside the browser you're running on your own infrastructure.
- Document and feed importers. "Import from URL," RSS readers, "sync from your calendar link."
- XML parsers. External entity processing turns an upload into an outbound request. XXE and SSRF are neighbours.
- SSO and OIDC configuration. Metadata URLs, JWKS endpoints, issuer discovery. Often admin-configurable, often fetched at startup with no validation at all.
- Integrations with a "server URL" field. Self-hosted instance URLs, custom API endpoints, "connect your own S3."
- Health checks and monitoring features. "Enter your site URL and we'll check it's up" is SSRF with a UI.
- Image proxies and CDN-fill logic. Anything that takes a remote URL and caches it.
If a feature description contains the word "URL," it's on this list.
What it costs
The canonical example is Capital One, 2019. An SSRF flaw let an attacker reach the EC2 metadata service, retrieve temporary IAM credentials belonging to the instance role, and use them against S3. Around 100 million US and 6 million Canadian applicants had data exposed. The regulatory penalty was $80 million; the class action settled at $190 million. AWS's response was IMDSv2 a token-based metadata flow specifically designed so a naive SSRF can't reach it.
That case is worth knowing for one detail: the SSRF was not the expensive part. The expensive part was that the credentials it reached were over-permissioned. SSRF is a pivot, and how far it pivots depends entirely on what your internal network hands out to anything that connects.
Which is why the impact ranges so widely. Sometimes it's a port scan of your VPC. Sometimes it's an unauthenticated internal admin API. Sometimes it's cloud credentials and everything they touch.
The fix
Start with what doesn't work.
// This looks careful. It isn't.
const url = new URL(req.body.imageUrl);
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
throw new Error('Not allowed');
}
await fetch(url);// This looks careful. It isn't.
const url = new URL(req.body.imageUrl);
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
throw new Error('Not allowed');
}
await fetch(url);A deny-list here fails for structural reasons, not because this particular list is short:
- An address has many valid spellings. IPv4 accepts several numeric formats, IPv6 has its own notation plus IPv4-mapped forms, and the private ranges are large. You are comparing strings against a space you can't enumerate.
- DNS isn't yours. A hostname you've never seen can resolve to an internal address. Nothing in the URL string reveals this.
- Redirects move the goalposts. You validate the URL, the fetch follows a 302, and the second request goes somewhere you never checked.
- The answer can change between the check and the connection. You resolve a hostname, approve the IP, then your HTTP client resolves it again before connecting and gets a different answer. That's DNS rebinding, and it's the race condition from the last post wearing a different hat.
You cannot win a guessing game where the other side controls both the vocabulary and the dictionary. So don't play it.
Validate the destination, not the string
import { lookup } from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
// Allow-list first, if you possibly can. "Which hosts does this feature
// legitimately need?" usually has a short, boring answer.
const ALLOWED_HOSTS = new Set(['images.partner.com', 'cdn.partner.com']);
async function safeFetch(rawUrl) {
const url = new URL(rawUrl); // throws on garbage - that's fine
// 1. Scheme. Everything except http/https is a category error here.
if (!['http:', 'https:'].includes(url.protocol)) throw new BlockedUrlError();
if (!ALLOWED_HOSTS.has(url.hostname)) throw new BlockedUrlError();
// 2. Resolve to an actual address. Now we're checking a destination
// rather than a piece of text.
const { address } = await lookup(url.hostname);
const ip = ipaddr.parse(address);
// 3. Reject anything that isn't a plain public address. This covers
// loopback, link-local (including the metadata range), RFC1918,
// carrier-grade NAT, and their IPv6 equivalents.
if (ip.range() !== 'unicast') throw new BlockedUrlError();
// 4. Connect to the address we just validated - not to the hostname.
// Re-resolving here is the DNS rebinding hole.
return fetch(`${url.protocol}//${address}${url.pathname}${url.search}`, {
headers: { Host: url.hostname }, // keeps TLS and vhosts working
redirect: 'manual', // 5. never auto-follow
signal: AbortSignal.timeout(5000),
});
}import { lookup } from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
// Allow-list first, if you possibly can. "Which hosts does this feature
// legitimately need?" usually has a short, boring answer.
const ALLOWED_HOSTS = new Set(['images.partner.com', 'cdn.partner.com']);
async function safeFetch(rawUrl) {
const url = new URL(rawUrl); // throws on garbage - that's fine
// 1. Scheme. Everything except http/https is a category error here.
if (!['http:', 'https:'].includes(url.protocol)) throw new BlockedUrlError();
if (!ALLOWED_HOSTS.has(url.hostname)) throw new BlockedUrlError();
// 2. Resolve to an actual address. Now we're checking a destination
// rather than a piece of text.
const { address } = await lookup(url.hostname);
const ip = ipaddr.parse(address);
// 3. Reject anything that isn't a plain public address. This covers
// loopback, link-local (including the metadata range), RFC1918,
// carrier-grade NAT, and their IPv6 equivalents.
if (ip.range() !== 'unicast') throw new BlockedUrlError();
// 4. Connect to the address we just validated - not to the hostname.
// Re-resolving here is the DNS rebinding hole.
return fetch(`${url.protocol}//${address}${url.pathname}${url.search}`, {
headers: { Host: url.hostname }, // keeps TLS and vhosts working
redirect: 'manual', // 5. never auto-follow
signal: AbortSignal.timeout(5000),
});
}If you need redirects, handle them yourself: read the Location header, run the whole pipeline again on it, and cap the number of hops. Every hop is a fresh URL from an untrusted source, because that's exactly what it is.
The fix that survives your mistakes
Everything above is application code, and application code has bugs. The durable control is network-level:
Give the component that fetches user-supplied URLs its own egress path a dedicated subnet, an outbound proxy, or a separate service with no route to anything internal. Then a flaw in your validation reaches the public internet and stops.
Alongside that:
- Enforce IMDSv2 (or your cloud's equivalent) and set the metadata hop limit to 1. This alone would have blunted Capital One.
- Right-size the instance role. Assume something will reach it eventually.
- Require auth on internal services. "It's only reachable internally" was a reasonable assumption before you shipped a URL field.
- Log outbound requests from fetching components. Egress to an address nobody expected is one of the few detectable signals this class produces.
The pattern here should feel familiar by now: application-level checks catch the common case, and an architectural boundary catches the case you didn't think of.
Why it slips through review
SSRF is invisible in a diff because the vulnerable code is the feature. fetch(userUrl) isn't a mistake in the way that a missing ownership check is a mistake. It's a faithful implementation of the ticket.
It also crosses a team boundary. The developer knows what the code does; the platform team knows what the network allows. Neither one alone can see that a profile picture feature has a route to the metadata service. The vulnerability lives in the gap between two accurate mental models.
And it arrives by dependency. Plenty of SSRF ships inside a Markdown renderer, a thumbnailer, an HTTP client with redirects on by default, or an XML parser with entity resolution enabled. Nobody wrote fetch(); a library did, using settings from an era when internal networks were assumed friendly.
Library and framework notes
- Node —
fetchandaxiosfollow redirects by default. Setredirect: 'manual'ormaxRedirects: 0and drive them yourself.ipaddr.jshandles range classification properly. - Python —
requestsfollows redirects by default too (allow_redirects=False). Validate with the stdlibipaddressmodule;is_globalis the check you want. - Java —
HttpClientwithRedirect.NEVER. WatchURL.openConnection()in older code, and make sure XML parsers have external entities disabled. - PHP — disable
CURLOPT_FOLLOWLOCATION, setCURLOPT_PROTOCOLSto HTTP and HTTPS only, and never pass a user string tofile_get_contents. - Go — set
CheckRedirecton yourhttp.Client, or hookDialContextto validate the resolved address at connection time. That hook is the cleanest place to close the rebinding gap in any language that offers one. - Headless browsers — run them in an isolated network namespace. Trying to constrain what a rendering engine fetches from inside the page is a losing position.
The test that catches it
Assert that internal destinations are refused, across all the shapes:
describe('avatar URL fetching', () => {
const blocked = [
'http://127.0.0.1:6379/',
'http://169.254.169.254/latest/meta-data/',
'http://10.0.0.1/admin',
'http://[::1]:8080/',
'file:///etc/passwd',
'gopher://internal:70/',
];
blocked.forEach(url => {
it(`refuses ${url}`, async () => {
await expect(safeFetch(url)).rejects.toThrow(BlockedUrlError);
});
});
it('refuses a redirect that lands somewhere internal', async () => {
// public URL, 302 to an internal address - the check must run on hop two
await expect(safeFetch(redirectorTo('http://169.254.169.254/'))).rejects.toThrow();
});
it('refuses a hostname that resolves to a private address', async () => {
await expect(safeFetch('http://internal.test.example/')).rejects.toThrow();
});
});describe('avatar URL fetching', () => {
const blocked = [
'http://127.0.0.1:6379/',
'http://169.254.169.254/latest/meta-data/',
'http://10.0.0.1/admin',
'http://[::1]:8080/',
'file:///etc/passwd',
'gopher://internal:70/',
];
blocked.forEach(url => {
it(`refuses ${url}`, async () => {
await expect(safeFetch(url)).rejects.toThrow(BlockedUrlError);
});
});
it('refuses a redirect that lands somewhere internal', async () => {
// public URL, 302 to an internal address - the check must run on hop two
await expect(safeFetch(redirectorTo('http://169.254.169.254/'))).rejects.toThrow();
});
it('refuses a hostname that resolves to a private address', async () => {
await expect(safeFetch('http://internal.test.example/')).rejects.toThrow();
});
});The last two tests are the ones that fail on most implementations. Anyone can block a literal 127.0.0.1. The redirect hop and the DNS resolution are where real code breaks.
Ask this in code review
- Does this code make a request to an address influenced by user input — including indirectly, through a library?
- Is the destination checked after DNS resolution, or only as text?
- What happens on a redirect?
- If this fetch reached an internal service, what would it find there and what credentials would it be carrying?
Question 4 is the one worth asking loudest, because it's the one that decides whether this is a nuisance or a headline.
Takeaways
- SSRF isn't about the response. It's about your server's network position being loaned to a stranger.
- If a feature accepts a URL, it's an SSRF surface. Webhooks, previews, importers, PDF renderers, SSO config.
- Deny-lists lose structurally: too many address spellings, and DNS belongs to someone else.
- Validate the resolved IP, then connect to that IP. Checking the string is not checking the destination.
- Redirects are untrusted input. Handle them manually and re-validate every hop.
- Blind SSRF is still SSRF.
- The control that actually holds is network-level: give fetchers their own egress with no internal route.
- Metadata services and IAM roles turn a medium bug into a company-ending one. IMDSv2, hop limit 1, least privilege.
Search your codebase for fetch(, requests.get(, curl_exec, HttpClient, and new URL(. For each one, trace where the argument came from. Anything that reaches a request body or query parameter belongs behind the pipeline above.