All posts
VibeZero Team6 min read

SSRF, explained

Server-side request forgery turns your backend into the attacker's proxy. Why blocklists fail, what DNS rebinding defeats, and the validation order that works.

  • security
  • vibe coding
  • engineering

Server-side request forgery is what happens when your server fetches a URL that someone else chose. It sounds minor until you notice the asymmetry: your server sits inside the network, holds credentials, and is trusted by things that would never answer a request from the open internet. An attacker who can point it at an address of their choosing has borrowed all of that.

It is A10 in the OWASP Top 10, added in 2021 on the strength of community survey data rather than incidence, because when it lands the consequences are severe out of proportion to how often it appears.

The mechanism

Any feature where the user supplies a URL and the backend retrieves it:

  • Import from a link
  • Avatar or image fetched from a remote address
  • Webhook delivery to a customer-supplied endpoint
  • PDF or screenshot rendering of a page
  • Link preview generation
  • An SSO or OIDC discovery document

The naive implementation is one line, and it is the whole bug:

// Vulnerable. The user chose the destination.
const response = await fetch(userSuppliedUrl);
return await response.text();

Now point it inward. http://169.254.169.254/latest/meta-data/iam/security-credentials/ is the AWS instance metadata endpoint, reachable only from the instance itself, and on IMDSv1 it answers without authentication. That address is what turned an SSRF into the Capital One breach, which exposed data on roughly 100 million US applicants and customers in 2019. The same class reaches http://localhost:6379 for an unauthenticated Redis, internal admin panels on private ranges, Kubernetes service addresses, and any other host that was only ever protected by not being routable.

Why blocklists do not work

The instinct is to reject localhost and 127.0.0.1. That fails immediately, and enumerating the reasons is the fastest way to understand the correct approach.

The address space is bigger than the obvious entries. 127.0.0.1 has 127.0.0.2 through 127.255.255.254 behind it. There is 0.0.0.0, the IPv6 loopback [::1], IPv4-mapped IPv6 [::ffff:127.0.0.1], and the link-local range that holds cloud metadata.

Encodings multiply every entry. Decimal (2130706433), octal (0177.0.0.1), hex (0x7f000001), and mixed forms all resolve to loopback in most resolvers.

DNS is attacker-controlled. A hostname on a domain the attacker owns can have an A record pointing at 127.0.0.1. No string check on the URL will see that, because the URL looks entirely ordinary.

Redirects move the target after validation. You validate https://harmless.example/x, it answers 302 to http://169.254.169.254/, and your HTTP client follows it by default.

And the resolution can change between your check and your fetch. That is DNS rebinding: the name resolves to a public address when you validate and a private one a moment later when you connect. Any design that validates a hostname and then hands the hostname to the HTTP client has this race, no matter how good the validation is.

The validation order that works

Our own outbound webhook dispatcher has to solve this, because the entire feature is “POST to a URL the customer typed,” which is SSRF as a product requirement. The order it uses, in src/services/webhook_dispatch.rs:

  1. Scheme allowlist: https only. Not a blocklist of file://, gopher://, and dict://, an allowlist of the one scheme the feature needs.
  2. Port allowlist: 443 and 8443. An internal service on 6379, 3306, 9200, or 8080 is unreachable by construction rather than by enumeration.
  3. No embedded credentials. A URL like https://user:pass@internal.example/ is refused, since the userinfo section is a classic parser-confusion vector.
  4. Resolve the host, and reject if any answer is non-public. Every address in the DNS response is checked, not just the first, because a name can return one public and one private address and a client may pick either.
  5. Pin the connection to the validated address. The HTTP client connects to the IP that passed step 4, not to the hostname. This is what closes the rebinding race in step 4.
  6. Disable redirect following. A redirect is a new destination, so it would need the whole checklist again. Refusing is simpler and the feature does not need them.
  7. Re-validate on every attempt. Retries and re-deliveries run the full check again, because DNS at attempt three is not DNS at attempt one.

Step 5 is the one most implementations miss, and it is the one that makes the rest hold. Validating an address and then connecting by name means you validated one thing and fetched another.

In code

Node, using the pinning approach:

import dns from "node:dns/promises";
import ipaddr from "ipaddr.js";

const ALLOWED_PORTS = new Set(["443", "8443"]);

function isPublic(address) {
  const parsed = ipaddr.parse(address);
  // 'unicast' is the only range that is genuinely routable on the internet.
  return parsed.range() === "unicast";
}

export async function validateTarget(rawUrl) {
  const url = new URL(rawUrl);
  if (url.protocol !== "https:") throw new Error("https required");
  if (url.username || url.password) throw new Error("credentials not allowed");
  const port = url.port || "443";
  if (!ALLOWED_PORTS.has(port)) throw new Error("port not allowed");

  const answers = await dns.lookup(url.hostname, { all: true });
  if (answers.length === 0) throw new Error("no address");
  for (const { address } of answers) {
    if (!isPublic(address)) throw new Error("non-public address");
  }
  // Connect to this address, not to url.hostname.
  return { address: answers[0].address, port, host: url.hostname };
}

Then make the request against address with the Host header set to host, and with redirects off. In Node that means a custom agent with a lookup that returns the pinned address; most HTTP clients in most languages have an equivalent hook, and if yours does not, that is a reason to change clients rather than to skip the step.

Testing it

Stand up a listener you control and see whether the server calls it. That proves the fetch happens at all, which is the precondition for everything else:

# Any request-catching service works; a plain netcat on a public box is fine.
curl -X POST https://your-app.example/api/import \
  -H 'content-type: application/json' \
  -d '{"url":"https://your-listener.example/probe"}'

If the probe fires, work through the bypass list against your own validator: loopback in decimal form, a hostname you control with a private A record, a public URL that redirects to metadata, and a URL with a non-standard port. Each one that gets through is a specific missing step above.

Where it connects

SSRF is the network-layer member of the same family as the injection bugs: a value the user controls reaching a position that was supposed to be decided by your code. The difference is that the grammar being injected into is the address space of your private network.

It compounds badly with other findings. An SSRF plus verbose error messages becomes a port scanner, because the difference between “connection refused” and “timeout” in the response body maps the internal network. An SSRF plus a service with default credentials is a full compromise. And an SSRF reachable by an AI agent is one of the more direct routes from prompt injection to real damage, because “fetch this URL” is a tool many agents legitimately have.

The four-line version: allowlist the scheme and port, resolve the host and reject every non-public answer, connect to the resolved address rather than the name, and refuse redirects.

ShareXLinkedIn