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.
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 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
@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
The defence is to connect to the address you validated, never to re-resolve:
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
@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:
@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:
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?
Why are cloud metadata endpoints such a target?
What about redirects?
Related tutorials
- File Upload SecurityEvery attack a file upload enables and its defence: extension allowlists, real content-type detection with Tika, path traversal, polyglot files, SVG, and safe serving.
- Insecure DeserialisationWhy readObject on untrusted data is remote code execution, how gadget chains work, ObjectInputFilter as a mitigation, and the Jackson polymorphic typing configuration to avoid.
- XSS PreventionStopping cross-site scripting: the three XSS types, why encoding must be context-aware, Thymeleaf escaping, OWASP Java Encoder, nonce-based CSP and sanitising rich text.
- OWASP Top 10 for LLM ApplicationsSecuring AI features in a Spring application: why prompt injection cannot be fully solved, treating model output as untrusted, capability scoping for agents, and cost-based denial of service.