SSRF in URL fetch features: link previews, webhooks and AI summarizers
SSRF happens when your server fetches a URL a user supplied without checking where it points. Link previews, image imports, webhooks and 'summarize this URL' features do this, and attackers aim them at localhost, internal services or the cloud metadata address 169.254.169.254. Fix it with an allowlist of known hosts, a block on private IP ranges after DNS (hostname lookup) resolves, no redirects, and a timeout and size cap.
QUICK CHECKLIST
- Allow only http and https, ports 80 and 443
- Resolve DNS, then block private and link-local IPs
- Connect to the IP you checked, not a second lookup
- Turn off redirects, set a timeout and a size cap
- Return extracted fields, never the raw response
- Use an exact-hostname allowlist when you know the hosts
What SSRF is and why URL fetch features have it
SSRF stands for server-side request forgery. An attacker gets your server to send a request to a place you never intended. Your server sits inside your network and holds your credentials, so it can reach things a visitor's browser can't.
Any feature that takes a URL from a user and fetches it is a candidate:
- A link preview that reads the title and image of a pasted link
- An avatar or image import from a URL
- A webhook or callback URL that users configure
- A "summarize this URL" feature that feeds a page to an LLM
In the 2025 OWASP Top 10, SSRF (CWE-918) is mapped under A01 Broken Access Control.
How an attacker uses SSRF against your server
The attacker types a URL into your form and your server does the rest. Common targets:
http://localhost/adminorhttp://127.0.0.1:3000/. The request now comes from the machine itself, so any check that trusts local traffic is skipped. If your admin pages lean on that trust, read exposed admin routes.http://192.168.0.68/adminor another private address. Internal systems often have weaker security, and some need no login.http://169.254.169.254/. This link-local address (never routed over the public internet) is where the big cloud providers serve instance metadata. On a cloud machine it can return details about the instance and, depending on setup, temporary credentials for its role.
Simple filters get bypassed. A blocklist for localhost and 127.0.0.1 misses other spellings such as 2130706433, 017700000001 or 127.1, and a domain the attacker owns can resolve to 127.0.0.1. An allowlist that inspects the raw string falls to tricks like https://expected-host@evil-host and https://evil-host#expected-host.
Redirects are another gap: an approved site can redirect your client to an internal address.
DNS rebinding abuses a second lookup. You resolve the hostname to a public address and approve it, then your HTTP client resolves the name again and the attacker's DNS server answers 127.0.0.1.
Wrong and right: a link preview route
Most first versions look like the bad route below. The fixed one calls a safeGet helper you'll build next.
// app/api/preview/route.ts
export async function POST(req: Request) {
const { url } = await req.json()
const res = await fetch(url) // any URL, follows redirects
return new Response(await res.text()) // raw body goes to the caller
}// app/api/preview/route.ts
import { safeGet } from "@/lib/safe-fetch"
export async function POST(req: Request) {
const { url } = await req.json()
try {
const res = await safeGet(String(url))
if (res.status !== 200 || !res.type.includes("text/html")) throw new Error("no preview")
const html = res.body.toString("utf8")
const title = html.match(/<title[^>]*>([^<]{1,200})/i)?.[1]?.trim() ?? null
return Response.json({ title })
} catch {
return Response.json({ error: "Could not load preview" }, { status: 422 })
}
}The fixed route never returns the upstream body. It returns only a title it extracted itself, because echoing raw responses turns any successful SSRF into a data leak. Render the title as plain text in your UI.
How to fix SSRF, step by step
Put the helper in lib/safe-fetch.ts. The steps below build it in pieces, so keep them in one file. It targets Node 20 or newer.
Use an allowlist when you can
If the feature only talks to hosts you know, keep an exact set of hostnames and reject the rest. Compare
url.hostnameexactly. Never usestartsWithorincludeson the raw string, becausehttps://expected-host.evil-hostpasses both.const ALLOWED = new Set(["images.example.com"]) if (!ALLOWED.has(url.hostname)) throw new Error("host not allowed")Link previews and user webhooks can point anywhere, so they rely on the next steps.
Block private IP ranges
List the address ranges your app must never reach: loopback, private networks, link-local (which includes 169.254.169.254), ranges that embed IPv4 addresses (NAT64 and 6to4), and other reserved blocks. Node's built-in
net.BlockListdoes the matching. Extend the list if your network uses other internal ranges.// lib/safe-fetch.ts import dns from "node:dns" import http from "node:http" import https from "node:https" import net from "node:net" const blocked = new net.BlockList() const v4 = [["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.0.0.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4]] as const const v6 = [["::", 128], ["::1", 128], ["64:ff9b::", 96], ["64:ff9b:1::", 48], ["2002::", 16], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]] as const for (const [ip, bits] of v4) blocked.addSubnet(ip, bits, "ipv4") for (const [ip, bits] of v6) blocked.addSubnet(ip, bits, "ipv6") export function isPublic(ip: string) { return !blocked.check(ip, net.isIPv6(ip) ? "ipv6" : "ipv4") }Check the address at connection time
Do the DNS lookup inside the connection itself by passing a custom
lookupfunction to the request. Node then connects to the address it returns, so there is no second lookup to poison and the DNS rebinding gap closes for requests made throughsafeGet. Send every user-influenced fetch through the helper, and don't route these requests through a proxy that does its own DNS lookup.const safeLookup: net.LookupFunction = (host, opts, cb) => { dns.lookup(host, { ...opts, all: true }, (err, addrs) => { if (err) return cb(err, [], 0) if (addrs.length === 0 || !addrs.every((a) => isPublic(a.address))) { return cb(new Error("blocked address"), [], 0) } if (opts.all) return cb(null, addrs, 0) cb(null, addrs[0].address, addrs[0].family) }) }Parse strictly, skip redirects, cap time and size
The helper applies these rules:
- Allow only the
http:andhttps:schemes, ports 80 and 443, and no credentials in the URL. OWASP notes SSRF can use schemes likefile://andgopher://. - Check IP literals like
http://127.0.0.1/directly, since they skip DNS. - Don't follow redirects, as OWASP advises. Node's
fetchfollows them by default, so the helper usesnode:httpand treats any redirect response as a failed preview.
export async function safeGet(input: string, maxBytes = 1_000_000) { const url = new URL(input) if (!["http:", "https:"].includes(url.protocol)) throw new Error("scheme") if (url.username || url.password) throw new Error("credentials") if (url.port && url.port !== "80" && url.port !== "443") throw new Error("port") const host = url.hostname.replace(/^\[|\]$/g, "") if (net.isIP(host) && !isPublic(host)) throw new Error("blocked address") const lib = (url.protocol === "https:" ? https : http) as typeof http return new Promise<{ status: number; type: string; body: Buffer }>((resolve, reject) => { const req = lib.get(url, { lookup: safeLookup, signal: AbortSignal.timeout(5000) }, (res) => { const chunks: Buffer[] = [] let size = 0 res.on("data", (c: Buffer) => { size += c.length if (size > maxBytes) { const e = new Error("too large") req.destroy(e) reject(e) } else chunks.push(c) }) res.on("error", reject) res.on("close", () => reject(new Error("closed early"))) res.on("end", () => resolve({ status: res.statusCode ?? 0, type: String(res.headers["content-type"] ?? ""), body: Buffer.concat(chunks) })) }) req.on("error", reject) }) }- Allow only the
Return one field, never the raw response
Extract the title or image URL and send only that back. Don't echo the body, the headers or the upstream error text. Don't forward your own cookies or Authorization headers to the target.
If the page goes to an LLM for a "summarize this URL" feature, the page text is untrusted input that can carry hidden instructions. Read prompt injection in AI apps. If you save imported images, treat the bytes like any upload and read insecure file uploads.
Also require a login and rate limit the route. An open fetch endpoint is a free proxy for anyone.
Test it yourself
Run these only against your own app. Never point them at someone else's site.
Start your dev server and send internal targets to your preview route (change the path if yours differs).
for u in http://127.0.0.1:3000/ http://localhost/ "http://[::1]/" "http://[::ffff:7f00:1]/" http://2130706433/ http://169.254.169.254/ file:///etc/passwd; do
echo "== $u"
curl -s -X POST localhost:3000/api/preview \
-H "content-type: application/json" \
-d "$(printf '{"url":"%s"}' "$u")"
echo
doneA bad result is any page content coming back (for the :3000 URL, that's your own app's HTML), or a request that hangs, which means your server tried to connect. A good result is a fast, identical Could not load preview error for every line.
Then test the other gaps:
- Use a public URL you control that answers 302 with
Location: http://127.0.0.1/. You should get the same error. - Point it at a large file you host. The request should fail at your byte cap.
Fix prompt you can paste
Give this to Cursor or Claude Code at the root of your repo.
Audit this repo for SSRF (server-side request forgery). Find every place the server requests a URL a user can influence: fetch, axios, got, undici, node-fetch, http.get/https.get, link previews, "import image from URL", webhook senders, and "summarize this URL" features. List each finding as `file:line` with the function name and which part of the URL the user controls. Do not edit anything until the list is done. Then fix each one: 1. Create lib/safe-fetch.ts: only http/https on ports 80/443, no credentials in the URL, a custom DNS lookup that rejects private, loopback, link-local (169.254.0.0/16), unique-local and reserved addresses (IPv4 and IPv6, including NAT64 and 6to4), IP literal checks, no redirects, a 5 second timeout and a 1 MB cap. Connect to the exact IP you checked. 2. Use an exact hostname allowlist where the hosts are known. 3. Return only extracted fields, never the raw body, headers or upstream errors. 4. Never forward cookies or Authorization headers to the target. 5. Require a logged-in user on these routes. Explain every change in plain English and add tests showing that http://127.0.0.1/, http://localhost/, http://169.254.169.254/, http://[::1]/ and file:///etc/passwd are rejected.
Which defense stops which SSRF trick
No single check covers everything, so layer them. If your host lets you restrict outbound traffic from the server, do that too.
| Trick | What it looks like | What stops it |
|---|---|---|
| Direct internal URL | http://169.254.169.254/ or 127.0.0.1 | Block private ranges after DNS resolution |
| Odd IP spelling | 2130706433, 127.1, 017700000001 | Parse with URL, then check the resolved IP |
| URL parser tricks | https://good@evil, https://evil#good | Compare url.hostname exactly |
| Redirect | Allowed site returns 302 to an internal URL | Don't follow redirects |
| DNS rebinding | Name resolves public, then private | Connect to the IP you checked |
The vibe coding security checklist covers the rest. For a second look at your whole app, run it through Leakcade.
Further reading
- OWASP: Server-Side Request Forgery Prevention Cheat Sheet
- PortSwigger Web Security Academy: What is SSRF?
- OWASP Top 10:2025, A01 Broken Access Control
FAQ
What is SSRF in simple terms?
SSRF (server-side request forgery) is when an attacker makes your server send a request to an address they choose. Because the request comes from your server, it can reach localhost, private networks and cloud metadata the attacker can't.
How do I prevent SSRF in Node.js?
Allow only http and https, resolve DNS and block private, loopback and link-local addresses at connection time, and don't follow redirects. Add a timeout and a size cap, and return only extracted fields.
Is blocking localhost and 127.0.0.1 enough?
No. Other spellings like 2130706433 or 127.1, a domain that resolves to 127.0.0.1, redirects and DNS rebinding all get past a string filter. Check the resolved IP address instead.
What is 169.254.169.254?
It's a link-local address that major cloud providers use for the instance metadata service. From inside a cloud machine it can return instance details and sometimes temporary credentials. Block it by rejecting the 169.254.0.0/16 range after DNS resolution, and on AWS also require IMDSv2.
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms