Server-Side Request Forgery (SSRF)
When your server makes requests for the user
Open redirects trick the user’s browser into visiting a malicious URL. SSRF tricks your server into making a request to a URL the attacker chooses. The difference: your server often has access to internal resources that the attacker’s browser cannot reach.
The vulnerable code
Our bookmarks feature fetches a URL to get the page title:
route.post("/bookmarks", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const body = await c.request.json();
const url = body.url;
// DELIBERATELY VULNERABLE — fetches any URL the user provides
let title = url;
try {
const response = await fetch(url);
const html = await response.text();
const match = html.match(/<title>(.*?)<\/title>/i);
if (match) title = match[1];
} catch {
// Fetch failed, use URL as title
}
const id = crypto.randomUUID();
db.prepare("INSERT INTO bookmarks (id, user_id, url, title) VALUES (?, ?, ?, ?)").run(
id,
user.id,
url,
title,
);
return Response.json({ id, url, title }, { status: 201 });
},
}); The server fetches whatever URL the user provides. This is the vulnerability.
Attack 1: Read cloud metadata
On cloud platforms (AWS, GCP, Azure), instances can access a metadata service at a well-known internal IP. On AWS:
curl -X POST http://localhost:3000/bookmarks \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/"}' Your server fetches the AWS metadata URL and returns the response (possibly containing IAM credentials) in the bookmark title. The attacker just read your cloud credentials from the outside.
Attack 2: Scan internal services
The attacker can probe your internal network:
# Is there a service on port 6379 (Redis)?
{"url":"http://localhost:6379/"}
# What about the database admin panel?
{"url":"http://internal-db-admin.local:8080/"}
# Your app's own admin endpoint?
{"url":"http://localhost:3000/admin/users"} Your server makes these requests from inside the network, bypassing firewalls. The response time and error messages tell the attacker what services exist.
Attack 3: Read local files
Some URL parsers accept file:// URLs:
{"url":"file:///etc/passwd"} Your server reads a local file and returns its content in the bookmark title.
The fix: validate URLs
Create src/url-validator.ts:
// src/url-validator.ts
import dns from "dns/promises";
const BLOCKED_HOSTS = new Set([
"localhost",
"127.0.0.1",
"0.0.0.0",
"::1",
"[::1]",
"metadata.google.internal",
]);
function isPrivateIp(ip: string): boolean {
// IPv4 private ranges
if (ip.startsWith("10.")) return true;
if (ip.startsWith("172.") && parseInt(ip.split(".")[1]) >= 16 && parseInt(ip.split(".")[1]) <= 31)
return true;
if (ip.startsWith("192.168.")) return true;
if (ip.startsWith("169.254.")) return true; // Link-local (AWS metadata)
if (ip === "127.0.0.1" || ip === "0.0.0.0") return true;
return false;
}
export async function isUrlSafe(urlString: string): Promise<boolean> {
let parsed: URL;
try {
parsed = new URL(urlString);
} catch {
return false;
}
// Only allow http and https
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return false;
}
// Block known dangerous hosts
if (BLOCKED_HOSTS.has(parsed.hostname)) {
return false;
}
// Resolve the hostname and check if it points to a private IP
try {
const addresses = await dns.resolve4(parsed.hostname);
for (const addr of addresses) {
if (isPrivateIp(addr)) return false;
}
} catch {
// DNS resolution failed — block to be safe
return false;
}
return true;
} This function checks three things:
- Protocol: Only
http:andhttps:are allowed. This blocksfile://,ftp://,gopher://, and other schemes. - Hostname: Known dangerous hosts (localhost, metadata endpoints) are blocked.
- DNS resolution: The hostname is resolved, and the resulting IP is checked against private ranges. This catches cases where an attacker registers a domain that resolves to
127.0.0.1or169.254.169.254.
[!WARNING] The DNS resolution check has a race condition called DNS rebinding: the attacker’s domain could resolve to a safe IP during validation, then resolve to a private IP when
fetchmakes the actual request. Full protection against DNS rebinding requires more advanced techniques (using a DNS proxy, or resolving the IP and connecting to it directly). The checks above stop the most common attacks.
Apply it to the bookmarks route:
import { isUrlSafe } from "../url-validator.js";
route.post("/bookmarks", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const body = await c.request.json();
const url = body.url;
if (!url || typeof url !== "string") {
return Response.json({ error: "URL is required" }, { status: 400 });
}
// Validate the URL before fetching
const safe = await isUrlSafe(url);
if (!safe) {
return Response.json({ error: "URL not allowed" }, { status: 400 });
}
let title = url;
try {
const response = await fetch(url);
const html = await response.text();
const match = html.match(/<title>(.*?)<\/title>/i);
if (match) title = match[1];
} catch {
// Fetch failed
}
const id = crypto.randomUUID();
db.prepare("INSERT INTO bookmarks (id, user_id, url, title) VALUES (?, ?, ?, ?)").run(
id,
user.id,
url,
title,
);
return Response.json({ id, url, title }, { status: 201 });
},
}); Exercises
Exercise 1: Add the bookmarks route (vulnerable version). Try fetching http://localhost:3000/health via the bookmarks endpoint. You should see the response.
Exercise 2: Apply the URL validation. Try the same request. It should be blocked.
Exercise 3: Try file:///etc/passwd. The protocol check should block it.
Exercise 4: Think about DNS rebinding. Why might resolving the DNS before fetching not be sufficient? (Because the DNS could change between the check and the fetch.)
Why is SSRF particularly dangerous on cloud platforms?