A tool that takes a URL and returns the page is one of the most common additions to an AI agent. It is also a server-side request whose destination is chosen by a model, and the model reads content that someone else wrote: web pages, support tickets, documents, e-mail. When that content says "fetch this address next", the request leaves from the server's network position, not from the author's.
The question for a review is not "can the model be made to behave". It is "what can this tool reach if it is asked to reach anything".
Where the risk is classified
The OWASP Top 10 for LLM Applications (2025) describes this as LLM06:2025 Excessive Agency, with three root causes: excessive functionality (the system exposes tools it does not need), excessive permissions (the tool can touch more than its task requires) and excessive autonomy (high-impact actions happen without independent approval). Among its prevention measures: avoid open-ended tools in favour of narrow, purpose-built ones, grant downstream access only at the level needed, and enforce authorization in the downstream system rather than relying on the model's judgment ("complete mediation").
The same page notes that logging, monitoring and rate limiting do not prevent excessive agency; they only limit the damage. The controls below are of the preventing kind.
Two cases, two sets of controls
The OWASP SSRF Prevention Cheat Sheet separates two situations, and the right control set depends on which one applies.
Case 1: the tool only needs a few known destinations. The cheat sheet's guidance is to match the host against an allow-list and build the request in code, instead of passing the model's string to an HTTP client. Most "look up this order", "read this ticket" or "call this internal API" tools belong here, and for them the best design is not a URL parameter at all but an identifier that the server turns into a request.
Case 2: the tool must be able to fetch arbitrary public pages. The cheat sheet states that allow-lists cannot be used here because the list of destinations is not known in advance. Control then moves to the network and to what is checked at connection time. It also warns that deny-lists are bypass-prone and a last resort, and that regular expressions over complex formats are hard to maintain and error-prone.
Five controls for Case 2
- Check the address the socket will use, not the string that was submitted. The cheat sheet notes that domain allow-listing alone does not prevent DNS rebinding, and that checking DNS answers separately does not either. The check has to happen on the resolved address at connection time.
- Do not follow redirects automatically. The cheat sheet recommends disabling redirect-following in the web client. If redirects are needed, each hop is a new request that goes through every check again.
- Block non-routable ranges at the network layer too. Application checks can have gaps (one is described below). Egress rules for the process that runs the tool are the second layer.
-
Make the cloud metadata service hard to reach. On AWS, IMDSv2 uses session tokens obtained with a
PUTrequest; aPUTcarrying anX-Forwarded-Forheader is rejected, and the response hop limit defaults to 1. The instance can be configured to accept only IMDSv2, which makes IMDSv1 calls fail. The OWASP cheat sheet's advice is to migrate to IMDSv2 and disable IMDSv1. AWS describes the design goal in the title of its security blog post: "Add defense in depth against open firewalls, reverse proxies, and SSRF vulnerabilities with enhancements to the EC2 Instance Metadata Service". - Give the tool the smallest possible identity. Run it as a separate process with no cloud role, no internal network route and no credentials in its environment, so that a successful request has nothing valuable to return.
A guard in Node.js, and the gap found while testing it
The following uses only built-in modules. The check lives in a custom lookup function, so it runs on the address the connection will use.
import dns from 'node:dns';
import https from 'node:https';
import net from 'node:net';
// Address ranges a public fetch tool must never reach.
const blocked = new net.BlockList();
for (const [addr, bits] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16]]) blocked.addSubnet(addr, bits, 'ipv4');
for (const [addr, bits] of [['::1', 128], ['fc00::', 7], ['fe80::', 10], ['::', 128]]) blocked.addSubnet(addr, bits, 'ipv6');
function guardedLookup(hostname, options, callback) {
dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
if (err) return callback(err);
const bad = addresses.find(a => blocked.check(a.address, a.family === 6 ? 'ipv6' : 'ipv4'));
if (bad) return callback(new Error(`blocked address ${bad.address}`));
if (options.all) return callback(null, addresses);
callback(null, addresses[0].address, addresses[0].family);
});
}
export function fetchPublic(rawUrl, allowedHosts) {
const url = new URL(rawUrl);
if (url.protocol !== 'https:') throw new Error('https only');
if (!allowedHosts.includes(url.hostname)) throw new Error('host not on the allow-list');
// Node does not call `lookup` for an IP literal, so the guard above would not run for one.
if (net.isIP(url.hostname.replace(/^\[|\]$/g, ''))) throw new Error('IP literals are not accepted');
return new Promise((resolve, reject) => {
const req = https.get(url, { lookup: guardedLookup, timeout: 5000 }, res => {
if (res.statusCode >= 300 && res.statusCode < 400) { res.resume(); return reject(new Error('redirect not followed')); }
const chunks = []; let size = 0;
res.on('data', c => { size += c.length; if (size > 1_000_000) req.destroy(new Error('response too large')); else chunks.push(c); });
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
});
req.on('timeout', () => req.destroy(new Error('timeout')));
req.on('error', reject);
});
}
The line about IP literals is there because of a gap in the first version of this example. In the first test run, a request to an address given as a literal IP reached a local test server, while the same request by hostname was rejected: the custom lookup is never called when the host is already an address. After the fix, every notation that the URL parser normalises to an IP (127.1, 0x7f000001, 2130706433, [::1]) is rejected by the same line. The practical lesson: a guard has to be tested with the inputs it is meant to stop, not only with the input it was designed around.
Two limits are worth stating. A response size cap and a timeout are included because an open fetch tool is also a way to make the server spend resources. And the example does not make the open-fetch case safe on its own; the other four controls, and the model not being the only thing standing between a page and an internal service, still apply.
What was tested
The code above was run against a local HTTP server and a stubbed client before publication:
- the address ranges: loopback, private, link-local, carrier-grade NAT and unique-local ranges were blocked, and two public addresses were not
- a hostname that resolves to loopback (
localhost) was rejected at connection time - IP literals in several notations were rejected
- a non-HTTPS URL and a host outside the allow-list were rejected
- a 3xx response was refused (tested with a stubbed client, so the redirect rule is checked in isolation)
Not tested: a real DNS-rebinding setup, an IPv6-only network, and behaviour behind a corporate proxy. Those need to be checked in the environment where the tool runs.
A test list for your own agent
Each row is a request an authorised tester makes through the agent's own fetch tool, in an environment they own, with the expected result.
| The tool is asked to fetch | Expected |
|---|---|
| A normal public page on an allowed host | Content returned |
| A loopback address | Rejected |
| A private-network address | Rejected |
| The cloud metadata address | Rejected |
| A hostname that resolves to a private address | Rejected |
| An address in a non-standard notation | Rejected |
| A public URL that answers with a redirect | Redirect not followed |
A plain http:// URL |
Rejected |
| A very large response | Truncated or rejected |
If any row returns content, the tool can reach more than its description says, whatever the system prompt says.
Practise the review
Breachloom has a chapter that frames the problem from the agent's side, A fetch tool lends the assistant the server's network position, a hands-on exercise, Agent metadata fetch, and a general SSRF checklist, SSRF Explained. For the authorization side of agent tools, there is AI Agent Security: A Safer Prompt Cannot Replace Tool Authorization. The chapters and the free IDOR case need no account; the full set of challenges is part of a paid plan.
Which tool in your own agent takes a URL, and what network can its process reach today? Answering that from the deployment, not from the code, is usually the quickest way to find out.
Sources
- OWASP Top 10 for LLM Applications 2025, LLM06:2025 Excessive Agency. https://genai.owasp.org/llmrisk/llm062025-excessive-agency/
- OWASP Server-Side Request Forgery Prevention Cheat Sheet (sections: Cases, Application layer, Case 2, IMDSv2 in AWS, Deny-list). https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
- AWS documentation, "Use the Instance Metadata Service to access instance metadata" (IMDSv2 session tokens,
PUTwithX-Forwarded-Forrejected, default hop limit of 1, IMDSv2-only option). https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html - AWS Security Blog, "Add defense in depth against open firewalls, reverse proxies, and SSRF vulnerabilities with enhancements to the EC2 Instance Metadata Service". https://aws.amazon.com/blogs/security/defense-in-depth-open-firewalls-reverse-proxies-ssrf-vulnerabilities-ec2-instance-metadata-service/
- Node.js documentation:
net.BlockList,dns.lookup, and thelookupoption ofhttps.request.
Top comments (6)
The IP-literal gap is the part that would have caught me out. I used to think validating the hostname string was the whole job, until I learned Node never calls a custom lookup for an IP literal at all, so a guard built only on lookup silently never runs. Your rule — check the address the socket will actually use, and treat every redirect hop as a new request — is the part I now steal for any tool that fetches a URL a model chose. Where do you draw the line on the tool's identity: a separate process with no credentials, or is network egress enough on its own?
Both, in a defined order. For a tool that can fetch any public URL, egress filtering at the network layer is the stronger first control: it blocks the request itself, whatever bug the application code has, and a default-deny policy with an allow-list is easy to audit.
The separate process with no credentials is the second control, and it covers what egress cannot. A process that holds secrets can still send them to any destination the egress policy allows; one that holds none has little to leak even when a request succeeds. It also keeps a successful request from reaching internal data through the process's own permissions.
The cloud metadata address deserves its own line (IMDSv2 required, hop limit of 1), because that traffic can sit outside the egress rules written for ordinary connections. If only one of the two is affordable: egress deny-by-default first, identity second.
egress first, identity second. the metadata line is the one people forget.
Agreed. Cloud metadata endpoints are the classic case: blocked by default at the network layer, and the tool process should not hold the role credentials in the first place.
The useful reframing here is asking what the fetch tool can reach if told to reach anything, instead of hoping the model behaves. Your IP literal gap is a good catch, since a custom lookup never runs for an address that is already numeric. One thing I would test next is connection reuse: if the agent keeps an HTTP agent with keep-alive sockets, a later call to an allowed host may reuse a connection and skip the lookup check entirely. Did you try that with your local test server?
Good question, and it was not covered in the article, so it was measured after reading the comment. With a keep-alive agent, three sequential requests to the same host produced one
lookupcall and one socket; with keep-alive off, three lookups and three sockets. So the lookup runs once per connection, not once per request.In this design that is not a bypass: the reused socket still points at the address that passed the check, and the per-request checks (scheme, host allow-list, refusing redirects) run before the request, outside
lookup. A new socket always goes throughlookupagain.What would break the guarantee: a policy that lives only inside
lookup, an agent shared between callers with different trust levels, and anything behind an HTTP proxy, where the proxy resolves the name andlookupnever sees the real target. Thanks for pushing on it.