Skip to content

SSRF Through Customer-Supplied URLs

8 min read · updated August 4, 2026

Server-side request forgery is not a bug in a URL parser. It is what happens whenever a customer names an address and the server fetches it, because the server sits somewhere on the network the customer does not. The fix is not a better regular expression over the hostname; it is resolving the name and checking the address you are about to connect to, on the first request and on every redirect after it.

The shape of the bug

A product accepts an endpoint from a customer, stores it, and later issues an HTTP request to it from a machine inside a private network. That machine can reach things the customer cannot: a cloud metadata service on 169.254.169.254, an admin interface on 10.0.x.x, a database on loopback, a colleague’s tenant across a flat VPC. The customer’s URL is a remote-control for the server’s network position.

The check almost everybody writes first looks like this, and it is worth reading closely because it is not stupid — it is in the wrong place.

// Validated once, at creation, against the string the customer typed.
const host = new URL(input).hostname;
if (
  host === "localhost" ||
  /^127\./.test(host) ||
  /^10\./.test(host) ||
  /^192\.168\./.test(host)
) {
  return "That address is on a private network.";
}
await db.insert(webhooks).values({ url: input });

// ...and hours later, from a delivery job:
await fetch(row.url, { method: "POST", body });

Two properties make this fail. It inspects a name when the thing that decides where the packet goes is an address, and the two are not connected until DNS resolves — which happens inside fetch, long after the check ran. And it runs at creation, when the answer can still change afterwards. A security control that evaluates a different input at a different time from the operation it is meant to guard is not a control; it is a form-validation message.

Where the URLs come from

The webhook field is the famous one, and it is rarely the only one. The question to ask of any codebase is: how many places take a URL from outside the system and pass it to an HTTP client? In an AI product the list is longer than it used to be.

  • Webhook endpoints. The canonical case. The customer supplies a URL, you POST an event body to it, and you probably attach a signature header.
  • Telemetry and trace exporters. An OTLP endpoint field lets a customer point their spans at Honeycomb, Datadog, or a collector of their own. Same shape, less scrutiny, and the request carries the customer’s vendor token.
  • Avatar and asset imports. “Import from URL” on a profile picture is an SSRF primitive that also stores and re-serves the response, which turns a blind fetch into one whose body you can read back.
  • Tool and function definitions. An agent platform that lets a user register an HTTP tool has made the tool’s base URL customer-supplied, and the agent will call it with whatever credentials the platform attaches. The general problem of what an agent is permitted to invoke is covered in securing tool calls; this is the network half of it.
  • OpenAPI and MCP server URLs. A schema fetched from a customer-named host, to decide what tools exist. The fetch happens before any of the schema is trusted, which is exactly when nobody is thinking about the network.
  • Retrieval and “summarise this page”. The URL arrives in a prompt rather than a settings form, which changes nothing about the fetch and removes the settings form’s validation entirely. Worse, the URL can arrive from a document the model read rather than from the user, so “customer-supplied” becomes “supplied by anyone who can get text in front of the model”.

These want one shared client, not six validators. The number of places that call fetch with a non-constant host should be small enough to name, and each of them should be calling the same wrapper.

Four ways the hostname check is walked past

DNS, which needs no cleverness at all

https://collector.attacker.example/ passes every pattern in the snippet above. The attacker points its A record at 169.254.169.254 and waits. There is no race, no encoding trick and no timing: it is one record in a zone they control. TLS does not help, because a certificate attests to the hostname, and the hostname is genuinely theirs. The connection succeeds, the certificate validates, and the request lands on the metadata service.

This is the bypass that matters most, and it is the one a string check can never see, because the string is correct. Nothing about collector.attacker.example is suspicious until you ask a resolver.

Redirects

fetch follows 3xx responses by default, and so do most HTTP clients in most languages. A perfectly ordinary public host answering 302 Location: http://169.254.169.254/latest/meta-data/ walks straight past a check that only ever examined the URL that was configured. The redirect target is a URL nobody validated, requested by a client that was told to be helpful.

IPv6, usually because nobody wrote any

https://[::1]/ gives a hostname of [::1], and https://[fd00::1]/ one of [fd00::1]. Neither matches an IPv4 pattern, so loopback and unique-local space is reachable simply by writing the address the other way round. The embedded forms are the ones a checker written by hand tends to drop: ::ffff:127.0.0.1 is IPv4-mapped, 64:ff9b::127.0.0.1 is NAT64, and ::169.254.169.254 is the deprecated IPv4-compatible form from RFC 4291 §2.5.5.1. All three carry a v4 address in the low 32 bits and all three will reach it.

Decimal and hex literals, which are usually a myth

