HomeGuidesSSRF in Next.js: safe fetch for link previews and AI tools

Guide

SSRF in Next.js: safe fetch for link previews and AI tools

Stop SSRF in Next.js and Node fetch. Lock down link previews, AI URL tools, image imports and webhooks with a tested safe fetch helper you can copy.

Scan your live app free with LeakCheck. Sign in with Google to keep scanning.

24 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

Your app has a box that says "paste a link." Maybe it shows a preview card, maybe an AI model summarizes the page, maybe it imports a profile picture or calls a webhook later. Behind that box is one line your AI coding tool wrote for you:

const res = await fetch(url);

That line runs on your server, so whoever types the URL decides where your server connects. This is server-side request forgery (SSRF), and it is easy to ship with Cursor, Claude Code, Lovable, Bolt, or v0, because the feature works perfectly in a demo.

This guide covers what SSRF can reach on Vercel, Cloudflare, Supabase, and your own server, the bypasses that beat most AI-written filters, and a TypeScript helper that blocks them, tested against local servers (details below). Checked on October 20, 2026 against Node.js 22.23.3, undici 8.12.3, ipaddr.js 2.5.0, Next.js 16.4.2, the AI SDK 7.0.147, and the docs and advisories linked below. VibeLocked is independent and not affiliated with any company named here.

The short version

  • Never fetch a user's URL with a plain fetch(url). Use one helper for every "fetch this URL" feature.
  • Allowlist hosts when you can. If the feature only needs GitHub avatars, accept only that hostname.
  • Otherwise, check addresses, not strings. Parse with new URL(), allow only https:, resolve the hostname, and refuse if any address is private, loopback, link-local, or reserved.
  • Connect to the address you checked. A second DNS lookup lets the answer change in between (DNS rebinding).
  • Handle redirects yourself. Use redirect: 'manual', check every hop, and stop after a few.
  • Limit everything. Timeouts, a response size cap, a content-type check, and no cookies or API keys sent along.
  • Keep Next.js patched, and never use hostname: '**' in images.remotePatterns.

What SSRF is, in plain words

Your server sits inside a network outsiders cannot see: an admin panel on localhost:8080, a database dashboard, a Redis port, other services in your cloud account, and on many cloud servers a metadata service at 169.254.169.254 that hands out credentials. PortSwigger's SSRF guide gives the classic example: a shop sends stockApi=http://localhost/admin, and the server fetches its own admin page, which trusts requests from itself, and returns it.

OWASP's SSRF Prevention Cheat Sheet describes two cases: features that should only call known, trusted services (use an allowlist) and features that must accept any public URL (block internal destinations). SSRF is CWE-918, and the OWASP Top 10 2025 now counts it under Broken Access Control.

Features in vibe-coded apps that do this:

  • Link previews and unfurling. Fetch the page, read the title and image.
  • "Summarize this URL" and AI agents with a web fetch tool. Here the model picks the URL, and the model can be steered by text on any page it reads (prompt injection).
  • Import an image or avatar by URL.
  • User-configured webhooks. Your server calls the customer's URL whenever an event happens.
  • PDF and screenshot generators that load a page in headless Chrome.

What it can reach on serverless hosts

The honest answer depends on where your code runs, and some of it is not documented.

Your own server, a VPS, Docker, or EC2. This is where SSRF hurts most. Anything listening on localhost or your private network is in reach. On AWS EC2, the metadata service lives at http://169.254.169.254/latest/meta-data/ and, on Nitro instances with IPv6, at http://[fd00:ec2::254]/latest/meta-data/, per AWS's instance metadata docs. The instance role's temporary credentials sit under iam/security-credentials/, and with IMDSv1 allowed, a plain GET with no token is accepted. IMDSv2 needs a PUT to get a session token first, rejects token requests that carry an X-Forwarded-For header, and by default limits the token response to one network hop. AWS calls these features defense in depth against SSRF. Require IMDSv2 on every instance, and still fix the bug.

