CodeAuditAgent
All articles
  • Security
  • SSRF
  • Node.js

SSRF Defense for Webhooks, Link Previews and URL Fetchers

How SSRF turns webhooks, link previews and importers into a path to cloud metadata and internal services, and the defenses that hold, with a Node.js example.

· 7 min read · Lina Source LLC

Any feature that takes a URL from a user and fetches it from your server is a potential server-side request forgery (SSRF, CWE-918). Webhooks, link previews, avatar-from-URL, RSS and calendar imports, PDF renderers and "import from URL" buttons all share the same shape: your server makes a request to a destination chosen by someone else.

The problem is where your server sits. It can reach things the attacker cannot: the cloud metadata service, internal admin panels, databases without passwords on a private network, and services on localhost. SSRF turns your server into the attacker's proxy into that network. It is especially easy to introduce in small teams, because the feature that causes it looks harmless: a URL field in a settings page, a preview card in a chat, a helper that downloads a profile picture. None of these look like network security code, so they rarely get reviewed as such.

What an attacker aims for

  • Cloud metadata endpoints, most famously 169.254.169.254 on AWS, GCP and Azure, which can return instance details and, in some configurations, temporary credentials for the machine's role.
  • Services bound to localhost, such as admin interfaces, debug servers and metrics endpoints that assume only local callers.
  • Internal services on private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) that have no authentication because they were never meant to be reachable from outside.
  • Port scanning and service discovery, using response times or error messages to map the internal network.
  • Non-HTTP protocols, if the fetch library supports schemes like file:, gopher: or ftp:.

Even when the response is not returned to the attacker, a blind SSRF can still trigger state-changing requests against internal endpoints. Webhooks are often blind, and that does not make them safe. Many internal services accept simple GET requests that change state, such as cache purges or admin actions behind a URL, precisely because they assume nobody outside can reach them.

Why simple checks fail

The first instinct is to parse the URL and reject hostnames like localhost or strings that start with 10. or 192.168. This fails for several reasons.

  • Hostnames resolve to IPs. An attacker registers a domain whose A record points to 127.0.0.1 or 169.254.169.254, and a hostname check sees nothing wrong.
  • IP addresses have many spellings: decimal (2130706433), hex, short forms like 127.1, and IPv4-mapped IPv6 such as ::ffff:127.0.0.1. String matching misses them.
  • DNS rebinding: the domain resolves to a public IP when you validate it and to a private IP a moment later when the HTTP client connects. Validating and connecting are two separate lookups.
  • Redirects: the URL you validated returns a 302 to http://169.254.169.254/, and the HTTP client follows it without asking you.

The common thread is that validation happens on something other than the address the socket actually connects to. The fix is to check the resolved IP at connection time, for every connection, including after redirects.

Defenses, strongest first

Allowlist destinations when you can

If the feature only needs to talk to a known set of hosts, such as an integration with a handful of providers, allowlist those hostnames and reject everything else. This is the strongest control and the easiest to reason about. Compare the parsed hostname exactly, not with startsWith or endsWith checks, which accept look-alike hosts such as api.example.com.attacker.net or evilexample.com. Everything below is for features that must accept arbitrary public URLs.

Restrict scheme and port

Accept only https: (and http: only if you must). Reject credentials in the URL and restrict ports to 443 and 80 unless there is a clear need. Parse with a standard URL parser, never with a regex; the WHATWG parser also normalizes odd IP spellings, which helps later checks.

Resolve, then check the IP at connect time

Block loopback, private, link-local, carrier-grade NAT, unspecified and IPv6 unique-local and link-local ranges. Crucially, apply the check inside the DNS lookup the HTTP client uses, so the address that is validated is the address that is connected to. That closes the DNS rebinding gap. Node's http and https modules accept a custom lookup function for exactly this.

The block list below covers the ranges that matter for most deployments. Add any public ranges that belong to your own infrastructure, since a load balancer or internal API with a public IP can still trust requests from inside your network. Reject a hostname if any of its resolved addresses is blocked, not just the first one, because the client may try them in any order.

// safe-lookup.js
import dns from "node:dns";
import net from "node:net";

const blocked = new net.BlockList();
blocked.addSubnet("0.0.0.0", 8, "ipv4");
blocked.addSubnet("10.0.0.0", 8, "ipv4");
blocked.addSubnet("100.64.0.0", 10, "ipv4");
blocked.addSubnet("127.0.0.0", 8, "ipv4");
blocked.addSubnet("169.254.0.0", 16, "ipv4");
blocked.addSubnet("172.16.0.0", 12, "ipv4");
blocked.addSubnet("192.168.0.0", 16, "ipv4");
blocked.addAddress("::", "ipv6");
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("::ffff:0:0", 96, "ipv6"); // IPv4-mapped
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");

export function isBlockedIp(address, family) {
  return blocked.check(address, family === 6 ? "ipv6" : "ipv4");
}

// Validates every resolved address before the socket connects
export function safeLookup(hostname, options, callback) {
  dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
    if (err) return callback(err);
    const denied =
      addresses.length === 0 ||
      addresses.some((a) => isBlockedIp(a.address, a.family));
    if (denied) {
      return callback(new Error("Destination not allowed: " + hostname));
    }
    if (options.all) return callback(null, addresses);
    callback(null, addresses[0].address, addresses[0].family);
  });
}

One detail is easy to miss: when the URL contains an IP literal, Node connects directly without calling lookup at all. So the request function has to check IP literals itself before handing off.

import https from "node:https";
import net from "node:net";
import { isBlockedIp, safeLookup } from "./safe-lookup.js";

export function postWebhook(rawUrl, payload) {
  const url = new URL(rawUrl);
  if (url.protocol !== "https:") throw new Error("Only https is allowed");
  if (url.port && url.port !== "443") throw new Error("Port not allowed");
  if (url.username || url.password) throw new Error("Credentials not allowed");

  const host = url.hostname.replace(/^\[|\]$/g, "");
  const family = net.isIP(host);
  if (family !== 0 && isBlockedIp(host, family)) {
    throw new Error("Destination not allowed");
  }

  return new Promise((resolve, reject) => {
    const req = https.request(
      url,
      {
        method: "POST",
        lookup: safeLookup,
        timeout: 5000,
        headers: { "content-type": "application/json" },
      },
      (res) => {
        // node:https never follows redirects; a 3xx is treated as a failure
        res.resume();
        resolve(res.statusCode);
      }
    );
    req.on("timeout", () => req.destroy(new Error("Request timed out")));
    req.on("error", reject);
    req.end(JSON.stringify(payload));
  });
}

If you use fetch in Node, which is built on undici, the same idea applies through a custom dispatcher: create an undici Agent with a connect.lookup option and pass it as the dispatcher. The principle does not change: the check lives where the connection is made. If your environment routes outbound traffic through an HTTP proxy, note that the lookup then happens on the proxy for the target host, so the proxy itself must enforce the same rules.

Disable redirects, or re-validate each hop

For webhooks, do not follow redirects at all; a receiver that redirects is misconfigured, and failing loudly tells the customer to fix their endpoint. For link previews and importers, where redirects are normal, follow them manually with a small limit and run every hop through the same scheme, port and IP checks. With fetch, set redirect: "manual" and handle the Location header yourself.

Limit what a successful request can do

  • Set connect and total timeouts, and cap the response size, so the fetcher cannot be used to hold connections open or pull large files.
  • Do not return raw responses or detailed error messages to the user. For link previews, return only the extracted title, description and image URL.
  • Strip your own authentication headers and cookies; the fetcher should never send internal credentials to user-supplied hosts.

Route through an egress proxy

The most robust setup moves the policy out of application code. Run outbound user-driven fetches through a dedicated egress proxy, or from an isolated worker in a network segment that simply has no route to internal services or the metadata endpoint. Then a bug in URL validation does not become a breach, because the network itself refuses. Open-source forward proxies can enforce destination rules for you, and a separate worker also isolates slow or hostile responses from your main web process.

Harden the metadata service

On AWS, require IMDSv2, which needs a session token obtained with a PUT request, and keep the hop limit at 1 so containers cannot reach it through the host. GCP and Azure require a specific request header on metadata calls. These raise the bar considerably for simple GET-based SSRF, but they are a second layer, not a substitute for blocking link-local addresses. Also give the instance or service role only the permissions the app needs, so credentials stolen through the metadata service unlock as little as possible.

Testing your defenses

  • Try http://127.0.0.1/, http://[::1]/, http://2130706433/, http://127.1/ and http://169.254.169.254/latest/meta-data/ and confirm each is rejected.
  • Point a hostname you control at 127.0.0.1 and confirm the lookup check rejects it. Then give it two A records, one public and one private, and confirm it is still rejected.
  • Serve a 302 redirect to a private address from a public host and confirm it is not followed.
  • Try file:, ftp: and gopher: URLs, and URLs with credentials or unusual ports.

In code review, search for every place a request URL is built from input: fetch, axios, got, requests, http.Get, headless browser page.goto calls and image processing libraries that accept URLs. CodeAuditAgent traces that data flow in public repositories and pasted snippets and reports SSRF as CWE-918 with the quoted call site, an exploit scenario and a patch.