The lists of “SSRF bypasses” that circulate all include 2130706433, 0x7f000001 and 127.1. Whether these work against a given codebase depends on one thing: whether the check reads the hostname from a WHATWG-compliant URL parser. That parser normalises all three to 127.0.0.1 before hostname is read, so the naive check sees the dotted quad and refuses it.

That normalisation is load-bearing rather than incidental. A codebase that swaps new URL() for a hand-rolled split on / and : — which happens, usually in a logging or metrics helper that later gets reused for validation — brings all three straight back. The right conclusion is not “those forms are harmless” but “those forms are handled by a component you must not replace”.

Resolve, then connect

There are two different controls here and they are frequently conflated. Which one you get depends on a product question, not a security one.

ControlDescription
destination allowlistAn enumerated set of hosts the product may talk to. The strongest control by a wide margin — nothing else has to be right — and available only when the destination is not the customer's to choose. Applies to payment providers, model providers and internal services; does not apply to a webhook feature, whose entire purpose is that the customer picks the endpoint.
scheme allowlisthttps and nothing else, checked on every hop. This one is always available. file:, gopher:, ftp:, dict: and redis: are protocol-confusion primitives, and a redirect down to plain http puts the body — and any signature header — on the wire in clear.
address deny-listThe set of IP ranges no customer endpoint can legitimately live in. A deny-list rather than an allowlist because the legitimate space is 'the public internet', which cannot be enumerated. Correct only if it is applied to a resolved address rather than to a name.
resolve-then-connectLook up every address the name resolves to, refuse if any of them is in the deny-list, then issue the request. This is the step that turns the deny-list from a string test into a network control, and it is the one the naive version is missing.

The order matters. A deny-list applied to a hostname is theatre; the same deny-list applied to the output of a resolver is the actual boundary. And every address the name resolves to has to be checked, not just the first, or a name with one public and one private A record becomes a coin flip decided by resolver ordering — a working export most of the time and a request into your own network the rest.

The loop that follows redirects by hand is the other half. Set redirect: "manual" precisely so that each Location gets the same treatment as the original URL.

const MAX_REDIRECTS = 3;

export async function safeFetch(target, init) {
  let url = new URL(target);
  const origin = url.origin;
  let headers = init.headers;

  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
    // https only, on every hop; resolve the name; refuse if ANY address
    // it resolves to is in private, link-local or reserved space.
    const problem = await hopProblem(url, hop > 0);
    if (problem) return { ok: false, status: null, error: "Refused: " + problem };

    const res = await fetch(url, { ...init, redirect: "manual" });

    const location =
      res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
    if (!location) {
      // Our own words. The destination's body is deliberately never read.
      return { ok: res.ok, status: res.status, error: res.ok ? null : "Endpoint answered " + res.status };
    }
    if (hop === MAX_REDIRECTS) {
      return { ok: false, status: res.status, error: "Too many redirects" };
    }

    url = new URL(location, url);
    // Leaving the origin we were configured with takes the secrets off.
    if (url.origin !== origin) headers = stripCredentials(headers);
  }
}

A creation-time check on the URL string still earns its place, but as a user-experience feature rather than a boundary: telling somebody that the address they just typed is on a private network, while they are still looking at the field, is worth a great deal more than the same discovery buried in a delivery log an hour later. The comment on such a function should say plainly that it is not the security control, or the next person to read it will assume it is.

The ranges that must be refused

The obvious three RFC 1918 blocks are the smallest part of the list. The prize is 169.254.0.0/16, where every major cloud’s metadata service lives.

RangeDescription
169.254.0.0/16Link-local, and the actual target of most SSRF attempts: 169.254.169.254 answers instance credentials on more than one cloud. If only one range is checked, it is this one.
10/8, 172.16/12, 192.168/16RFC 1918 private space. Your own services, and on a flat network somebody else's.
127/8 and 0/8Loopback and the unspecified address. Loopback reaches anything bound to the local interface, which is usually the things nobody put authentication on.
100.64.0.0/10Carrier-grade NAT, which on some hosting providers reaches other tenants.
224/4 and 240/4Multicast and reserved. Not a collector anyone meant.
::1, fc00::/7, fe80::/10IPv6 loopback, unique-local and link-local. The same three categories as above, written the way the naive checker cannot see.
::ffff:0:0/96 and 64:ff9b::/96IPv4-mapped and NAT64. Judge these by the v4 address in the low 32 bits, not by the prefix, or ::ffff:169.254.169.254 passes as ordinary IPv6.
Match the prefix length exactly. The special assignments inside 192.0.0.0/16, 198.51.0.0/16 and 203.0.0.0/16 are each a single /24, and the rest of those /16s is ordinary allocated space with real hosts in it. Widening a check to the second octet reads as the tidier version and silently refuses a customer’s genuine public endpoint with “that is a documentation address” as the explanation. Over-blocking here is invisible to you and unfixable by them.

