TL;DR
- Ask any AI editor for a "fetch this URL" feature and it hands the user's raw input straight to your HTTP client. That is SSRF (CWE-918).
- The danger is not the public internet. It is your cloud metadata endpoint and internal services that trust anything from your own server.
- Block private IP ranges, resolve DNS before you connect, and turn off redirects. Five lines.
I was reviewing a side project last week. It had a neat little feature: paste a link, get a preview card with the title and image. Classic. I asked what generated the backend and the answer was Cursor, one prompt, "add an endpoint that fetches a URL and returns its metadata."
The endpoint worked. It also happily fetched http://169.254.169.254/latest/meta-data/ and returned the IAM credentials sitting on the cloud metadata service. That is a full SSRF, and it took me about thirty seconds to find.
This is not a Cursor problem specifically. Every AI editor does this. The model was trained on thousands of tutorials that show the happy path and skip the part where the input is hostile.
The vulnerable code
Here is the pattern, near enough to what I actually saw (CWE-918):
app.get('/api/preview', async (req, res) => {
const target = req.query.url;
const response = await fetch(target); // fetches ANYTHING
const html = await response.text();
res.json({ title: extractTitle(html) });
});
Python version, same bug:
@app.route('/preview')
def preview():
url = request.args.get('url')
r = requests.get(url) # SSRF
return {'title': parse_title(r.text)}
Both take a user-controlled string and hand it to an HTTP client with no checks. An attacker does not point it at example.com. They point it at:
- http://169.254.169.254/latest/meta-data/iam/security-credentials/ for AWS creds
- http://localhost:9000/ or other internal admin panels bound to loopback
- http://10.0.0.5/ and friends across your private network
- file:// and gopher:// if the client follows non-http schemes
Why this keeps happening
The training data is full of "build a link preview" and "make an image proxy" walkthroughs. None of them validate the target, because a tutorial is teaching fetch, not threat modeling. The model reproduces the shape it saw most. It has no concept that your server sits inside a network that trusts it.
The tell is any endpoint where the user supplies a URL, hostname, or webhook and your server makes the request. Preview cards, image proxies, webhook testers, PDF-from-URL, avatar-by-URL. All the same class.
The fix
You need three things: a denylist of internal ranges, DNS resolution before you connect so a hostname cannot resolve to a private IP, and redirects turned off so a public URL cannot 302 you into the metadata service.
import dns from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
async function assertPublicUrl(raw) {
const u = new URL(raw);
if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error('scheme');
const { address } = await dns.lookup(u.hostname);
if (ipaddr.parse(address).range() !== 'unicast') throw new Error('private address blocked');
return u;
}
app.get('/api/preview', async (req, res) => {
const u = await assertPublicUrl(req.query.url);
const response = await fetch(u, { redirect: 'error' }); // no redirects
res.json({ title: extractTitle(await response.text()) });
});
The range() check rejects loopback, private, link-local (that is the 169.254 metadata range), and reserved blocks in one call. redirect: 'error' stops the classic bypass where a whitelisted host redirects you inward. For Python, resolve with socket.getaddrinfo, reject anything where ipaddress.ip_address(...).is_private or .is_link_local or .is_loopback, and pass allow_redirects=False.
One thing people miss: validate the resolved IP, not the string. http://[::ffff:169.254.169.254], decimal IP encodings, and DNS rebinding all sail past a naive hostname blocklist.
I've been running SafeWeave for this. It hooks into Cursor and Claude Code as an MCP server and flags SSRF the moment the endpoint is generated, before I move on to the next feature. That said, even a basic pre-commit hook with semgrep will catch the obvious fetch(req.query.url) shape. The important thing is catching it early, whatever tool you use.
Top comments (0)