API & Automation·8 min read·

Catching SSRF When Your API Fetches URLs

Prevent server-side request forgery in APIs that fetch URLs by validating schemes, blocking metadata ranges, and restricting egress.

NB

Netbay Cloud Team

Netbay Engineering

On this page

Server-Side Request Forgery (SSRF) is what happens when an attacker controls the URL your server fetches. Your well-intentioned "preview this link" or "import this webhook" endpoint suddenly becomes a way to reach your internal metadata service, scan your private network, or probe local services. This post explains how SSRF works and the layered defenses that keep it in check.

The attack in one line

The victim is not you the client — it is your *server*. When your backend does 'requests.get(user_input_url)', the attacker points that request at internal addresses: 'http://169.254.169.254/' for cloud metadata, 'http://127.0.0.1:6379/' for a local Redis, or 'http://10.0.0.5/' for a neighboring node. Your server is already *inside* the network, so it can reach things the attacker cannot.

The danger is amplified because these fetches carry your service's credentials and run from a trusted position.

Consider how common this pattern has become: link previews that unfurl a title and image, PDF or image fetch tools, webhook validation that pings a callback URL, SSO metadata importers, and ingress controllers that proxy to a configured upstream. In each case, a field that was meant to hold an external URL is silently reinterpreted as an internal reference. The classic high-value target is the cloud metadata endpoint that hands out keys and other secrets to any local caller, which is precisely why it is on every SSRF blocklist. The same trick can reach Redis and Elasticsearch interfaces that are exposed on the loopback without authentication, or a services registry that reveals internal topology.

attacker supplies fetch URL your API fetches server-side internal target 169.254.169.254 blocklist + allowlist + egress rules resolve DNS, re-check IP after redirects

Defense layers

  1. **Scheme and host allow-list.** Only permit https, and only to hosts/domains you expect. Reject the rest early.
  2. **Resolve-then-check.** DNS can point a friendly hostname at an internal address. Resolve the host, validate the resulting IP is not private/link-local/metadata, then fetch by IP, not the original name.
  3. **Block metadata and anycast ranges.** Hard-deny 169.254.169.254, 169.254.0.0/16, 0.0.0.0/8, and all non-internet ranges.
  4. **Re-check after redirects.** A server may respond with a 302 to an internal URL. Follow redirects only through the same sanitizer.
  5. **Egress firewalling.** The strongest control is to make it *impossible*: restrict outbound traffic at the network layer so your appliance only reaches the few public hosts it must.

Layering matters: a scheme-only check is trivially bypassed by a redirect, a host-only check is bypassed by a DNS record that resolves internally, and a resolve-once check is bypassed by DNS rebinding where the second lookup returns a private address. That is why the order above goes from cheap parser checks to the authoritative network backstop — each layer closes the hole a previous layer cannot, and the egress rule is the one that holds even when a clever payload slips through the parser.

A redirect-safe validation helper

python
import ipaddress, socket, urllib.parse

PRIVATE_CANDIDATES = (
    ipaddress.ip_network("10.0.0.0/8"),
    ipaddress.ip_network("172.16.0.0/12"),
    ipaddress.ip_network("192.168.0.0/16"),
    ipaddress.ip_network("169.254.0.0/16"),
    ipaddress.ip_network("127.0.0.0/8"),
)

def safe_target(url):
    p = urllib.parse.urlparse(url)
    if p.scheme not in ("https",):
        raise ValueError("https only")
    host = p.hostname
    for addr in socket.getaddrinfo(host, None):
        ip = ipaddress.ip_address(addr[4][0])
        if ip.is_private or ip.is_link_local or ip.is_loopback:
            raise ValueError("internal address blocked")
    return p

Resolving against an allow-list at the app layer

go
func resolveAndCheck(host string) (string, error) {
    if !allowedDomain(host) {
        return "", errors.New("domain not allowed")
    }
    addrs, err := net.LookupIP(host)
    if err != nil { return "", err }
    for _, ip := range addrs {
        if isForbiddenIP(ip) {
            return "", fmt.Errorf("resolved to forbidden ip %s", ip)
        }
    }
    return host, nil
}

func isForbiddenIP(ip net.IP) bool {
    return ip.IsLoopback() || ip.IsPrivate() ||
        ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
}

Do not forget egress

No amount of in-app validation stops a determined bypass via unusual encodings, redirect chains, or DNS rebinding. Put your real assurance in egress rules: restrict the fetch service to a small, explicit set of destinations so that even a missed check cannot reach the metadata endpoint. That way the network is the backstop, not your parser.

Takeaway

Treat any URL your API fetches as hostile input. Validate scheme and host, resolve and re-check the IP after redirects, block metadata ranges, and back it all with strict egress rules. You can test these protections by pointing an API-backed VPS at sketchy URLs — deploy one on Netbay at netbayhosts.in and harden it in minutes.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance