LEAKCADE

SSRF in URL fetch features: link previews, webhooks and AI summarizers

Updated 6 min read

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:

How one pasted URL reaches your internalsAn attacker pastes the metadata address into a link preview form. Your API fetches it unchecked, reaches internal systems, and returns the response.ATTACKERPasteshttp://169.254.169.254/YOUR APIfetch(url) withno checksINTERNALMetadata,localhost, adminpagesLEAKResponse echoedback to attackerATTACKERPastes http://169.254.169.254/YOUR APIfetch(url) with no checksINTERNALMetadata, localhost, admin pagesLEAKResponse echoed back to attacker

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:

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.

Most first versions look like the bad route below. The fixed one calls a safeGet helper you'll build next.

BEFOREFetches anything, returns everything
// 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
}
AFTERChecked fetch, one field back
// 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 good route blocks internal addresses, caps time and size, and returns only the title.

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.

  1. 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.hostname exactly. Never use startsWith or includes on the raw string, because https://expected-host.evil-host passes 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.

  2. 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.BlockList does 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")
    }
  3. Check the address at connection time

    Do the DNS lookup inside the connection itself by passing a custom lookup function 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 through safeGet. 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)
      })
    }
  4. Parse strictly, skip redirects, cap time and size

    The helper applies these rules:

    • Allow only the http: and https: schemes, ports 80 and 443, and no credentials in the URL. OWASP notes SSRF can use schemes like file:// and gopher://.
    • Check IP literals like http://127.0.0.1/ directly, since they skip DNS.
    • Don't follow redirects, as OWASP advises. Node's fetch follows them by default, so the helper uses node:http and 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)
      })
    }
  5. 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
done

A 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:

Fix prompt you can paste

Give this to Cursor or Claude Code at the root of your repo.

FIX PROMPT
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.

TrickWhat it looks likeWhat stops it
Direct internal URLhttp://169.254.169.254/ or 127.0.0.1Block private ranges after DNS resolution
Odd IP spelling2130706433, 127.1, 017700000001Parse with URL, then check the resolved IP
URL parser trickshttps://good@evil, https://evil#goodCompare url.hostname exactly
RedirectAllowed site returns 302 to an internal URLDon't follow redirects
DNS rebindingName resolves public, then privateConnect 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

  1. OWASP: Server-Side Request Forgery Prevention Cheat Sheet
  2. PortSwigger Web Security Academy: What is SSRF?
  3. 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 repo

Leakcade 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

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.