Vercel. Vercel does not publish which internal addresses a Function can reach, and we did not probe it. (Plain AWS Lambda, for comparison, hands a function its role credentials in environment variables and has a separate local metadata endpoint that needs a token.) What is documented: Secure Compute connects your Functions to your private databases and APIs, with VPC peering. If you use it, an SSRF in a Function can reach those private services. Several of the Next.js SSRF advisories below also say Vercel-hosted apps were not affected.

Cloudflare Workers. Cloudflare's known issues page says Workers subrequests "can only be made to URLs, not to IP addresses directly." Cloudflare does not document what happens when a public hostname points to a private address, so treat that as unknown. What is documented: Workers VPC network bindings reach "any service in your private network," and the URL you pass to fetch() on the binding decides the destination. Never pass a user's URL to a VPC binding.

Supabase Edge Functions. Supabase describes them as globally distributed, powered by Deno, and built on its open-source Edge Runtime. Supabase's limits page blocks outgoing ports 25 and 587 and says nothing about private addresses, so we will not guess. Note that Deno can fetch file: URLs, which is one more reason to check the scheme. Whatever is reachable, your function holds secrets such as SUPABASE_SERVICE_ROLE_KEY, which Supabase provides by default, and an SSRF that sends a secret along leaks it.

Every platform. Even with no internal network, a fetch(userUrl) that returns the response turns your app into a free proxy running on your IP and your bill. And if your helper adds an Authorization header (say, a GitHub token), pointing it at the attacker's server hands them the token.

The bypasses that beat AI-written filters

Each of these got past at least one naive filter in our tests.

  • Other ways to write 127.0.0.1. http://2130706433/ (decimal), http://0x7f.0.0.1/ (hex), http://0177.0.0.1/ (octal), and http://127.1/ all mean 127.0.0.1. The WHATWG URL standard that new URL() follows parses these forms, so a raw string check never sees "127.0.0.1". PortSwigger lists the same tricks.
  • IPv6 forms. http://[::ffff:127.0.0.1]/ is the IPv4 loopback inside an IPv6 address, and new URL() rewrites it to [::ffff:7f00:1], which no 127. regex matches. http://[::1]/ is IPv6 loopback.
  • 0.0.0.0 and [::]. On Linux, these reach the local machine. Few blocklists include them.
  • A public name that points inside. Wildcard DNS services such as nip.io resolve 127-0-0-1.nip.io to 127.0.0.1.
  • Redirects. A harmless public page answers 302 Location: http://127.0.0.1/admin. The Fetch standard follows up to 20 redirects by default, wherever they go.
  • DNS rebinding. Your check resolves the name to a public address, then fetch() resolves it again and the attacker's DNS answers 127.0.0.1. OWASP warns that "a second, unchecked DNS lookup between validation and connection can bypass these checks."
  • Userinfo. http://good.example@127.0.0.1/ starts with http://good.example but the host is 127.0.0.1. Node's built-in fetch refuses URLs with credentials, as the Fetch standard requires, but axios and other clients do not, and a startsWith check is still fooled.
  • Other schemes. file:, data:, gopher:, ftp:. Node's fetch did not implement file: in our test. Deno does.

Typical AI-generated mistakes

These are the exact naive versions we tested. Each looks fine in a code review.

1. A string check on the raw input. Beaten by 2130706433, hex, octal, and 127.1.

if (url.includes('localhost') || url.includes('127.0.0.1') || url.includes('169.254.169.254')) throw new Error('blocked');
return fetch(url);

2. A regex on the hostname. Beaten by [::ffff:127.0.0.1], 0.0.0.0, [::], and 127-0-0-1.nip.io. (It did catch 127.0.0.1.nip.io, only because that name starts with "127.")

const { hostname } = new URL(url);
if (/^(localhost|127\.|10\.|192\.168\.|169\.254\.|\[::1\])/.test(hostname)) throw new Error('blocked');
return fetch(url);

3. A startsWith allowlist. Beaten by http://good.example.127.0.0.1.nip.io/.

if (!url.startsWith('http://good.example')) throw new Error('blocked');
return fetch(url);

4. A correct host allowlist that follows redirects. If the allowed host has an open redirect, the fetch lands wherever it points.

if (new URL(url).hostname !== 'images.partner.test') throw new Error('blocked');
return fetch(url);

