Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

SSRF Prevention

Stopping server-side request forgery: why cloud metadata endpoints are the prize, validating URLs correctly, defeating DNS rebinding, and egress controls as a second layer.

Intermediate6 min readUpdated
On this page

Server-side request forgery turns your application into a proxy into your own network. The attacker supplies a URL, your server fetches it, and suddenly they can reach services that have no external exposure at all.

Key Takeaways

  • Any endpoint accepting a URL is an SSRF candidate — webhooks, imports, previews, PDF rendering.
  • Cloud metadata endpoints are the highest-value target: credentials, no authentication.
  • Resolve the hostname, check the IP, then connect to that IP — re-resolving reopens the gap.
  • Validate every redirect hop, or the first hop is the only one you checked.
  • Add egress network policy so a logic bypass still reaches nothing.

What an attacker reaches

The server sits inside the trust boundary, so a request it makes reaches things no external attacker could.

The metadata endpoint is the prize because it hands out IAM credentials without authentication, on the assumption that only code on the instance can reach it. One SSRF invalidates that assumption completely.

Enforce IMDSv2 on AWS. It requires a PUT with a header to obtain a session token before any metadata read, and most SSRF primitives can only issue a GET.

It also matters that SSRF is exploitable when the response never reaches the attacker at all. A request that takes 40ms and one refused in 2ms map the internal network just as well as a returned body would, and a webhook firing a POST at an internal admin endpoint needs no response to have done its work. "We never show the fetched content" is not a mitigation.

Validating a URL

SafeUrlValidator.java
@Component
public class SafeUrlValidator {
 
    private static final Set<String> ALLOWED_SCHEMES = Set.of("http", "https");
 
    private static final List<IpAddressMatcher> BLOCKED = Stream.of(
            "0.0.0.0/8",        // this network
            "127.0.0.0/8",      // loopback
            "10.0.0.0/8",       // RFC 1918
            "172.16.0.0/12",
            "192.168.0.0/16",
            "169.254.0.0/16",   // link-local — cloud metadata lives here
            "100.64.0.0/10",    // carrier-grade NAT
            "::1/128",
            "fc00::/7",         // unique local
            "fe80::/10")        // link-local v6
            .map(IpAddressMatcher::new).toList();
 
    /** Returns the validated IP to connect to. */
    public InetAddress validate(String rawUrl) {
        URI uri;
        try {
            uri = new URI(rawUrl);
        } catch (URISyntaxException ex) {
            throw new BadRequestException("malformed URL");
        }
 
        // file://, gopher://, ftp:// and dict:// have all been used in SSRF
        // chains. Allowlist the two schemes you actually need.
        if (uri.getScheme() == null || !ALLOWED_SCHEMES.contains(uri.getScheme().toLowerCase())) {
            throw new BadRequestException("only http and https are permitted");
        }
        if (uri.getHost() == null) throw new BadRequestException("URL has no host");
 
        InetAddress[] resolved;
        try {
            // A hostname can resolve to several addresses. Check ALL of them —
            // an attacker can return one public and one internal.
            resolved = InetAddress.getAllByName(uri.getHost());
        } catch (UnknownHostException ex) {
            throw new BadRequestException("host could not be resolved");
        }
 
        for (InetAddress address : resolved) {
            String ip = address.getHostAddress();
            if (BLOCKED.stream().anyMatch(matcher -> matcher.matches(ip))) {
                securityEvents.record("ssrf-attempt-blocked", rawUrl, ip);
                throw new BadRequestException("that address is not permitted");
            }
        }
        return resolved[0];
    }
}

Checking every resolved address matters. A hostname returning both a public and a private address passes a check that only looks at the first one, and the HTTP client may then connect to either.

DNS rebinding

If the code resolves twice — once to validate, once to connect — an attacker with a short TTL controls what happens between them.

The defence is to connect to the address you validated, never to re-resolve:

PinnedConnectionFetcher.java
public byte[] fetch(String rawUrl) {
    URI uri = URI.create(rawUrl);
    InetAddress validated = validator.validate(rawUrl);
 
    // Build the request against the validated IP, with the original hostname
    // in the Host header so virtual hosting and TLS SNI still work.
    return client.get()
            .uri(builder -> builder
                    .scheme(uri.getScheme())
                    .host(validated.getHostAddress())
                    .port(uri.getPort())
                    .path(uri.getRawPath())
                    .query(uri.getRawQuery())
                    .build())
            .header(HttpHeaders.HOST, uri.getHost())
            .retrieve()
            .body(byte[].class);
}

