SSRF from frontend features
When URL inputs, previews, and proxies turn your server into an internal scanner, and how to allowlist safely in Next.js actions.
Server-side request forgery is a server bug with a frontend face. The UI lets a user supply a URL. The server fetches it. Suddenly your backend is probing cloud metadata, internal admin panels, or localhost services the browser could never reach.
Frontend features that invite SSRF: link previews, import from URL, screenshot services, webhook testers, image proxies, OG scrapers in Next.js Route Handlers.
Frontend trigger, server damage
The React form looks innocent. The danger is the server trust boundary behind it. Allowlist hosts, lock the scheme, reject credentials in the URL, and do not follow redirects to an unchecked location.
- Allowlist hosts and schemes.
- Block localhost, link-local, and cloud metadata names.
- Disable or strictly limit redirects.
- Set timeouts. Reject IP literals unless you intentionally resolve and check ranges.
"use server";
// Dangerous pattern
export async function previewLink(url: string) {
const res = await fetch(url); // may hit 169.254.169.254 or http://localhost
return res.text();
}
const ALLOWED_HOSTS = new Set(["cdn.example.com", "images.example.com"]);
const BLOCKED_HOSTS = new Set([
"localhost",
"metadata.google.internal",
"metadata.azure.com",
]);
function assertSafeUrl(raw: string) {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error("Invalid URL");
}
if (parsed.protocol !== "https:") throw new Error("HTTPS only");
if (parsed.username || parsed.password) {
throw new Error("Credentials in URL are not allowed");
}
if (BLOCKED_HOSTS.has(parsed.hostname.toLowerCase())) {
throw new Error("Host not allowed");
}
if (!ALLOWED_HOSTS.has(parsed.hostname.toLowerCase())) {
throw new Error("Host not allowed");
}
// Block literal IPs; prefer hostnames you control
if (/\d{1,3}(\.\d{1,3}){3}$/.test(parsed.hostname) || parsed.hostname.includes(":")) {
throw new Error("IP literals are not allowed");
}
return parsed;
}
export async function safePreviewLink(url: string) {
const parsed = assertSafeUrl(url);
const res = await fetch(parsed.toString(), {
redirect: "error",
signal: AbortSignal.timeout(_000),
});
if (!res.ok) throw new Error("Upstream failed");
return res.text();
}