SSRF: The Vulnerability That Turns Your Server Against Its Own Network

Your webapp has a feature where users can attach an image by URL. A user submits https://example.com/logo.png and the backend fetches it. Harmless — until someone submits http://169.254.169.254/latest/meta-data/iam/security-credentials/ and your server, running on AWS, helpfully fetches its own IAM credentials and returns them in the response body. That’s Server-Side Request Forgery: tricking a server into making requests it should never make, to places it should never reach.

SSRF earned its own category in the OWASP Top 10 precisely because cloud adoption made it dramatically more dangerous. Modern infrastructure is full of internal HTTP services — metadata endpoints, admin panels on localhost, service meshes, health checks — that assume only trusted processes can reach them. A single SSRF bug collapses that assumption.

Why It’s Harder Than It Looks

The naive defense is a blocklist: check the URL, reject anything with localhost, 127.0.0.1, or 169.254.169.254, fetch the rest. This fails for a stack of reasons, and each one is a class of bypass that attackers use daily.

IP literals and encodings. 127.0.0.1 has equivalent spellings: 127.1, 0x7f000001, 2130706433 (the decimal integer), 0177.0.0.1 (octal). URL parsers and resolvers don’t always agree on how these normalize, and any disagreement is a bypass.

DNS rebinding. The attacker registers a domain with a very low TTL that alternates between a public IP and 127.0.0.1. You validate the URL at request time — DNS resolves to a public address, check passes. Your HTTP client then fetches the URL, DNS resolves again — this time to loopback. The validation and the fetch saw different IPs. CWE-918 explicitly covers this TOCTOU gap between checking and fetching.

Redirects. You validate the initial URL, it points at an attacker-controlled server, which responds with 302 Location: http://169.254.169.254/.... Default HTTP clients follow redirects automatically. Your validation never saw the second URL.

Schemes. The URL field accepts more than http and https. Depending on the client library, file:///etc/passwd, gopher://, or dict:// handlers may be reachable. Gopher is the notorious one: it can serialize arbitrary bytes into a TCP stream, which historically allowed SSRF-to-internal-service exploitation far beyond HTTP.

The common thread: validating a URL is not the same as constraining a connection. The URL is a string; the connection is what matters, and strings get reinterpreted between the check and the socket.

The Defense That Actually Works: Pin DNS at the Socket Layer

The robust pattern is to resolve the hostname yourself, validate the resolved IP, and then force the HTTP client to connect to that exact IP — eliminating the TOCTOU window. In Go, that means a custom DialContext:

package fetcher

import (
	"fmt"
	"net"
	"net/http"
	"net/url"
	"syscall"
	"time"
)

// safeDialer resolves the host, validates every returned IP,
// and dials the validated IP directly. The HTTP client never
// performs its own DNS lookup, so validation and connection
// are guaranteed to see the same address.
func safeDialer(allowed func(net.IP) bool) *net.Dialer {
	return &net.Dialer{
		Timeout: 5 * time.Second,
		Resolver: &net.Resolver{
			PreferGo: true,
		},
		Control: func(network, address string, _ syscall.RawConn) error {
			host, _, err := net.SplitHostPort(address)
			if err != nil {
				return err
			}
			ip := net.ParseIP(host)
			if ip == nil {
				return fmt.Errorf("non-IP address reached dialer: %s", host)
			}
			if !allowed(ip) {
				return fmt.Errorf("connection to %s blocked by egress policy", ip)
			}
			return nil
		},
	}
}

func isPublicIP(ip net.IP) bool {
	return ip != nil &&
		!ip.IsLoopback() &&
		!ip.IsPrivate() &&
		!ip.IsLinkLocalUnicast() &&
		!ip.IsLinkLocalMulticast() &&
		!ip.IsUnspecified()
}

func NewSafeClient() *http.Client {
	dialer := safeDialer(isPublicIP)
	return &http.Client{
		Timeout: 10 * time.Second,
		Transport: &http.Transport{
			DialContext: dialer.DialContext,
		},
		// Do not follow redirects. If you must, re-validate
		// each hop yourself (see below).
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
}

The key detail is the Control hook on the dialer: it runs immediately before the socket is created, with the literal address about to be connected. Nothing reaches a socket without passing the policy check. Combined with the custom resolver, the DNS-rebinding window closes entirely — the IP you validated is the IP you connect to.

One subtlety: the dialer connects to the validated IP, but the TLS handshake and the Host header still use the original hostname, so HTTPS and virtual hosting keep working. You get the validation without breaking the protocol.

Redirects and Other Perimeter Details

With redirects, the simplest correct policy is to not follow them — the CheckRedirect above returns http.ErrUseLastResponse, handing the 3xx back to your code. If the product genuinely needs redirect following, re-validate the destination of each hop before requesting it, and cap the chain length. The destination hostname goes through the same resolve-validate-dial pipeline, so the Control hook protects you even on hop three.

Restrict schemes at parse time, before any network activity:

u, err := url.Parse(rawURL)
if err != nil {
	return err
}
if u.Scheme != "http" && u.Scheme != "https" {
	return fmt.Errorf("scheme %q not allowed", u.Scheme)
}

Defense in depth belongs below the application too. Cloud environments let you enforce egress rules at the network layer: security groups or network policies that deny instances access to the metadata service unless it’s explicitly needed, and IMDSv2 on AWS, which requires a token obtained via PUT before the metadata endpoint answers — a GET-based SSRF can’t retrieve it. Application validation can have bugs; network segmentation is what contains the blast radius when it does.

What Not to Rely On

  • Blocklists of hostnames. Encodings, aliases, and parser disagreements make them indefinitely bypassable. Validate IPs at connection time instead.
  • Fetching first, checking after. By the time you’ve made the request, the internal service already saw it. With GET this is bad enough; POST is worse.
  • Checking the response body. Filtering the response reduces disclosure, but the request already happened. Internal state-changing endpoints don’t need to return anything for the damage to be done.
  • Assuming internal services authenticate. Metadata endpoints, metrics exporters, and admin panels frequently assume network locality equals trust. SSRF breaks exactly that assumption.

For a fuller treatment of layered mitigations — including DNS pinning variations and allowlist design — the OWASP SSRF Prevention Cheat Sheet and the attack description cover the ground systematically.

Wrapping Up

SSRF is an architectural bug, not a parsing bug. The vulnerable code isn’t the URL parser — it’s the boundary between “the internet can talk to this server” and “this server can talk to everything.” Fixing it means moving your check from the string layer to the socket layer: resolve, validate the IP, dial the validated address, and keep your internal network segmented so that one bug in user-facing code can’t reach infrastructure endpoints.

Start by inventorying where your application fetches user-supplied URLs. Most codebases have more of these than expected — webhook testers, image previewers, importers, SSO callbacks — and each one is a candidate for the same treatment.

Leave a Reply

Your email address will not be published. Required fields are marked *