5. Resolve, check, then fetch by hostname. It checks the right thing at the wrong time. Beaten by a public redirect and by DNS rebinding.

const { hostname } = new URL(url);
const { address } = await dns.promises.lookup(hostname);
if (!isPublicAddress(address)) throw new Error('blocked');
return fetch(url);

The fix: one safe fetch helper

The helper below follows the OWASP cheat sheet: strict parsing, an optional exact allowlist, a check on every resolved address, a connection pinned to that address, manual redirects checked on every hop, and limits on time, size, and content type. It uses undici, the library behind Node's own fetch, because its Agent lets us supply the DNS lookup for the connection, and ipaddr.js, a maintained IP parser that knows the special ranges.

Install them with npm install undici ipaddr.js. undici 8 needs Node 22.19 or later, per its package.json, so check your host's Node version. In a Next.js app, add import 'server-only'; at the top so the file never reaches a client bundle. We left it out only because our test ran outside Next.js.

// lib/safe-fetch.ts
import dns from 'node:dns';
import { isIP } from 'node:net';
import ipaddr from 'ipaddr.js';
import { Agent, fetch } from 'undici';

export class BlockedUrlError extends Error {}

export type SafeFetchOptions = {
  allowedHosts?: string[]; // exact hostnames; leave empty only if you must accept any public site
  allowHttp?: boolean; // default false: https only
  method?: 'GET' | 'HEAD' | 'POST';
  headers?: Record<string, string>; // only what you set; never the incoming request's headers
  body?: string;
  maxRedirects?: number; // default 3, use 0 for webhooks
  timeoutMs?: number; // default 5000, covers every hop and the body
  maxBytes?: number; // default 2 MB
  allowedContentTypes?: string[]; // prefixes, for example ['text/html', 'image/']
};

export type SafeFetchResult = {
  url: string; // final URL after redirects
  status: number;
  contentType: string;
  body: Uint8Array;
};

// Only globally routable unicast addresses pass. This blocks loopback, private,
// link-local (169.254.169.254), carrier-grade NAT, unique local (fd00:ec2::254),
// multicast, 0.0.0.0, and the other special ranges ipaddr.js knows about.
export function isPublicAddress(address: string): boolean {
  if (!ipaddr.isValid(address)) return false;
  let ip = ipaddr.parse(address);
  if (ip instanceof ipaddr.IPv6 && ip.isIPv4MappedAddress()) {
    ip = ip.toIPv4Address(); // ::ffff:127.0.0.1 is 127.0.0.1
  }
  if (ip instanceof ipaddr.IPv6 && !ip.match(ipaddr.IPv6.parse('2000::'), 3)) {
    return false; // only 2000::/3 is global unicast IPv6
  }
  return ip.range() === 'unicast';
}

// Strict parsing. Run at save time (webhook URLs) and on every hop.
export function parseUserUrl(input: string, opts: SafeFetchOptions = {}): URL {
  let url: URL;
  try {
    url = new URL(input);
  } catch {
    throw new BlockedUrlError('Not a valid absolute URL');
  }
  const schemes = opts.allowHttp ? ['https:', 'http:'] : ['https:'];
  if (!schemes.includes(url.protocol)) throw new BlockedUrlError(`Scheme ${url.protocol} not allowed`);
  if (url.username || url.password) throw new BlockedUrlError('URLs with a username or password are not allowed');
  if (url.port !== '') throw new BlockedUrlError('Custom ports are not allowed');

  const host = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
  if (opts.allowedHosts?.length && !opts.allowedHosts.includes(host)) {
    throw new BlockedUrlError(`Host ${host} is not on the allowlist`);
  }
  // new URL() already turned 2130706433, 0x7f.1 and 0177.0.0.1 into 127.0.0.1.
  if (isIP(host) && !isPublicAddress(host)) throw new BlockedUrlError(`Address ${host} is not public`);
  return url;
}

