SSRF: your AI agent's URL tool can reach your internal network (Node)
If your agent has a tool that takes a URL, the model chooses where your server connects. Measured end to end on Node 24 with no dependencies and no API key: the guard most people write fails four ways, including the IPv4-mapped IPv6 bypass behind CVE-2026-56266, which also defeated the first version of my own hardened guard.
Updated on October 11, 2026
On this page
Quick Answer (October 2026). If your agent has a tool that takes a URL, that tool is an SSRF primitive: the model chooses the address and your server makes the request. The guard most people write checks u.hostname against a list of strings, and this article measures it failing four ways on one machine. It allows http://[::1]/ because the WHATWG URL parser keeps the square brackets, so a check for '::1' never matches. It allows the cloud metadata address, which is neither loopback nor RFC1918. It allows any public hostname whose DNS answer points at loopback. And it blocks http://172.32.0.1/, which is a perfectly ordinary public address. The fix is not a better string check. You have to resolve the name yourself, classify every address the resolver returns, and then connect to the address you vetted rather than to the name. Everything below runs on plain Node with no dependencies and no API key.
This is the third tool class in a short series. The first was an agent's file tools, where the attacker controls a path. The second was an allowlisted shell tool, where the attacker controls an argument. This one is the network leg, where the attacker controls a destination.
The tool everyone builds first
An agent that can read a web page is immediately more useful, so a fetch_url tool shows up early. The handler is three lines and the model supplies the URL. That last part is the whole problem: the URL is attacker-influenced input. Anyone who can get text in front of your model, through a prompt, a retrieved document, an email or a scraped page, can propose a destination, and your server is the one that connects.
The threat has a name and a number, CWE-918, and it is not theoretical in this product category. VulnCheck's advisory for CVE-2026-56266 describes Crawl4AI before 0.8.7 and says, verbatim:
VulnCheck, on CVE-2026-56266: "Unauthenticated attackers can bypass the internal-address blocklist using IPv6-mapped IPv4 addresses to reach internal services and cloud metadata endpoints."
Hold on to that sentence. It describes a product that had a blocklist and shipped anyway, and by the end of this article my own first guard will fail in exactly that way.
The guard almost everyone ships
'use strict';
// The guard almost everyone ships: string checks on the hostname.
function naiveGuard(input) {
let u;
try { u = new URL(input); } catch (e) { return 'blocked: unparseable'; }
if (!['http:', 'https:'].includes(u.protocol)) { return 'blocked: scheme'; }
const h = u.hostname.toLowerCase();
if (['localhost', '127.0.0.1', '0.0.0.0', '::1'].includes(h)) { return 'blocked: host'; }
if (h.startsWith('10.') || h.startsWith('192.168.') || h.startsWith('172.')) { return 'blocked: private'; }
return 'ALLOWED';
}
module.exports = { naiveGuard };
It reads as thorough. It has a scheme check, a loopback list that remembers IPv6, and a private-range check. Let us give it a workspace it can be attacked in, then run real URLs through it.
Something you can actually attack
Two servers on loopback. T stands in for an internal service nobody outside should reach, and it counts every hit, which is what turns "the guard said no" into "no packet arrived". R is reachable under a public-looking hostname and redirects into T.
'use strict';
// Two loopback servers standing in for things an agent must never reach.
// T is the "internal service" and counts every hit it receives.
// R is reachable under a public hostname and redirects into T.
const http = require('node:http');
const fs = require('node:fs');
const PORT_T = Number(process.env.AN_SSRF_PORT_T);
const PORT_R = Number(process.env.AN_SSRF_PORT_R);
const COUNTER = process.env.AN_SSRF_COUNTER;
fs.writeFileSync(COUNTER, '0');
const t = http.createServer(function (req, res) {
const n = Number(fs.readFileSync(COUNTER, 'utf8')) + 1;
fs.writeFileSync(COUNTER, String(n));
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('INTERNAL SERVICE REACHED, hit number ' + n + '\n');
});
const r = http.createServer(function (req, res) {
res.writeHead(302, { location: 'http://127.0.0.1:' + PORT_T + '/internal' });
res.end();
});
t.listen(PORT_T, '127.0.0.1', function () {
r.listen(PORT_R, '127.0.0.1', function () {
fs.writeSync(1, 'servers-ready T=' + PORT_T + ' R=' + PORT_R + ' marker=an1011-ssrf-v1\n');
});
});
The probe
Seventeen URLs through both guards, then four end-to-end arms and a classifier check. Arm A7 is the control that matters: a real public host, through a real redirect, has to keep working, or a guard that blocks everything would score perfectly.
'use strict';
const fs = require('node:fs');
const { naiveGuard } = require('./naive.js');
const { vet, safeRequest, classify } = require('./guard.js');
const PORT_T = process.env.AN_SSRF_PORT_T;
const PORT_R = process.env.AN_SSRF_PORT_R;
const COUNTER = process.env.AN_SSRF_COUNTER;
const out = [];
function say(s) { out.push(s); }
function hits() { return Number(fs.readFileSync(COUNTER, 'utf8')); }
const CASES = [
['http://example.com/', 'public, must stay allowed'],
['file:///etc/passwd', 'not an http scheme'],
['http://localhost/', 'obvious loopback'],
['http://127.0.0.1/', 'obvious loopback'],
['http://127.1/', 'short-form loopback'],
['http://2130706433/', 'decimal loopback'],
['http://0177.0.0.1/', 'octal loopback'],
['http://[::1]/', 'IPv6 loopback'],
['http://169.254.169.254/latest/meta-data/', 'cloud metadata'],
['http://10.0.0.1/', 'RFC1918'],
['http://192.168.1.1/', 'RFC1918'],
['http://172.16.0.1/', 'RFC1918'],
['http://172.32.0.1/', 'PUBLIC, must stay allowed'],
['http://localtest.me/', 'public name, loopback answer'],
['http://[::ffff:127.0.0.1]/', 'v4-mapped loopback, CVE-2026-56266 shape'],
['http://[::ffff:169.254.169.254]/', 'v4-mapped metadata'],
['http://[::ffff:a9fe:a9fe]/', 'v4-mapped metadata, hex form']
];
async function main() {
say('=== A1/A2 the two guards over the same fourteen inputs ===');
say('url'.padEnd(42) + 'naive'.padEnd(9) + 'hardened'.padEnd(10) + 'note');
for (const pair of CASES) {
const n = naiveGuard(pair[0]) === 'ALLOWED' ? 'ALLOW' : 'deny';
const v = await vet(pair[0]);
const h = v.ok ? 'ALLOW' : 'deny';
const flag = n === h ? '' : ' ** differ';
say(pair[0].padEnd(42) + n.padEnd(9) + h.padEnd(10) + pair[1] + flag);
}
say('');
say('=== A3 naive guard, then the socket: where does the packet land ===');
const direct = 'http://localtest.me:' + PORT_T + '/internal';
say('url ' + direct);
say('naive guard verdict ' + naiveGuard(direct));
say('internal hits before ' + hits());
const r3 = await fetch(direct);
say('fetch status ' + r3.status);
say('fetch body ' + (await r3.text()).trim());
say('internal hits after ' + hits());
say('');
say('=== A4 same url through the hardened guard (hits must not move) ===');
const before4 = hits();
const r4 = await safeRequest(direct);
say('ok ' + r4.ok);
for (const h of r4.hops) { say('hop ' + h.verdict + ' ' + h.url + ' ' + h.detail); }
say('internal hits before ' + before4);
say('internal hits after ' + hits());
say('');
say('=== A5 redirect: first url passes the naive guard, second does not exist to it ===');
const viaRedirect = 'http://localtest.me:' + PORT_R + '/start';
say('url ' + viaRedirect);
say('naive guard verdict ' + naiveGuard(viaRedirect));
const before5 = hits();
const r5 = await fetch(viaRedirect);
say('fetch status ' + r5.status);
say('fetch final url ' + r5.url);
say('fetch body ' + (await r5.text()).trim());
say('internal hits before ' + before5);
say('internal hits after ' + hits());
say('');
say('=== A6 same redirect through the hardened guard ===');
const before6 = hits();
const r6 = await safeRequest(viaRedirect);
say('ok ' + r6.ok);
for (const h of r6.hops) { say('hop ' + h.verdict + ' ' + h.url + ' ' + h.detail); }
say('internal hits before ' + before6);
say('internal hits after ' + hits());
say('');
say('=== A7 CONTROL: a real public host that redirects must still work ===');
const r7 = await safeRequest('http://github.com/');
say('ok ' + r7.ok);
say('status ' + r7.status);
say('pinned address ' + r7.pinned);
for (const h of r7.hops) { say('hop ' + h.verdict + ' ' + h.url + ' ' + h.detail); }
say('');
say('=== A8 classifier spot checks ===');
for (const a of ['8.8.8.8', '127.0.0.1', '10.1.2.3', '172.16.5.5', '172.32.5.5', '169.254.169.254', '100.64.0.1', '::1', '::', '::ffff:127.0.0.1', '::ffff:7f00:1', '::ffff:a9fe:a9fe', 'fd00::1', 'fe80::1', '2606:4700::1', '2001:db8::1']) {
say(a.padEnd(22) + classify(a));
}
say('');
say('total internal hits ' + hits());
fs.writeSync(1, out.join('\n') + '\n');
}
main().catch(function (e) { fs.writeSync(1, out.join('\n') + '\nPROBE ERROR ' + e.message + '\n'); process.exitCode = 1; });
Run it, using an environment variable name of your own rather than PORT, which on many hosts is already taken by the app itself:
export AN_SSRF_PORT_T=18731 AN_SSRF_PORT_R=18732 AN_SSRF_COUNTER=/tmp/hits.txt
node servers.js &
node probe.js
What it prints
=== A1/A2 the two guards over the same fourteen inputs ===
url naive hardened note
http://example.com/ ALLOW ALLOW public, must stay allowed
file:///etc/passwd deny deny not an http scheme
http://localhost/ deny deny obvious loopback
http://127.0.0.1/ deny deny obvious loopback
http://127.1/ deny deny short-form loopback
http://2130706433/ deny deny decimal loopback
http://0177.0.0.1/ deny deny octal loopback
http://[::1]/ ALLOW deny IPv6 loopback ** differ
http://169.254.169.254/latest/meta-data/ ALLOW deny cloud metadata ** differ
http://10.0.0.1/ deny deny RFC1918
http://192.168.1.1/ deny deny RFC1918
http://172.16.0.1/ deny deny RFC1918
http://172.32.0.1/ deny ALLOW PUBLIC, must stay allowed ** differ
http://localtest.me/ ALLOW deny public name, loopback answer ** differ
http://[::ffff:127.0.0.1]/ ALLOW deny v4-mapped loopback, CVE-2026-56266 shape ** differ
http://[::ffff:169.254.169.254]/ ALLOW deny v4-mapped metadata ** differ
http://[::ffff:a9fe:a9fe]/ ALLOW deny v4-mapped metadata, hex form ** differ
=== A3 naive guard, then the socket: where does the packet land ===
url http://localtest.me:18731/internal
naive guard verdict ALLOWED
internal hits before 0
fetch status 200
fetch body INTERNAL SERVICE REACHED, hit number 1
internal hits after 1
=== A4 same url through the hardened guard (hits must not move) ===
ok false
hop DENY http://localtest.me:18731/internal blocked ::1=loopback,127.0.0.1=loopback
internal hits before 1
internal hits after 1
=== A5 redirect: first url passes the naive guard, second does not exist to it ===
url http://localtest.me:18732/start
naive guard verdict ALLOWED
fetch status 200
fetch final url http://127.0.0.1:18731/internal
fetch body INTERNAL SERVICE REACHED, hit number 2
internal hits before 1
internal hits after 2
=== A6 same redirect through the hardened guard ===
ok false
hop DENY http://localtest.me:18732/start blocked ::1=loopback,127.0.0.1=loopback
internal hits before 2
internal hits after 2
=== A7 CONTROL: a real public host that redirects must still work ===
ok true
status 200
pinned address 140.82.121.3
hop allow http://github.com/ public 140.82.121.3=public
hop allow https://github.com/ public 140.82.121.3=public
=== A8 classifier spot checks ===
8.8.8.8 public
127.0.0.1 loopback
10.1.2.3 private
172.16.5.5 private
172.32.5.5 public
169.254.169.254 link-local
100.64.0.1 cgnat
::1 loopback
:: unspecified
::ffff:127.0.0.1 loopback/v4-mapped
::ffff:7f00:1 loopback/v4-mapped
::ffff:a9fe:a9fe link-local/v4-mapped
fd00::1 unique-local
fe80::1 link-local
2606:4700::1 public
2001:db8::1 documentation
total internal hits 2
Reading the arms
The three bypasses every SSRF article warns you about are already dead, and not because the guard is good. http://127.1/, http://2130706433/ and http://0177.0.0.1/ are the short-form, decimal and octal spellings of loopback, and the naive guard blocks all three. It did not earn that. Node's WHATWG URL parser normalises each of them to 127.0.0.1 before any of your code runs, which you can see for yourself by printing new URL('http://2130706433/').hostname. Most of the encoding tricks in the generic SSRF literature are handled by the parser in this runtime. Budget your attention elsewhere.
http://[::1]/ is allowed, and the reason is a detail of the URL standard. u.hostname for an IPv6 literal keeps the square brackets, so its value is the five characters [::1] and not ::1. The developer who wrote that banned list believed they had covered IPv6 loopback. They had covered a string that never occurs. Strip the brackets before you compare anything.
The cloud metadata address is allowed because it is in none of the ranges people memorise. 169.254.169.254 is link-local, not loopback and not RFC1918, and on a cloud instance it is the single most valuable thing an SSRF can reach.
http://localtest.me/ is allowed, and this is the one that is not a missing entry in a list. That is a real public hostname whose public DNS answer is loopback. The guard inspected a string. The socket went somewhere else. Arm A3 is the proof and it is end to end: the guard returns ALLOWED, and then the internal service's hit counter moves. No list of hostnames can fix this, because the hostname was never the thing that mattered.
And it blocks a public address. 172.32.0.1 is not private. Only 172.16.0.0 through 172.31.255.255 is, and h.startsWith('172.') catches the whole /8. The guard is wrong in both directions at once, which is the ordinary result of doing range arithmetic with string prefixes.
Arm A5 adds the redirect. The first URL passes, fetch follows the 302 by default, and the final URL in the output is the internal service. A guard that runs once, on the URL the model handed you, has checked the one hop the attacker was willing to let you check.
The bypass that got past my own guard
I wrote the hardened guard before I read the CVE closely. Then I added the CVE's own shape to the case list, and my guard allowed it.
Node's URL parser does not keep ::ffff:127.0.0.1 in the dotted form you typed. It re-compresses the embedded IPv4 into hex:
http://[::ffff:127.0.0.1]/ hostname="[::ffff:7f00:1]"
http://[::ffff:169.254.169.254]/ hostname="[::ffff:a9fe:a9fe]"
My first classifier looked for an embedded IPv4 with a regular expression anchored on dotted-quad digits. That expression cannot match ::ffff:7f00:1, so the address fell through to the default and was classified public. Loopback and the metadata endpoint both sailed through a guard I had written specifically to stop them.
That is CVE-2026-56266 reproduced from scratch by someone who had just finished reading the advisory. It is the strongest argument I can make for the thesis of this article: do not hand-roll address classification from intuition, and do not trust a guard you have not run the known bypass set against. The fix is to stop pattern-matching the text and expand the address to its eight groups first, which is what expandV6 below does.
The guard that holds
'use strict';
const dns = require('node:dns').promises;
const http = require('node:http');
const https = require('node:https');
// lo and hi inclusive. Written with Math on purpose: this file contains no angle brackets.
function inRange(n, lo, hi) { return Math.min(Math.max(n, lo), hi) === n; }
function classifyV4(addr) {
const p = addr.split('.');
if (p.length !== 4) { return 'malformed'; }
const o = p.map(Number);
if (o.some(function (n) { return Number.isNaN(n) || !inRange(n, 0, 255); })) { return 'malformed'; }
const a = o[0], b = o[1], c = o[2];
if (a === 0) { return 'this-network'; }
if (a === 10) { return 'private'; }
if (a === 127) { return 'loopback'; }
if (a === 100 && inRange(b, 64, 127)) { return 'cgnat'; }
if (a === 169 && b === 254) { return 'link-local'; }
if (a === 172 && inRange(b, 16, 31)) { return 'private'; }
if (a === 192 && b === 168) { return 'private'; }
if (a === 192 && b === 0 && inRange(c, 0, 2)) { return 'reserved'; }
if (a === 198 && inRange(b, 18, 19)) { return 'benchmark'; }
if (a === 198 && b === 51 && c === 100) { return 'documentation'; }
if (a === 203 && b === 0 && c === 113) { return 'documentation'; }
if (inRange(a, 224, 239)) { return 'multicast'; }
if (inRange(a, 240, 255)) { return 'reserved'; }
return 'public';
}
// Expand any IPv6 text form to eight 16-bit groups. This is the step a
// dotted-quad regex skips, and skipping it is how the embedded IPv4 escapes.
function expandV6(input) {
let str = input.toLowerCase().split('%')[0];
const dotted = str.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (dotted) {
const o = dotted[2].split('.').map(Number);
if (o.some(function (n) { return Number.isNaN(n) || !inRange(n, 0, 255); })) { return null; }
str = dotted[1] + ((o[0] * 256) + o[1]).toString(16) + ':' + ((o[2] * 256) + o[3]).toString(16);
}
const halves = str.split('::');
if (halves.length !== Math.min(halves.length, 2)) { return null; }
function groups(part) { return part === '' ? [] : part.split(':'); }
const head = groups(halves[0]);
const tail = halves.length === 2 ? groups(halves[1]) : [];
if (halves.length === 1 && head.length !== 8) { return null; }
const fill = 8 - head.length - tail.length;
if (fill !== Math.max(fill, 0)) { return null; }
const mid = halves.length === 2 ? new Array(fill).fill('0') : [];
const all = head.concat(mid, tail);
if (all.length !== 8) { return null; }
const nums = all.map(function (g) { return parseInt(g === '' ? '0' : g, 16); });
if (nums.some(function (n) { return Number.isNaN(n) || !inRange(n, 0, 65535); })) { return null; }
return nums;
}
function embeddedV4(g) {
return [Math.floor(g[6] / 256), g[6] % 256, Math.floor(g[7] / 256), g[7] % 256].join('.');
}
function classifyV6(addr) {
const g = expandV6(addr);
if (g === null) { return 'malformed'; }
const topFiveZero = g.slice(0, 5).every(function (n) { return n === 0; });
if (topFiveZero && g[5] === 65535) { return classifyV4(embeddedV4(g)) + '/v4-mapped'; }
if (g.every(function (n) { return n === 0; })) { return 'unspecified'; }
if (topFiveZero && g[5] === 0 && g[6] === 0 && g[7] === 1) { return 'loopback'; }
if (topFiveZero && g[5] === 0) { return classifyV4(embeddedV4(g)) + '/v4-compatible'; }
const top = g[0];
if (inRange(top, 65152, 65215)) { return 'link-local'; }
if (inRange(top, 64512, 65023)) { return 'unique-local'; }
if (inRange(top, 65280, 65535)) { return 'multicast'; }
if (top === 8193 && g[1] === 3512) { return 'documentation'; }
return 'public';
}
function classify(addr) { return addr.includes(':') ? classifyV6(addr) : classifyV4(addr); }
// The WHATWG URL parser keeps the square brackets on an IPv6 hostname.
function bareHost(hostname) {
if (hostname.startsWith('[') && hostname.endsWith(']')) { return hostname.slice(1, -1); }
return hostname;
}
function isLiteralIp(h) {
if (h.includes(':')) { return true; }
const p = h.split('.');
if (p.length !== 4) { return false; }
return p.every(function (s) { return /^\d+$/.test(s); });
}
// Resolve the name ourselves and vet EVERY answer, not just the first.
async function vet(input) {
let u;
try { u = new URL(input); } catch (e) { return { ok: false, why: 'unparseable' }; }
if (!['http:', 'https:'].includes(u.protocol)) { return { ok: false, why: 'scheme ' + u.protocol }; }
const host = bareHost(u.hostname);
let answers;
if (isLiteralIp(host)) {
answers = [{ address: host, family: host.includes(':') ? 6 : 4 }];
} else {
try { answers = await dns.lookup(host, { all: true }); }
catch (e) { return { ok: false, why: 'dns ' + e.code }; }
}
const seen = answers.map(function (a) { return a.address + '=' + classify(a.address); });
const bad = answers.filter(function (a) { return classify(a.address) !== 'public'; });
if (bad.length !== 0) { return { ok: false, why: 'blocked ' + seen.join(','), u: u, answers: answers }; }
return { ok: true, why: 'public ' + seen.join(','), u: u, answers: answers };
}
// Connect to the vetted address, carry the original Host, re-vet every hop.
async function safeRequest(input, maxHops) {
const hops = [];
let current = input;
const limit = maxHops === undefined ? 4 : maxHops;
for (let i = 0; i !== limit; i += 1) {
const v = await vet(current);
hops.push({ url: current, verdict: v.ok ? 'allow' : 'DENY', detail: v.why });
if (!v.ok) { return { ok: false, hops: hops }; }
const pinned = v.answers[0].address;
const secure = v.u.protocol === 'https:';
const lib = secure ? https : http;
const res = await new Promise(function (resolve, reject) {
const req = lib.request({
host: pinned,
servername: secure ? bareHost(v.u.hostname) : undefined,
port: v.u.port === '' ? (secure ? 443 : 80) : v.u.port,
path: v.u.pathname + v.u.search,
headers: { Host: v.u.host },
timeout: 5000
}, resolve);
req.on('timeout', function () { req.destroy(new Error('ETIMEDOUT')); });
req.on('error', reject);
req.end();
});
res.resume();
const loc = res.headers.location;
if (loc !== undefined && [301, 302, 303, 307, 308].includes(res.statusCode)) {
current = new URL(loc, current).toString();
continue;
}
return { ok: true, status: res.statusCode, pinned: pinned, hops: hops };
}
return { ok: false, hops: hops, why: 'too many hops' };
}
module.exports = { inRange, classify, classifyV4, classifyV6, expandV6, bareHost, vet, safeRequest };
Four things in there are load-bearing.
bareHost strips the brackets before any comparison, which closes the [::1] case.
expandV6 normalises before classifying. Every IPv6 form reaches the same eight numbers, so the IPv4-mapped prefix is detected by checking that the first five groups are zero and the sixth is 65535, rather than by hoping the text is dotted. That one change is the difference between my first guard and a guard.
vet resolves the hostname itself and rejects if any answer is non-public. Not the first answer, every answer. localtest.me returns both ::1 and 127.0.0.1, and a resolver is free to put the harmless one first.
safeRequest connects to the vetted address and re-vets every hop. host is the IP, the original name travels in the Host header, and for TLS it travels again as servername so certificate verification still works against the name.
Why validating the URL was never enough
This last part is not my idea and I do not want to present it as one. The OWASP SSRF Prevention Cheat Sheet prescribes it directly. Quoting it, with its inline code formatting removed:
OWASP, on why a name check is insufficient: "Domain allowlisting alone does not prevent DNS rebinding. Validate the resolved destination IP addresses against the application's permitted networks, then ensure the HTTP client connects only to validated addresses."
OWASP, on the gap between checking and connecting: "A second, unchecked DNS lookup between validation and connection can bypass these checks; see GitLab's URL blocker guidance."
OWASP, on what the client must do: "Use an HTTP client mechanism that connects to the validated IP while preserving the original hostname for the HTTP Host header, TLS Server Name Indication (SNI), and certificate verification, as illustrated by curl's custom address resolution."
That is the whole reason safeRequest uses node:http with an IP in host instead of calling fetch with the URL. If you resolve a name, approve it, and then hand the name to your HTTP client, the client resolves it again. Between your lookup and its lookup there is a window, and a DNS answer that changes inside that window is a published attack, not a hypothetical one. Passing the vetted address closes the window because there is no second lookup to poison.
OWASP also says, in the same section, "Apply the destination policy to retries and fallback connections as well." Redirects are the case that bites agents hardest, because a page the model found can redirect, and the cheat sheet's own advice elsewhere is to disable redirect support entirely. If you need to follow them, the loop in safeRequest is the alternative: treat each hop as a fresh URL and run the full vet again before the next connection.
Three things this guard does not do
It does not stop an agent exfiltrating through an allowed host. Everything here is about which destinations are reachable. A tool that can reach any public URL can put data in a query string. That is a different control, and usually a narrow destination allowlist rather than a blocklist of bad ranges.
It does not survive a split-horizon or internal DNS setup unchanged. The classifier knows the standard special-use ranges. If your corporate network reaches sensitive services on ordinary public addresses, the ranges are not the right boundary and you want an explicit allowlist.
It does not bound the response. Nothing above limits body size, content type or time beyond a socket timeout, and an agent that reads a very large response into context has a different and cheaper problem.
I also did not measure IPv6-only hosts end to end, and arm A6 denies at the first hop, so the per-hop re-vetting is demonstrated by the two allowed hops in the A7 control rather than by a blocked second hop. Both are stated so you can see where the evidence stops. The one value in the transcript that will differ on your machine is the pinned address in A7, because github.com answers with a rotating set of addresses.
Prior art, and what is new here
SSRF is a well-documented vulnerability class and nothing in the threat model is novel. The OWASP cheat sheet above is the reference, and the IPv4-mapped bypass is in a CVE. What I have not found written down is the agent-tool version: the measurement that the hostname you validate and the socket you open are different things, run end to end with a hit counter; the Node-specific detail that the URL parser silently closes the decimal and octal cases while silently re-writing the IPv4-mapped case into a form a dotted-quad check cannot see; and a guard you can paste into a tool handler and verify on your own machine in about a minute.
If you take one thing: your tool's job is not to decide whether a URL looks safe. It is to decide which address it is willing to open a socket to, and then to open a socket to exactly that address.
Written by
Sofia NievesSofia works on agent evaluation and reliability. She writes about measuring LLM systems before and after they reach production.
Frequently asked questions
Is a URL tool on an AI agent really an SSRF risk?
Yes, and it is the textbook shape. SSRF is CWE-918: an attacker influences a destination and your server makes the request. With an agent the model picks the URL, and anyone who can get text in front of the model can propose one. VulnCheck's advisory for CVE-2026-56266 describes exactly this in Crawl4AI before 0.8.7.
Why does checking u.hostname against a blocklist not work?
Three reasons measured here. For an IPv6 literal, u.hostname keeps the square brackets, so a check for '::1' never matches the actual value '[::1]'. The cloud metadata address is link-local and in neither the loopback nor the RFC1918 lists people memorise. And a hostname is not a destination: a public name can resolve to loopback, so the string you validated and the address you connect to are different things.
Do I still need to handle decimal and octal IP encodings in Node?
Measured on Node 24, no. The WHATWG URL parser normalises http://2130706433/, http://0177.0.0.1/ and http://127.1/ to 127.0.0.1 before your code runs. Those bypasses dominate the generic SSRF literature and are already closed in this runtime. Check it yourself with new URL('http://2130706433/').hostname.
What is the IPv4-mapped IPv6 bypass?
An address like ::ffff:127.0.0.1 carries an IPv4 address inside an IPv6 one. The trap in Node is that the URL parser re-compresses it to hex, so the hostname becomes [::ffff:7f00:1]. A guard that looks for an embedded dotted-quad with a regular expression cannot match that and falls through to allowing it. This defeated the first version of the guard in this article, and it is the bypass named in CVE-2026-56266.
Why connect to the IP instead of passing the URL to fetch?
Because fetch resolves the name again. If you resolve, approve, then hand the name to your HTTP client, there is a window between your lookup and its lookup, and a DNS answer that changes inside that window defeats the check. The OWASP SSRF Prevention Cheat Sheet prescribes connecting only to validated addresses while preserving the original hostname for the Host header and TLS SNI.
How do I keep TLS working if I connect to an IP?
Pass the IP as the connection host, send the original hostname in the Host header, and set servername to the original hostname so SNI and certificate verification still run against the name. The safeRequest function in this article does all three, and the A7 control proves it by reaching a real public host over https through a redirect.
What about redirects?
A guard that runs once has only checked the hop the attacker allowed you to check. In the measured run, fetch follows a 302 by default and the final URL is the internal service. Either disable redirects, which is what the OWASP cheat sheet suggests, or follow them yourself and run the full vet again on every hop before the next connection.
Does this guard stop an agent leaking data?
No, and that is a different control. Everything here decides which destinations are reachable. A tool that can reach any public URL can still put data in a query string. If that is your concern you want a narrow allowlist of permitted destinations rather than a blocklist of bad address ranges.
Related tutorials
How to sandbox an AI agent's file tools in Node.js
If your agent has a read_file tool, you probably guard it with path.resolve plus startsWith. It blocks the obvious traversal and is defeated by a symlink and by a sibling directory sharing the prefix. Both reproduced on Node 24.8.0, with the guard that passes all nine cases.
Argument injection: your AI agent's allowlisted shell tool is not safe (Node)
You gave your agent a shell tool, used execFile so there is no shell, and allowlisted the programs. It is still not safe. If the agent controls the argument array, tar's own --checkpoint-action=exec runs any command with no metacharacters. Here is the reproduction and the per-binary argument policy that stops it, measured on Node 24.8.0.
How to see the exact request your AI agent sends, without an API key
Agent frameworks build the request for you, then show you a summary of it. Here is a 60-line local server that records the exact JSON body your SDK sends, with no API key, no proxy and no certificate, plus the CI assertion a proxy cannot give you.