Two leaks that are not about addresses

The response body. Handing the destination’s response back to the customer is helpful when the destination is their own collector, and it is an exfiltration primitive when it is something inside your network that got reached anyway. It converts a blind SSRF — where the attacker learns only that something answered — into a full read. Return the status code, which carries the part a customer can act on (401 is a token, 404 is a path, 413 is a payload), and never the bytes.

Credentials across a redirect. Most of these requests carry a secret: a webhook signature, or the customer’s vendor token on a telemetry export. Replaying it at whatever host a 302 names hands that secret to the redirect target. Browsers and curl both strip credential headers on a cross-origin redirect for exactly this reason, and a manual redirect loop has to do it explicitly:

const CREDENTIAL_HEADERS =
  /^(authorization|proxy-authorization|cookie|x-api-key|api-key|.*-token|.*-secret)$/i;

function stripCredentials(headers) {
  return Object.fromEntries(
    Object.entries(headers).filter(([name]) => !CREDENTIAL_HEADERS.test(name)),
  );
}

A vendor that genuinely redirects across origins is asking to be configured with its final URL instead. That is a support answer, not a reason to weaken the rule. The broader habit — that a secret goes only to the party it was minted for — is the subject of secrets management, and a redirect is one of the quieter ways it gets broken.

What this does not close

Resolve-then-connect leaves a gap, and a page that does not name it is selling something. Between the resolver call and the socket connect there is a second lookup — the one the HTTP client does itself — and a DNS record with a very short TTL can answer differently on each. The validator sees a public address; the connection goes to a private one. This is DNS rebinding applied to a server rather than a browser, and it is a genuine time-of-check-to-time-of-use bug in the design above.

Closing it requires pinning the socket to the address that was validated, which means reaching under the HTTP client: a custom dispatcher in Node, a custom DialContext in Go, a resolver hook in Python’s requests. That is real work and it adds a dependency in some runtimes, which is why plenty of production implementations stop short of it.

The two honest positions are to pin the socket, or to write the residual down where the next reader will find it. What is not honest is describing resolve-then-connect as complete. It raises the cost of the attack from “publish an A record” to “win a race against a resolver you do not control”, which is a large improvement and not a closure.

Two other things stay open regardless of the address logic. A request that is allowed can still be used as an amplifier or a port scanner if timing differences leak — which is one argument for a fixed, short timeout and no distinct error for “refused” versus “no answer”. And none of this addresses what the destination does with the payload, which is the province of data exfiltration rather than of the network.

The test that pins it

The classifier is a pure function from an address string to a reason or null, which makes it the easiest security control in a codebase to test properly. Table-driven, and the table is the specification:

const CASES = [
  // [address, must be refused?]
  ["203.0.113.1",              true ],  // TEST-NET-3
  ["203.0.200.1",              false],  // same /16, ordinary public space
  ["169.254.169.254",          true ],  // the prize
  ["10.0.0.1",                 true ],
  ["172.15.0.1",               false],  // just outside 172.16/12
  ["172.16.0.1",               true ],
  ["100.64.0.1",               true ],  // CGNAT
  ["8.8.8.8",                  false],
  ["::1",                      true ],
  ["fd00::1",                  true ],  // unique-local
  ["fe80::1",                  true ],  // link-local
  ["::ffff:127.0.0.1",         true ],  // IPv4-mapped
  ["::ffff:8.8.8.8",           false],  // mapped, but public
  ["64:ff9b::169.254.169.254", true ],  // NAT64
  ["::169.254.169.254",        true ],  // IPv4-compatible, RFC 4291 2.5.5.1
  ["2606:4700::1111",          false],
];

for (const [address, refused] of CASES) {
  assert.equal(Boolean(addressProblem(address)), refused, address);
}

Three of those rows are the ones that catch a regression rather than a bug: 203.0.200.1, 172.15.0.1 and ::ffff:8.8.8.8 all pass today and would fail the moment somebody widens a prefix to the tidier boundary. A deny-list without false-positive cases in its test suite will drift toward refusing legitimate customers, and nobody will notice, because the customer sees an error message and leaves.

Two more assertions belong in the same file and cannot be written as a table. That the delivery path calls the shared wrapper rather than fetch — a grep assertion over the source is unglamorous and catches the reintroduction that a unit test cannot. And that the redirect handler re-runs the address check, which is testable against a local server that answers 302 with a private Location. If the suite only proves the classifier is right, it is proving the easy half.