// DNS lookup for the connection itself: every answer must be public, and the
// socket connects to the exact addresses we checked. No second lookup, so DNS
// rebinding between "check" and "connect" has nothing to race.
const checkedLookup: typeof dns.lookup = ((hostname: string, options: any, callback: any) => {
  if (typeof options === 'function') { callback = options; options = {}; }
  dns.lookup(hostname, { all: true, verbatim: true }, (err, addresses) => {
    if (err) return callback(err);
    const blocked = addresses.find((a) => !isPublicAddress(a.address));
    if (addresses.length === 0 || blocked) {
      return callback(new BlockedUrlError(`${hostname} resolves to ${blocked?.address ?? 'nothing'}, which is not public`));
    }
    if (options.all) return callback(null, addresses);
    return callback(null, addresses[0].address, addresses[0].family);
  });
}) as typeof dns.lookup;

const agent = new Agent({
  connect: { lookup: checkedLookup, timeout: 5000 },
  headersTimeout: 5000,
  bodyTimeout: 5000,
});

// Save-time check for webhook URLs: parse, then resolve and check every address.
export async function assertPublicUrl(input: string, opts: SafeFetchOptions = {}): Promise<URL> {
  const url = parseUserUrl(input, opts);
  const host = url.hostname.replace(/^\[|\]$/g, '');
  if (!isIP(host)) {
    const addresses = await dns.promises.lookup(host, { all: true, verbatim: true });
    const blocked = addresses.find((a) => !isPublicAddress(a.address));
    if (addresses.length === 0 || blocked) throw new BlockedUrlError(`${host} resolves to a non-public address`);
  }
  return url;
}

export async function safeFetch(input: string, opts: SafeFetchOptions = {}): Promise<SafeFetchResult> {
  const maxRedirects = opts.maxRedirects ?? 3;
  const maxBytes = opts.maxBytes ?? 2_000_000;
  const signal = AbortSignal.timeout(opts.timeoutMs ?? 5000);
  let url = parseUserUrl(input, opts);

  for (let hop = 0; ; hop++) {
    let res;
    try {
      res = await fetch(url, {
        method: opts.method ?? 'GET',
        headers: { 'user-agent': 'MyAppFetcher/1.0', ...opts.headers },
        body: opts.body,
        redirect: 'manual', // we validate each hop ourselves
        dispatcher: agent,
        signal,
      });
    } catch (err) {
      const cause = (err as { cause?: unknown }).cause;
      if (cause instanceof BlockedUrlError) throw cause;
      throw err;
    }

    if (res.status >= 300 && res.status < 400 && res.headers.has('location')) {
      await res.body?.cancel();
      if (hop >= maxRedirects) throw new BlockedUrlError('Too many redirects');
      // Relative redirects resolve against the current URL, then get the full check again.
      url = parseUserUrl(new URL(res.headers.get('location')!, url).href, opts);
      continue;
    }

    const contentType = res.headers.get('content-type') ?? '';
    if (opts.allowedContentTypes && !opts.allowedContentTypes.some((t) => contentType.startsWith(t))) {
      await res.body?.cancel();
      throw new BlockedUrlError(`Content type ${contentType || 'missing'} not allowed`);
    }
    if (Number(res.headers.get('content-length') ?? 0) > maxBytes) {
      await res.body?.cancel();
      throw new BlockedUrlError('Response too large');
    }

    // Read with a hard cap, even if content-length was missing or wrong.
    const chunks: Uint8Array[] = [];
    let total = 0;
    if (res.body) {
      for await (const chunk of res.body) {
        total += chunk.byteLength;
        if (total > maxBytes) throw new BlockedUrlError('Response too large');
        chunks.push(chunk);
      }
    }
    const body = new Uint8Array(total);
    let offset = 0;
    for (const c of chunks) { body.set(c, offset); offset += c.byteLength; }
    return { url: url.href, status: res.status, contentType, body };
  }
}

