TL;DR
- Ask an AI editor to fix an SSRF and it writes a DNS lookup, an IP range check, then
fetch(url). That check does not hold. - Node resolves the hostname a second time when it opens the socket, so an attacker's nameserver can return a public IP for your check and 169.254.169.254 for the actual connection.
- Validate inside the connection, not before it. Then turn on IMDSv2 and egress rules so the application code is not the only thing standing between a URL parameter and your credentials.
I asked Cursor to fix an SSRF last week. It found the bug immediately, explained CWE-918 correctly, and rewrote the endpoint with a URL parser, a DNS resolution, a private-range check and redirects disabled. It looked like something out of a security guide. I nearly approved it.
It is still exploitable. Not because the check is wrong, but because the check runs against a different DNS answer than the request does.
This is the part I find genuinely interesting. The first version of this bug is a knowledge gap. The second version is not. The model knows what SSRF is, knows the mitigation, and produces code that fails anyway, because the failure lives in the gap between two lines rather than in either line.
The Vulnerable Code
Here is the "fixed" version, near enough to what I was handed:
import dns from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
async function assertPublicUrl(raw) {
const u = new URL(raw);
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('scheme');
const { address } = await dns.lookup(u.hostname);
if (ipaddr.parse(address).range() !== 'unicast') throw new Error('private address');
return u;
}
app.get('/api/preview', async (req, res) => {
const u = await assertPublicUrl(req.query.url); // resolves once
const r = await fetch(u, { redirect: 'error' }); // resolves again
res.json({ title: extractTitle(await r.text()) });
});
Read the last two lines as a pair. assertPublicUrl resolves the hostname and validates the address it got back. Then it throws that address away and returns a URL object holding a hostname string. fetch takes the hostname and resolves it again, independently, when it opens the socket.
Two lookups. Nothing carries the verdict of the first one into the second. That gap is the vulnerability, and it has its own classification: CWE-367, time-of-check to time-of-use. The SSRF (CWE-918) never actually got fixed, it just got a validator bolted to the front of it.
Why This Keeps Happening
The attack is DNS rebinding, and it is cheap. The attacker owns a domain and runs the authoritative nameserver for it with a TTL of zero. Your check asks for attacker.example, gets back a normal public IP, and passes. Milliseconds later fetch asks the same question and gets 169.254.169.254. Your server reads the cloud metadata service and hands back whatever it finds.
There is a Node-specific detail that makes this worse than it looks. Global fetch is powered by undici, and undici does not honour the Node agent option at all - it silently ignores it and resolves the hostname itself at connection time. So the other common attempt at this fix, pinning the validated IP onto a custom http.Agent, quietly does nothing on fetch. The code reads as though the IP is pinned. It is not.
This is not a hypothetical failure mode for careful teams. Budibase shipped this class of bug in its REST datasource integration, got an advisory for it, fixed it, and then got a second advisory because bypasses remained on other paths. A team that already knew the exact attack, with a filed CVE in hand, missed it on the follow-up pass. That is the difficulty level here.
As for why the model writes it: the training corpus is full of advice phrased as "validate the URL before you fetch it." That sentence is about URLs. The bug is about sockets. Almost nothing in the public writing connects the validator to the connection that eventually happens, so the model produces a function that takes a string, checks a string, and returns a string. Every part of that is idiomatic. The composition is wrong.
The tell in review: if the validated IP address is not the thing you connect to, you have not validated anything.
The Fix
The check has to run inside the connection path, against the address the socket is actually about to use. In Node that means a custom lookup on an undici Agent, handed to fetch as a dispatcher.
import { Agent } from 'undici';
import dns from 'node:dns';
import ipaddr from 'ipaddr.js';
function safeLookup(hostname, options, cb) {
dns.lookup(hostname, options, (err, address, family) => {
if (err) return cb(err);
if (ipaddr.parse(address).range() !== 'unicast') {
return cb(new Error('blocked: non-public address'));
}
cb(null, address, family);
});
}
const safeAgent = new Agent({ connect: { lookup: safeLookup } });
app.get('/api/preview', async (req, res) => {
const u = new URL(req.query.url);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return res.status(400).end();
const r = await fetch(u, { dispatcher: safeAgent, redirect: 'error' });
res.json({ title: extractTitle(await r.text()) });
});
ipaddr.parse(address).range() !== 'unicast' rejects loopback, private, link-local and reserved blocks in one call, and link-local is where 169.254.169.254 lives. The important change is not the check itself, it is where it sits. safeLookup runs on every connection the client opens, so there is no window between checking and connecting, and no separate path for a redirect hop to slip through.
Python has the same shape of problem. requests resolves the hostname down inside the adapter, so anything you validate beforehand is advisory. The two honest options are a custom HTTPAdapter whose pool manager validates the resolved address at connect time, or resolving once yourself and connecting to the validated IP directly with the Host header and the TLS server hostname set explicitly.
Now the part that matters more than the code. Application-layer URL validation is the weakest of the three available layers, and it is the only one an AI editor will ever write for you.
Turn on IMDSv2 and require it (http_tokens = required). With IMDSv2, reading metadata takes a PUT to /latest/api/token carrying an X-aws-ec2-metadata-token-ttl-seconds header, then the returned token in an X-aws-ec2-metadata-token header on the GET. A typical SSRF controls a URL, not the method and not the headers, so it cannot complete that handshake. The default hop limit of 1 also stops the token being usable from one network hop away.
Then add egress rules so 169.254.0.0/16 and the RFC1918 ranges are simply unreachable from the service that fetches user-supplied URLs. That control does not care whether your validator was correct.
FAQ
Q: Does checking the resolved IP before fetching stop SSRF?
A: Not on its own. If the check and the request are two separate DNS lookups, an attacker with a zero-TTL record can return a public address to the check and a private one to the request. The validation has to happen on the address the socket connects to.
Q: Is redirect: 'error' enough?
A: No. It closes one bypass, where an allowed public host 302s you inward, and you should keep it. It does nothing about rebinding, because there is no redirect involved - the same hostname simply resolves differently the second time.
Q: Does IMDSv2 make SSRF safe?
A: No. It removes one high-value target by requiring a method and a header that most SSRF primitives cannot supply. Internal admin panels, databases and service-to-service endpoints on your private network are all still reachable.
I've been running SafeWeave for this, hooked into Cursor and Claude Code as an MCP server, so the fetch path gets flagged while I am still looking at it. I will be blunt about the limits though: this specific bug is hard for any pattern-matching scanner, because the vulnerable code contains a validator and therefore looks like the fix. The controls that hold regardless are the ones underneath the application - IMDSv2 required, and egress rules that make the metadata endpoint unreachable from the service in the first place. Get those in place and a mistake in the validator stops being a credential leak.
Read the full original on the SafeWeave blog: https://safeweave.dev/blog/the-ssrf-fix-cursor-writes-is-still-vulnerable-cwe-918
Top comments (1)
SSRF is a great example of why generated security fixes need adversarial review. The patch can look reasonable while preserving the same trust mistake. I would want tests for redirects, DNS rebinding, private ranges, unusual schemes, and parser disagreement.