Server-side request forgery (SSRF) in link preview and import-from-URL features
By Obscura Security · Updated · Node.js, Next.js, Vercel, AWS
Short answer
If your server fetches any URL a user gives it (link preview, import from URL, webhook setting, AI browsing), someone can make it request internal addresses such as localhost or the cloud metadata service at 169.254.169.254. Allow only http and https, resolve the hostname and refuse private, loopback and link-local addresses at connection time, don't follow redirects blindly, and cap time and size.
If your app has a box where someone pastes a URL and your server goes and gets it, that server can be aimed at places the visitor could never reach themselves.
Why this happens
Features that fetch a URL are common in AI-built apps: link previews, “import from URL”, a website summariser, a webhook URL in settings, an image downloader, an agent with a browse tool. The code is usually three lines: take the URL, call fetch, return the result.
The problem is that your server sits somewhere visitors don’t. It can reach localhost, other services on the same network, and on many cloud hosts a metadata service at 169.254.169.254 that can hand out credentials for the machine. If someone types one of those addresses into your URL box, your server makes the request on their behalf. That’s server-side request forgery, or SSRF.
Typical targets:
- the cloud metadata address,
http://169.254.169.254/ http://localhostorhttp://127.0.0.1on ports where an admin panel, database tool or debug server is listening- private ranges like
10.x.x.x,172.16.x.xto172.31.x.xand192.168.x.x - a public address that redirects to one of the above
How to check your own app
Do this on your own app, in a development or test environment.
- Find every place the server fetches a URL it didn’t write. Search the code for
fetch(,axios,got(,requests.getand any browse or scrape tool, then see which ones take a URL from a user, a database field or model output. - On the machine that runs your app, start a harmless listener:
python3 -m http.server 8000. In the feature, submithttp://127.0.0.1:8000/. If the listener’s log shows a request, your server will fetch loopback addresses. - Submit
http://localhost:8000/, thenhttp://[::1]:8000/, then the same address written as a number,http://2130706433:8000/. Each should be refused. - Test redirects. Host a page on a public site you control that redirects to
http://127.0.0.1:8000/and submit its address. If the listener is hit, redirects are followed without checks. - If you run on a cloud virtual machine, check that the metadata service needs extra protection (see below). Don’t paste whatever it returns into a chat or ticket, because it may contain credentials.
The fix
1. Allow only http and https
Reject file:, ftp:, gopher: and everything else. Parse the URL with new URL() first. It also converts odd spellings like 2130706433 into 127.0.0.1, so your checks see the real address.
2. Check the address the connection really uses
Checking the hostname text isn’t enough, because internal.attacker.example can resolve to 10.0.0.5. Resolve the name, and refuse the connection if any resolved address is not a public one. Do the check inside the connection itself, so the address you validated is the address you connect to. Checking in one step and fetching in another leaves a gap that DNS tricks can use.
This Node.js version uses the ipaddr.js package (npm install ipaddr.js) and a custom lookup function:
import dns from 'node:dns'
import net from 'node:net'
import http from 'node:http'
import https from 'node:https'
import ipaddr from 'ipaddr.js'
const MAX_BYTES = 1_000_000
// 'unicast' is what ipaddr.js calls an ordinary public address. Private,
// loopback, link-local (169.254.x.x), reserved and similar ranges are not.
function isPublicAddress(address) {
try {
return ipaddr.process(address).range() === 'unicast'
} catch {
return false
}
}
// Runs at connection time, so the address checked is the address used.
function safeLookup(hostname, options, callback) {
dns.lookup(hostname, options, (err, address, family) => {
if (err) return callback(err)
const list = Array.isArray(address) ? address : [{ address, family }]
if (!list.every((a) => isPublicAddress(a.address))) {
return callback(new Error('Blocked address'))
}
callback(null, address, family)
})
}
export function safeGet(rawUrl) {
return new Promise((resolve, reject) => {
let url
try {
url = new URL(rawUrl)
} catch {
return reject(new Error('Invalid URL'))
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return reject(new Error('Only http and https are allowed'))
}
// Node skips DNS lookup for IP addresses, so check those directly.
const host = url.hostname.replace(/^\[|\]$/g, '')
if (net.isIP(host) && !isPublicAddress(host)) {
return reject(new Error('Blocked address'))
}
const lib = url.protocol === 'https:' ? https : http
const req = lib.get(
url,
{ lookup: safeLookup, signal: AbortSignal.timeout(10_000) },
(res) => {
// Redirects are refused. See the note below if you need them.
if (res.statusCode >= 300 && res.statusCode < 400) {
res.destroy()
return reject(new Error('Redirects are not allowed'))
}
const chunks = []
let size = 0
res.on('data', (chunk) => {
size += chunk.length
if (size > MAX_BYTES) {
req.destroy(new Error('Response too large'))
return
}
chunks.push(chunk)
})
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
res.on('error', reject)
},
)
req.on('error', reject)
})
}
Because node:http and node:https don’t follow redirects by themselves, this treats any redirect as a failure. If you need redirects, follow them in your own loop: read the Location header, resolve it against the current URL, run the same protocol check, and call safeGet again, with a limit of about three hops. Every hop has to pass the same checks.
3. Set limits
The example above has a ten second overall deadline and a 1 MB size cap. Pick numbers that suit your feature. Without them, a slow or huge response can tie up your server.
4. Isolate the fetcher where you can
Run URL fetching in its own small function or service that has no database credentials, no internal network access and few environment variables. If it is ever fooled, there is little to steal. Egress rules that only allow outbound traffic to the public internet add a second layer.
5. Harden the metadata service
On AWS, set EC2 instances to require IMDSv2, which makes the simple request an SSRF bug can send much less useful. Other clouds have similar settings, so check your provider’s documentation.
6. Don’t echo the response
Return only what the feature needs, such as a title and a preview image address. Don’t pass back raw bodies, headers or detailed error text, because they let an attacker read what the server saw.
Mistakes to avoid
- A blocklist of strings. Blocking
localhostand127.0.0.1misses IPv6, number forms and DNS names that resolve internally. - Validating the URL, then fetching it with a library that resolves it again. The second lookup can return a different answer.
- Letting
fetchfollow redirects by default when the first URL passed your check. - Trusting URLs that came from your database or from model output. If a user or a web page influenced them, they count as user input. See prompt injection in AI features.
- Giving the fetching code the same credentials as the rest of the app.
Common questions
- What is SSRF in plain terms?
- Server-side request forgery means you let a user choose where your server sends a web request, and they choose somewhere they shouldn't be able to reach, like an address that only works from inside your hosting network. Your server becomes their messenger.
- Is a link preview or Open Graph fetch really risky?
- Yes, if the server fetches the URL and returns anything about the response, even an error message or a title. That is enough to learn whether an internal address exists and sometimes to read its content.
- Can I just block the text 127.0.0.1 and localhost?
- No. Attackers use other spellings, hostnames that resolve to internal addresses, redirects from a public site to an internal one, and IPv6. Check the address the connection actually uses, not the text of the URL.
- Does this apply to serverless platforms like Vercel?
- The cloud metadata address matters most on virtual machines and containers, but the principle is the same everywhere. Your function can reach things the public internet can't, such as private databases, internal APIs and other services on the same network.
- Does my AI agent browsing the web count?
- Yes. A browsing or fetch tool is a URL fetch feature, and the URL may come from text the model read, not from your user. Apply the same checks to the tool.
Read next
Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].