Why each part is there:

  • new URL() first. It turns 2130706433, hex, and octal into 127.0.0.1 and normalizes IPv6, so the check sees the real address. We also refuse username, password, and custom ports.
  • IP literals are checked in parseUserUrl. Node skips the lookup function when the host is already an IP address (we confirmed this in Node 22).
  • checkedLookup runs inside the connection. The lookup option of Node's net.connect replaces dns.lookup(), and undici passes it through from connect (Client docs). The socket connects to an address we just checked, with no second lookup to race.
  • "Public unicast or nothing." ipaddr.js names loopback, private, link-local, carrier-grade NAT, unique local, multicast, unspecified, and reserved ranges, and any named range is refused. IPv4-mapped IPv6 is unwrapped first, and IPv6 outside 2000::/3 (global unicast) is refused, which also stopped the NAT64 and IPv4-compatible forms we tried.
  • redirect: 'manual' returns the 3xx response to us (undici fetch docs). We resolve Location against the current URL and run the whole check again, allowlist included, up to maxRedirects.
  • One timeout for everything. AbortSignal.timeout covers every hop and the body, so a slow server cannot tie up your function.
  • A size cap that does not trust content-length. We count bytes as they arrive.
  • Only headers you set. Never pass on the incoming request's headers, cookies, or Authorization, and never add your own API keys.
// app/api/link-preview/route.ts
import { safeFetch, BlockedUrlError } from '@/lib/safe-fetch';

export const runtime = 'nodejs'; // the helper needs node:dns, so not the Edge runtime

export async function POST(request: Request) {
  // Check the signed-in user and rate limit here first.
  const { url } = (await request.json()) as { url?: unknown };
  if (typeof url !== 'string' || url.length > 2048) {
    return Response.json({ error: 'Send a URL' }, { status: 400 });
  }

  try {
    const page = await safeFetch(url, { allowedContentTypes: ['text/html'], maxBytes: 1_000_000 });
    const html = new TextDecoder().decode(page.body);
    const title = html.match(/<title[^>]*>([^<]{0,300})<\/title>/i)?.[1]?.trim() ?? null;
    // Return only what the preview needs, never the raw response.
    return Response.json({ url: page.url, title });
  } catch (err) {
    if (err instanceof BlockedUrlError) {
      return Response.json({ error: 'That link cannot be previewed' }, { status: 400 });
    }
    return Response.json({ error: 'Could not load that page' }, { status: 502 });
  }
}

A Route Handler or Server Action that fetches a URL is a public endpoint, so it still needs auth and a rate limit (Server Actions are public too). Return only the title and image you need, never the fetched body.

The helper uses node:dns, so keep this route on the Node.js runtime. It will not run on the Edge runtime or in Cloudflare Workers, and we did not test it in Deno. For a Supabase Edge Function, the safest plan is an exact host allowlist with redirect: 'manual' and a check on every hop. If you must accept any public URL, send that job to a Node function that uses this helper.

An AI agent's web fetch tool

This is the same helper wrapped as an AI SDK tool. The model chooses the URL, so the helper has to make the decision, not the prompt.

// lib/fetch-page-tool.ts
import { tool } from 'ai';
import { z } from 'zod';
import { safeFetch, BlockedUrlError } from '@/lib/safe-fetch';

export const fetchPage = tool({
  description: 'Fetch a public web page and return its text.',
  inputSchema: z.object({ url: z.string().max(2048) }),
  execute: async ({ url }) => {
    try {
      const page = await safeFetch(url, {
        allowedContentTypes: ['text/html', 'text/plain'],
        maxBytes: 1_000_000,
      });
      const text = new TextDecoder().decode(page.body).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');
      // Treat this text as untrusted input to the model, not as instructions.
      return { url: page.url, text: text.slice(0, 20_000) };
    } catch (err) {
      return { error: err instanceof BlockedUrlError ? 'That URL is not allowed' : 'Fetch failed' };
    }
  },
});

Three extra rules for agents. First, the text you return is untrusted: a page can contain instructions aimed at your model, so keep the tool away from private data and other powerful tools in the same session (prompt injection defense). Second, the fetch tool must never carry your credentials. Third, check the fetch tools in your own coding setup too. The official MCP fetch server's README has long warned that it "can access local/internal IP addresses." The README now says the server refuses private addresses by default (the change requested in issue #4838, so check that your installed version has it), and it also says the check "does not protect against DNS rebinding between that check and the connection." That gap is exactly what pinning the connection closes. Our MCP server security guide covers the rest.

Webhook URLs: check at save time and at send time

