SSRF in AI Tools: Why Fetching a User's URL Is a Security Decision
Any feature that fetches a URL a user typed can be pointed at your own network. How SSRF works, why redirects break most defences, and the code we published.
Server-Side Request Forgery (SSRF) is what happens when your server fetches a URL that someone else chose. The request leaves from inside your network, so it carries your network’s trust: your firewall lets it through, and your cloud provider’s metadata service answers it. A visitor who submits http://169.254.169.254/latest/meta-data/iam/security-credentials/ is not attacking your firewall — they are asking your own server to read its cloud credentials and hand them back.
The reason this belongs in an AI-tools discussion is that AI features fetch user-supplied URLs constantly. A site audit tool, a link preview, an avatar importer, a “summarise this page” agent, an RSS ingester, a webhook tester — each one takes an address from a stranger and requests it. SSRF is A10 in the OWASP Top 10, and it is the single most likely serious bug in a tool of this shape.
We hit this directly. Our free AI Website Audit tool accepts a URL from anyone on the internet and fetches it. This article is the reasoning we applied, and the code is published: safe-url-fetch on GitHub, MIT-licensed and dependency-free.
What an attacker is actually reaching for
Not your website. The interesting addresses are the ones only reachable from inside:
| Target | Address | What it gives up |
|---|---|---|
| Cloud instance metadata | 169.254.169.254 |
IAM role credentials, on AWS, GCP, Azure, and DigitalOcean |
| Loopback services | 127.0.0.1:6379, 127.0.0.1:8080 |
Redis with no password, an internal admin panel, a debug endpoint |
| Private network | 10.0.0.5:5432, 192.168.1.x |
Databases, staging environments, printers, router admin pages |
| Link-local and CGNAT | 169.254.0.0/16, 100.64.0.0/10 |
Container orchestration and infrastructure endpoints |
Cloud metadata is the highest-value target because it is unauthenticated by design — the instance is supposed to be able to read its own configuration. AWS addressed this class of attack with IMDSv2, which requires a PUT request to obtain a session token before the data can be read; that helps considerably, but it is a mitigation on one provider’s endpoint, not a fix for SSRF. Your Redis on 127.0.0.1 still answers.
The half that gets skipped
Checking the URL the user typed is the obvious step. Almost every implementation does it. Here is the step that gets missed:
https://totally-fine.example.com/page2. It is public, it resolves publicly, it passes every check you wrote
3. That host — which the attacker controls — answers:
302 Location: http://169.254.169.254/…4.
fetch() follows the redirect automatically5. Your validator never sees the second address
The default value of the redirect option in fetch() is follow. The runtime chases the Location header itself, inside the same call, and the request to the metadata endpoint is issued before any line of your code runs again. A validator that inspects only the submitted URL is defeated by a one-line HTTP response from a host the attacker rents for five dollars a month.
There is only one fix: handle redirects yourself, and re-run the full check on every hop.
// src/index.js — the reason the library exists
response = await transport(url, {
redirect: 'manual', // do not let the runtime chase Location for us
headers: { 'User-Agent': userAgent, ...headers },
signal: AbortSignal.timeout(timeoutMs),
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location');
if (!location) throw new UnsafeUrlError('…', 'invalid_redirect');
// Resolved against the current URL, then validated again from scratch.
url = await validatePublicUrl(new URL(location, url).toString());
continue;
}
Four rules worth copying
Resolve DNS before connecting, and reject on some, not every. A hostname is not an address. internal.attacker.example can have an A record pointing at 127.0.0.1 — perfectly legal DNS. So resolve the name first and check every address it returns. If a hostname resolves to both a public and a private address, reject it: which record the connection actually uses is not under your control.
Allow-list protocols; never deny-list them. file:///etc/passwd reads local disk. gopher:// can be shaped into arbitrary TCP payloads. dict:, blob:, and data: each bypass expectations in their own way. A deny-list is a list of the schemes you thought of. Permit http: and https:, refuse everything else.
Reject credentials in the URL. http://trusted.example.com@attacker.example/ reads as trusted.example.com to a human and resolves to attacker.example. There is no legitimate reason for a user-submitted audit URL to carry credentials.
Fail closed on anything you do not understand. If an address does not parse into four octets, treat it as private. Unparsed input is not evidence of safety. In our implementation this is what handles a hex-form IPv4-mapped IPv6 address such as ::ffff:7f00:1 — it fails the octet parse and is refused by the default, rather than needing its own branch.
One implementation detail that cost us a real bug, worth passing on: the WHATWG URL parser keeps the brackets on an IPv6 literal. For http://[::1]/, url.hostname is the string "[::1]", which net.isIP() does not recognise. Without stripping the brackets, every IPv6 literal falls through to the DNS branch. It still gets rejected — the lookup fails and the fail-closed default catches it — but for the wrong reason and with a misleading error code. We caught it in code review before publishing, and the test suite now asserts the correct rejection code.
What this class of defence does not solve
Being honest about the boundary matters more than the feature list.
DNS rebinding. Between your lookup and your connection, a domain with a one-second TTL can change its answer from a public address to 127.0.0.1. Closing that gap at the library layer means connecting to the already-validated IP and passing the hostname through the Host header and TLS SNI — a custom HTTP agent. If your threat model includes a determined attacker rather than an opportunistic one, enforce the allow-list at the socket layer with an egress proxy and treat URL validation as defence in depth.
A public host is not a trustworthy host. Validation establishes that a destination is on the public internet, nothing more. The response body is attacker-controlled text. Parsing it is a separate problem with its own failure modes — and if you feed that body to a model, prompt injection is now in scope.
Ports. Any port on a public address is reachable unless you add your own allow-list.
Where the code lives
We extracted the URL-fetching layer of our audit tool and published it: gitsection.com/followaipro/safe-url-fetch.
Zero dependencies, Node 18+, MIT. It exports fetchPublicUrl for the whole flow, validatePublicUrl for a verdict without a fetch (storing a webhook target, say), and isPrivateAddress for a synchronous range check. Every rejection is an UnsafeUrlError carrying a machine-readable .code, so you can branch on the reason without matching message strings.
The test suite runs 17 tests on the Node built-in runner with no network access. The redirect cases drive a scripted transport, which lets them assert something stronger than “an error was thrown”: that the request to the private address is never issued at all.
const transport = scriptedTransport([{ location: 'http://127.0.0.1:6379/' }]);
await rejects(fetchPublicUrl(`${PUBLIC_HOST}/`, { fetch: transport }), 'private_address');
assert.equal(transport.seen.length, 1, 'the private address must never be requested');
Read the code before you trust it. That is the point of publishing it.
What FollowAI can build
If you are adding a feature that fetches, scrapes, or previews an address a user supplies — or an AI agent that browses on a user’s behalf — this check belongs in it from the first commit, not after a report arrives.
We build these systems and operate them. Concretely, on this topic: the URL-intake layer with per-hop validation, egress control at the socket layer where the threat model needs it, timeouts and response caps so a hostile host cannot exhaust your workers, structured error codes your product can show a user without describing your internal network, and the test suite that proves the unsafe request is never issued. Where an AI agent consumes the fetched content, we separate the fetch decision from the model’s output, so the model can propose a URL but never authorise the request.
Related reading: AI Access Control: Identity, Permissions, and Approvals for Agents covers the same principle one layer up — the model proposes an action, a separate layer decides whether it is allowed. The OpenAI“Hugging Face agent incident is worth reading before you give any agent tools and network access.
Sources
- OWASP Top 10: A10:2021 Server-Side Request Forgery (SSRF)Primary source
- OWASP: Server Side Request Forgery Prevention Cheat SheetOfficial documentation
- AWS: Instance metadata and user data (IMDSv2)Official documentation
- MDN: fetch() redirect optionOfficial documentation
- FollowAI: safe-url-fetch source codePrimary source
Turn a specific business problem into working software without forcing it into a generic template
FollowAI can design, code, connect, launch, and operate the complete custom development system around your workflow.