Two details make this harder in practice than it looks. Setting Host handles virtual hosting, but over HTTPS the TLS layer verifies the certificate against the address it actually dialled — an IP literal — and the handshake fails. Getting both right means a client that sends the original hostname as SNI and verifies against it while still connecting to the pinned address. In practice that is a custom DnsResolver on the connection manager which returns the address you already validated, rather than rewriting the URL at all; Apache HttpClient and Netty both expose that hook, and pinning the resolution keeps TLS working.

Parser disagreement is the other trap. java.net.URI and whatever parses the URL inside your HTTP client do not always agree on where the host ends, and an attacker who finds a string the two read differently has bypassed the check while keeping the connection. Validate and connect from one parsed representation — never parse the raw string twice.

Redirects

NoRedirects.java
@Bean
RestClient externalFetchClient() {
    var factory = new SimpleClientHttpRequestFactory();
    // A validated URL can redirect to an internal one. Not following at all
    // is the simplest correct answer.
    factory.setOutputStreaming(false);
    return RestClient.builder()
            .requestFactory(new BufferingClientHttpRequestFactory(factory))
            .defaultStatusHandler(HttpStatusCode::is3xxRedirection, (req, res) -> {
                throw new BadRequestException("redirects are not followed for external fetches");
            })
            .build();
}

If redirects must be followed — some legitimate image hosts require it — validate every hop against the same rules and cap the chain length. Validating only the original URL means the attacker simply uses a redirector.

Allowlisting

Where the set of legitimate destinations is known, an allowlist beats a blocklist:

DomainAllowlist.java
@ConfigurationProperties(prefix = "app.outbound")
public record OutboundProperties(Set<String> allowedHosts) { }
 
public void assertAllowed(URI uri) {
    String host = uri.getHost().toLowerCase(Locale.ROOT);
    boolean permitted = properties.allowedHosts().stream()
            // Exact match or a genuine subdomain. Note the dot: without it,
            // "evil-cdn.acme.com.attacker.net" would match "cdn.acme.com".
            .anyMatch(allowed -> host.equals(allowed) || host.endsWith("." + allowed));
 
    if (!permitted) throw new BadRequestException("destination not permitted");
}

Webhook URLs are the usual case for an allowlist plus per-tenant registration and verification, rather than accepting any URL a user types.

Network-level defence

Application validation can have a bug. Egress controls mean a bug does not become a breach:

egress-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: deny-internal-egress }
spec:
  podSelector:
    matchLabels: { app: import-service }
  policyTypes: [Egress]
  egress:
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 169.254.169.254/32     # metadata
              - 10.0.0.0/8
              - 172.16.0.0/12
              - 192.168.0.0/16
    - to:                              # explicitly allow what it does need
        - namespaceSelector: {}
          podSelector:
            matchLabels: { app: postgres }

An outbound proxy that enforces the allowlist centrally is the other common pattern, and it has the advantage of giving you a log of every external request your services make.

What to take away

Restrict schemes, resolve the hostname and check every returned address, then connect to the address you validated. Do not follow redirects, or validate each hop. Prefer an allowlist where the destinations are known. And add egress network policy so a validation bug still cannot reach the metadata endpoint.

Frequently Asked Questions

Is blocking private IP ranges enough?
It is necessary and not sufficient on its own. You must resolve the hostname first and check the resolved IP, then connect to that IP rather than re-resolving — otherwise DNS rebinding defeats the check. Add egress network controls so a bypass still cannot reach anything sensitive.
Why are cloud metadata endpoints such a target?
They return IAM credentials for the instance role with no authentication, on the assumption that only code running on the instance can reach them. SSRF breaks that assumption. On AWS, enforcing IMDSv2 helps because it requires a PUT to obtain a token, which simple SSRF cannot perform.
What about redirects?
A permitted URL can redirect to an internal one, so validating only the original is insufficient. Either disable redirect following entirely, or validate every hop against the same rules before following it.

Related tutorials