This section covers webhooks your app sends to customer URLs. For webhooks your app receives, the risk is forged requests instead, covered in verify webhooks from Clerk, Resend, GitHub, and Supabase.

// lib/webhooks.ts
import { createHmac } from 'node:crypto';
import { assertPublicUrl, safeFetch } from '@/lib/safe-fetch';

// Save time: reject private targets before you store the URL.
export async function normalizeWebhookUrl(input: string): Promise<string> {
  const url = await assertPublicUrl(input);
  return url.href;
}

// Send time: DNS may have changed since the user saved it, so check again.
// safeFetch re-parses, re-resolves, pins the IP, and here follows no redirects.
export async function sendWebhook(storedUrl: string, payload: unknown, signingSecret: string) {
  const body = JSON.stringify(payload);
  const signature = createHmac('sha256', signingSecret).update(body).digest('hex');
  const res = await safeFetch(storedUrl, {
    method: 'POST',
    body,
    headers: { 'content-type': 'application/json', 'x-webhook-signature': signature },
    maxRedirects: 0,
    timeoutMs: 10_000,
    maxBytes: 10_000,
  });
  return res.status;
}

Check on save so the customer sees a clear error. Check on send because the DNS behind a saved hostname can change at any time. With maxRedirects: 0, a 3xx counts as a failed delivery. Show the customer the status code and timing, not the response body, or your delivery log becomes a way to read internal pages. Stripe runs its own webhooks through Smokescreen, an egress proxy that allows only public addresses, to stop its webhook system being "used to scan Stripe's internal network." If you outgrow this helper, a proxy like that is the next step. (Receiving Stripe webhooks is a different job: verify the signature.)

Image import by URL, and PDF or screenshot tools

For "import avatar from URL," call safeFetch with allowedContentTypes: ['image/'] and a small maxBytes, then treat the bytes like any upload: check the real file type, re-encode if you can, and store the result in your own bucket (secure file uploads). Do not hotlink the user's URL forever.

Headless Chrome is harder. The helper checks the first URL, but the browser then loads scripts, images, iframes, and redirects, and the page's JavaScript can call fetch('http://169.254.169.254/') itself. Prefer rendering HTML you generated yourself, with JavaScript off. If you must load user pages, run the browser on a network that has no route to private addresses, or send it through an egress proxy, and block requests with Puppeteer's request interception as a second layer.

Next.js specifics

next/image remote patterns. The image optimizer is a server that fetches remote URLs for you. The Image docs say ** in remotePatterns matches "any number of path segments at the end or subdomains at the beginning," so hostname: '**' lets anyone use your optimizer to fetch from any host. The same page notes that optimizer redirects "do not need to satisfy remotePatterns." Next.js 16 made the defaults stricter, per the version 16 upgrade guide: local IPs are blocked unless you set dangerouslyAllowLocalIP, and redirects stop at 3. List exact hosts and paths:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'avatars.githubusercontent.com', pathname: '/u/**' },
      { protocol: 'https', hostname: 'your-project-ref.supabase.co', pathname: '/storage/v1/object/public/**' },
    ],
    // Next.js 16 defaults, written out so nobody "fixes" them later:
    dangerouslyAllowLocalIP: false,
    maximumRedirects: 3,
  },
};

export default nextConfig;

This config type-checks against Next.js 16.4.2.

Real Next.js SSRF advisories. Most need a self-hosted app, and all are fixed. Upgrade to the newest release in your major line (16.4.2 and 15.5.28 when we checked).

Advisory What happened Fixed in
CVE-2024-34351 Server Actions that redirect to a path starting with / could be pointed at another host through the Host header, on self-hosted apps 14.1.1
CVE-2025-57822 Middleware passing request headers into NextResponse.next() could lead to SSRF on self-hosted apps 14.2.32, 15.4.7
CVE-2026-44578 Crafted WebSocket upgrade requests could make the built-in Node server proxy to internal destinations; Vercel not affected 15.5.16, 16.2.5
CVE-2026-64649 Server Actions on custom servers that do not pin the Host header could send requests to an attacker's host 15.5.21, 16.2.11
CVE-2026-64645 rewrites() destinations that build the hostname from request input could be pointed at any host 15.5.21, 16.2.11
CVE-2026-94483 An allowlisted remote image host whose DNS an attacker controls could reach private IPs through Image Optimization 16.3.8

If you deploy Next.js to Cloudflare with OpenNext, the adapter had its own image SSRF bugs: an unrestricted /_next/image proxy (CVE-2025-6087, fixed in 1.3.0) and a /cdn-cgi\image/ path trick (CVE-2026-3125, fixed in 1.17.1). The middleware bypass CVE is a reminder of how much these patch releases matter.

Libraries, and the ones that failed

SSRF libraries are easy to get wrong, and several popular ones have:

  • ip: isPublic called addresses like 127.1, 01200034567, and ::fFFf:127.0.0.1 public (CVE-2024-29415). No fixed version is listed.
  • private-ip: an earlier bypass was fixed in 2.0.0 (CVE-2020-28360), and all versions through 3.0.2 miss multicast 224.0.0.0/4 (CVE-2025-8020). No fix listed.
  • netmask: misparsed octal IP strings, letting attackers past IP filters (CVE-2021-28918, fixed in 1.1.0). That fix was incomplete, and the rest landed in 2.0.1 (CVE-2021-29418).
  • request-filtering-agent: HTTPS requests to 127.0.0.1 skipped the filter (CVE-2025-57814, fixed in 2.0.0), and a private IP literal could crash the process (CVE-2026-62985, fixed in 3.2.1). It is maintained and checks the address it connects to, which is the right design. Its README says it works with node-fetch, axios, and got, and that Node's built-in fetch does not support http.Agent.

Whatever you use, run the bypass list in this guide against it.

How we tested this

Everything ran on our own test machine, and nothing outside it was attacked. We started an "internal admin" server on 127.0.0.1 and ::1 port 80, another on port 8081, and two local redirect servers. Public traffic went only to example.com and the test service httpbin.org. The machine's DNS answers one sandbox address for every name, so the test script resolved public names with DNS over HTTPS and played the DNS server for two .test names: images.partner.test pointed to a local redirect server, and rebind.test answered a public address first and 127.0.0.1 second, to simulate rebinding. No request went to a real metadata service; the helper refused 169.254.169.254 and fd00:ec2::254 before connecting.

Input Naive result safeFetch
http://127.0.0.1/admin (no checks) Reached the internal server Blocked
http://2130706433/admin, 0x7f.0.0.1, 0177.0.0.1, 127.1 (string check) Reached Blocked
http://[::ffff:127.0.0.1]/admin, 0.0.0.0, [::] (hostname regex) Reached Blocked
http://127-0-0-1.nip.io/admin (hostname regex) Reached Blocked after DNS
http://good.example.127.0.0.1.nip.io/admin (startsWith) Reached Blocked after DNS
http://good.example@127.0.0.1/admin (startsWith) Node's fetch refused the credentials Blocked
Allowlisted images.partner.test that redirects to 127.0.0.1 Reached Blocked
httpbin redirect to http://127.0.0.1/admin (resolve, then fetch) Reached Blocked on the second hop
rebind.test, public then 127.0.0.1 (resolve, then fetch) Reached Never reached in 10 tries
file:///etc/hostname (no checks) Node's fetch: not implemented Blocked
https://example.com/ 200, 577 bytes of HTML

The naive versions reached the internal server in 15 of 18 attempts. The helper blocked all 30 attack URLs in its default HTTPS-only mode and all 30 again with allowHttp: true, where the address checks do the work, and the internal servers logged zero requests from it. It also refused a redirect off the allowlist, stopped at the hop limit, enforced the size cap, content type, and timeout, and sent no cookies or Authorization header. The link preview route returned "Example Domain" for example.com and a 400 for internal URLs, the webhook sender got a 200 from httpbin and failed on a 307, and the agent tool refused the metadata address. All code on this page type-checks with TypeScript 7.0.2 in strict mode.

Checklist

  • Every server-side fetch of a user or model chosen URL goes through one helper
  • Exact host allowlist wherever the feature allows it
  • https: only, no username or password in the URL, default port only
  • Every resolved address checked with a real IP library, IPv4-mapped IPv6 unwrapped
  • Connection pinned to the checked address, with no second DNS lookup
  • redirect: 'manual', full check on every hop, 3 hops at most (0 for webhooks)
  • Timeout, response size cap, and content-type check
  • No incoming headers, cookies, or API keys sent to user-chosen URLs
  • Only the fields you need returned to the user or the model
  • Webhook URLs checked at save time and at send time
  • No hostname: '**' in images.remotePatterns, and dangerouslyAllowLocalIP off in production
  • Next.js on the latest patch of your major version, IMDSv2 required on any EC2 host

Copy-paste prompt for your AI coding tool

Paste this into Cursor, Claude Code, Lovable, Bolt, or v0. Share file names and code only, never keys, tokens, or .env contents.

Audit this project for server-side request forgery (SSRF). Find every place where server code (Route Handlers, Server Actions, API routes, Supabase Edge Functions, background jobs, AI tool or agent definitions, webhook senders, image or avatar import, PDF or screenshot generation, next.config images.remotePatterns) fetches or loads a URL that comes from a user, a database row a user can edit, or a model's output. For each one, report the file and line, where the URL comes from, and whether it: parses with new URL() and allows only https; uses an exact host allowlist where possible; resolves DNS and rejects private, loopback, link-local, unique local, carrier-grade NAT, multicast, 0.0.0.0 and IPv4-mapped forms for every address; connects only to the checked address with no second lookup; uses redirect: 'manual' and re-checks every hop with a hop limit; sets a timeout, a response size cap and a content-type check; and never forwards incoming headers, cookies or API keys. Replace unsafe fetches with one shared server-only safeFetch helper built on undici Agent with a custom connect lookup and ipaddr.js. Flag string checks such as includes, startsWith and regex on hostnames as unsafe. Check that hostname '**' is not used in images.remotePatterns and that dangerouslyAllowLocalIP is not true in production. List the Next.js version and any SSRF advisories that apply to it.

Review the diff, then try a few of the bypass URLs from this guide against your own app on your own machine. The security review prompt guide has more prompts like this, and common vulnerabilities in vibe-coded apps covers the rest of the usual suspects.

Sources

Questions

What is SSRF in simple terms?

Server-side request forgery is when your server fetches a URL that someone else chose. Your server sits inside a network the attacker cannot reach, so a URL like http://127.0.0.1/admin or http://169.254.169.254/ makes your server read internal pages, admin panels, or cloud credentials for them. Any feature that fetches a user's URL can do it: link previews, 'summarize this page' AI tools, image import by URL, webhooks, and PDF or screenshot generators.

Is my Next.js app on Vercel safe from SSRF?

Safer, not safe. Several Next.js SSRF bugs only hit self-hosted apps, and Vercel does not document a credential metadata endpoint for Functions. But a fetch(userUrl) in your own route still makes your server an open proxy, can reach anything you connected with Secure Compute, can leak any API key or token you attach to the request, and can follow a redirect anywhere. Validate every URL your code fetches, wherever it runs.

Is blocking 127.0.0.1 and localhost enough?

No. In our tests, 2130706433, 0x7f.0.0.1, 0177.0.0.1, 127.1, [::ffff:127.0.0.1], 0.0.0.0, a public hostname that resolves to 127.0.0.1, and a redirect from a public site all reached a local server past string and regex filters. Parse the URL, resolve the hostname, check every address against private and reserved ranges, connect only to the address you checked, and check again after every redirect.

Which npm package should I use to block SSRF?

Use a maintained parser for the IP ranges, such as ipaddr.js, and connect only to the address you checked, as the helper in this guide does with undici. request-filtering-agent does this for node-fetch, axios, and got, but not for Node's built-in fetch. Avoid ip and private-ip: both have open SSRF advisories with no fixed version. Whatever you pick, test it with the bypasses in this guide.

Do webhook URLs need SSRF protection?

Yes. A webhook URL is a URL a customer chose that your server will call again and again. Check it when they save it, so they get a clear error, and check it again every time you send, because the DNS behind it can change. Do not follow redirects, set a short timeout, cap the response size, and show the customer only the status code, not the response body.