Server Side Request Forgery in Java and Spring: Secure Coding Patterns

20+ years in software development, now focused on application security. Writing hands-on guides on secure coding patterns, vulnerability analysis, and security architecture.
Server Side Request Forgery, commonly abbreviated as SSRF, ranks among those vulnerabilities that look trivial during code review but escalate into full-blown incidents once they hit production. The core issue is deceptively simple: the server becomes a network client acting on behalf of the user without proper control over where it connects.
SSRF is classified under CWE-918 and in the OWASP Top 10 2025 has been merged into A01:2025 Broken Access Control, the number one web application security risk. Previously a standalone category in 2021, SSRF is now recognized as fundamentally an access control failure where the server gains access to resources it should not reach. In cloud environments where metadata services hold sensitive credentials, SSRF remains one of the most impactful vulnerability classes.
Attackers usually cannot reach internal services directly from the internet. The backend, however, sits inside the network perimeter and may be able to access everything from database admin panels to cloud metadata endpoints. When user-controlled input dictates where the backend connects, that internal network access has effectively been handed over.
This is fundamentally a trust boundary problem. A typical backend server operates within a network segment that can communicate with other internal HTTP services, cloud metadata APIs like the AWS instance metadata service at 169.254.169.254, administrative interfaces and debug endpoints, and depending on the HTTP client implementation, potentially local files or non-HTTP protocols. Letting untrusted input determine connection targets transforms a normal HTTP client into an SSRF vulnerability.
Throughout this post we will examine how SSRF manifests in typical Java and Spring code, understand why it poses significant risk, and construct defenses that integrate naturally with Java 25, Spring Framework 7, and Spring Security 7. The examples target Java 25 LTS, Spring Framework 7.0.8, Spring Security 7.1.0, and Reactor Netty 1.3.6. Each scenario follows the same structure: a vulnerable example, an explanation of what makes it dangerous, and a hardened version with reasoning for each protection measure.
One thing worth noting upfront: SSRF is purely a server-side vulnerability. Unlike XSS or CSRF where frontend validation might provide defense in depth, there is nothing meaningful that client-side code can do to prevent SSRF. The attack vector is the server making outbound requests based on user input, and any client-side validation can be trivially bypassed by calling the API directly. All defenses must live on the backend.
1. The Classic Proxy Endpoint
A common pattern that introduces SSRF is the simple proxy or downloader endpoint. Perhaps the application needs to fetch images, retrieve PDFs, or call partner APIs, and someone decides that exposing a generic proxy endpoint is the quickest path forward.
Vulnerable Controller
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URI;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/proxy")
public class VulnerableProxyController {
@GetMapping("/fetch")
public ResponseEntity<String> fetch(@RequestParam("url") String url) {
try {
URI target = URI.create(url);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(target.toURL().openStream(), StandardCharsets.UTF_8))) {
StringBuilder body = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
body.append(line).append('\n');
}
return ResponseEntity.ok(body.toString());
}
} catch (IllegalArgumentException | IOException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("Failed to fetch remote resource");
}
}
}
Why This Is Vulnerable
The first problem is that the attacker controls the URI scheme. Converting an untrusted URI to a URL is not limited to HTTP and HTTPS; installed protocol handlers can include file:. Passing a file URI lets the attacker read local files if process permissions allow:
file:///etc/passwd
The second problem is that the attacker controls the host. The server becomes a scanner for any target reachable from its network position:
http://localhost:8080/actuator
http://127.0.0.1:6379
http://169.254.169.254/latest/meta-data/
There is no validation of hostname, IP address, port, or protocol. The server follows wherever the URL points, handing the attacker the same network reachability the backend possesses.
Hardened Baseline with Allow List
The first step toward remediation is parsing once with URI, then restricting protocols, hosts, scheme-specific ports, and every resolved address before connecting.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Stream;
@Component
public class SsrfGuard {
private static final Logger log = LoggerFactory.getLogger(SsrfGuard.class);
private static final Set<String> ALLOWED_HOSTS = Set.of(
"api.trusted-partner.com",
"images.example-cdn.com"
);
private static final Set<String> ALLOWED_SCHEMES = Set.of("http", "https");
private static final List<CidrBlock> BLOCKED_RANGES = Stream.of(
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8",
"169.254.0.0/16", "172.16.0.0/12", "192.0.0.0/24",
"192.0.2.0/24", "192.88.99.0/24", "192.168.0.0/16", "198.18.0.0/15",
"198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4",
"::/128", "::1/128", "64:ff9b::/96", "64:ff9b:1::/48",
"100::/64", "100:0:0:1::/64", "2001::/23", "2001:db8::/32",
"2002::/16", "3fff::/20", "5f00::/16", "fc00::/7",
"fe80::/10", "ff00::/8")
.map(CidrBlock::parse)
.toList();
public ResolvedTarget validateAndResolve(String rawUrl) {
if (rawUrl == null || rawUrl.isBlank()) {
throw new IllegalArgumentException("URL cannot be null or empty");
}
URI uri;
try {
uri = new URI(rawUrl).parseServerAuthority().normalize();
} catch (URISyntaxException e) {
throw new IllegalArgumentException("Invalid URL syntax", e);
}
String scheme = uri.getScheme();
if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException("Unsupported or missing scheme");
}
if (uri.getRawUserInfo() != null || uri.getRawFragment() != null) {
throw new IllegalArgumentException("User-info and fragments are not allowed");
}
if (uri.getRawPath() != null && uri.getRawPath().startsWith("//")) {
throw new IllegalArgumentException("Authority-like paths are not allowed");
}
String host = uri.getHost();
if (host == null || !ALLOWED_HOSTS.contains(host.toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException("Host not in allow list");
}
int port = uri.getPort() == -1
? (scheme.equalsIgnoreCase("https") ? 443 : 80)
: uri.getPort();
if ((scheme.equalsIgnoreCase("https") && port != 443)
|| (scheme.equalsIgnoreCase("http") && port != 80)) {
throw new IllegalArgumentException("Port does not match the scheme");
}
InetAddress[] addresses;
try {
addresses = InetAddress.getAllByName(host);
} catch (UnknownHostException e) {
throw new IllegalArgumentException("Cannot resolve host", e);
}
List<InetAddress> resolved = List.copyOf(Arrays.asList(addresses));
for (InetAddress address : resolved) {
if (isBlockedAddress(address)) {
log.warn("Blocked SSRF attempt: host {} resolved to non-global address {}",
host, address.getHostAddress());
throw new IllegalArgumentException("Target resolves to blocked address");
}
}
return new ResolvedTarget(uri, host.toLowerCase(Locale.ROOT), port, resolved);
}
private boolean isBlockedAddress(InetAddress address) {
return address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()
|| address.isMulticastAddress()
|| BLOCKED_RANGES.stream().anyMatch(range -> range.contains(address));
}
public record ResolvedTarget(URI originalUri, String host, int port,
List<InetAddress> addresses) {
public ResolvedTarget {
addresses = List.copyOf(addresses);
}
public URI requestTarget() {
String path = originalUri.getRawPath();
if (path == null || path.isEmpty()) {
path = "/";
}
String value = originalUri.getRawQuery() == null
? path
: path + "?" + originalUri.getRawQuery();
return URI.create(value);
}
}
private record CidrBlock(byte[] network, int prefixLength) {
private CidrBlock {
network = network.clone();
if (prefixLength < 0 || prefixLength > network.length * 8) {
throw new IllegalArgumentException("Invalid prefix length");
}
}
static CidrBlock parse(String cidr) {
int slash = cidr.lastIndexOf('/');
InetAddress network = InetAddress.ofLiteral(cidr.substring(0, slash));
return new CidrBlock(
network.getAddress(),
Integer.parseInt(cidr.substring(slash + 1))
);
}
boolean contains(InetAddress candidate) {
byte[] value = candidate.getAddress();
if (value.length != network.length) {
return false;
}
int wholeBytes = prefixLength / 8;
int remainingBits = prefixLength % 8;
for (int i = 0; i < wholeBytes; i++) {
if (value[i] != network[i]) {
return false;
}
}
if (remainingBits == 0) {
return true;
}
int mask = 0xFF << (8 - remainingBits);
return ((value[wholeBytes] & 0xFF) & mask)
== ((network[wholeBytes] & 0xFF) & mask);
}
}
}
InetAddress.ofLiteral, introduced in Java 22, parses the CIDR constants without performing DNS. This policy deliberately rejects shared, transition, documentation, benchmark, multicast, private, link-local, IPv6 ULA, and other special-purpose ranges. That includes the familiar IPv4 metadata address 169.254.169.254 and AWS's IPv6 metadata address fd00:ec2::254, which falls inside fc00::/7. Java represents IPv4-mapped IPv6 literals as IPv4 addresses, so the IPv4 rules still apply. Production code should keep this policy synchronized with the IANA special-purpose registries and add its own network and cloud metadata ranges.
Now wire this guard into a hardened baseline controller that uses RestClient, the modern synchronous HTTP client introduced in Spring Framework 6.1 and still current in Spring Framework 7.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import java.net.http.HttpClient;
import java.time.Duration;
@Configuration
public class HttpClientConfig {
@Bean(destroyMethod = "close")
public HttpClient outboundHttpClient() {
return HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NEVER)
.proxy(HttpClient.Builder.NO_PROXY)
.build();
}
@Bean
public RestClient safeRestClient(HttpClient outboundHttpClient) {
JdkClientHttpRequestFactory requestFactory =
new JdkClientHttpRequestFactory(outboundHttpClient);
requestFactory.setReadTimeout(Duration.ofSeconds(10));
requestFactory.enableCompression(false);
return RestClient.builder()
.requestFactory(requestFactory)
.build();
}
}
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/secure-proxy")
public class SecureProxyController {
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
private static final Logger log = LoggerFactory.getLogger(SecureProxyController.class);
private final RestClient restClient;
private final SsrfGuard ssrfGuard;
public SecureProxyController(RestClient restClient, SsrfGuard ssrfGuard) {
this.restClient = restClient;
this.ssrfGuard = ssrfGuard;
}
@GetMapping("/fetch")
public ResponseEntity<String> fetch(@RequestParam("url") String url) {
SsrfGuard.ResolvedTarget target;
try {
target = ssrfGuard.validateAndResolve(url);
} catch (IllegalArgumentException ex) {
log.warn("SSRF validation failed: {}", ex.getMessage());
return ResponseEntity.badRequest().body("URL validation failed");
}
try {
byte[] bytes = restClient.get()
.uri(target.originalUri())
.exchange((request, response) -> {
if (!response.getStatusCode().is2xxSuccessful()) {
throw new IOException("Unexpected upstream status");
}
long length = response.getHeaders().getContentLength();
if (length > MAX_RESPONSE_BYTES) {
throw new IOException("Upstream response is too large");
}
try (InputStream in = response.getBody()) {
byte[] limited = in.readNBytes(MAX_RESPONSE_BYTES + 1);
if (limited.length > MAX_RESPONSE_BYTES) {
throw new IOException("Upstream response is too large");
}
return limited;
}
});
String body = new String(bytes, StandardCharsets.UTF_8);
return ResponseEntity.ok(body);
} catch (RestClientException ex) {
log.error("Failed to fetch from validated URL: {}", target.originalUri(), ex);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body("Upstream request failed");
}
}
}
Why This Is More Secure
The raw fetch that silently supports multiple protocols is gone. Only HTTP and HTTPS pass validation, eliminating file: and other non-HTTP schemes. Only exact hosts on the allow list are permitted. Ports must match their schemes: HTTP uses 80 and HTTPS uses 443. Every resolved IPv4 and IPv6 address is checked, and a single non-global answer rejects the target. Redirects and system proxies are disabled, compressed responses are not requested, and the response body is capped at 1 MiB.
This is still a hardened baseline rather than connection-level DNS pinning: the JDK client resolves the hostname again when it connects. Section 6 closes that time-of-check/time-of-use window with a connector that uses the validated IP while preserving the original hostname for HTTP and TLS. Default-deny egress filtering remains the strongest backstop.
2. SSRF Hidden in Webhooks and Callbacks
Not every SSRF vulnerability lives in a generic proxy endpoint. Many appear in integration features like webhooks, where users configure callback URLs that the system later invokes.
Vulnerable Webhook Registration
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "webhook_subscriptions")
public class WebhookSubscription {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String eventType;
@Column(nullable = false)
private String callbackUrl;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getEventType() { return eventType; }
public void setEventType(String eventType) { this.eventType = eventType; }
public String getCallbackUrl() { return callbackUrl; }
public void setCallbackUrl(String callbackUrl) { this.callbackUrl = callbackUrl; }
}
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/webhooks")
public class VulnerableWebhookController {
private final WebhookSubscriptionRepository repository;
private final RestClient restClient;
public VulnerableWebhookController(WebhookSubscriptionRepository repository,
RestClient restClient) {
this.repository = repository;
this.restClient = restClient;
}
@PostMapping("/subscribe")
public ResponseEntity<Void> subscribe(@RequestBody WebhookSubscription dto) {
repository.save(dto);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
@PostMapping("/trigger/{eventType}")
public ResponseEntity<Void> trigger(@PathVariable String eventType,
@RequestBody Map<String, Object> payload) {
List<WebhookSubscription> subscriptions = repository.findByEventType(eventType);
for (WebhookSubscription sub : subscriptions) {
restClient.post()
.uri(sub.getCallbackUrl())
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.retrieve()
.toBodilessEntity();
}
return ResponseEntity.accepted().build();
}
}
Why This Is Vulnerable
The subscribe endpoint looks innocuous because it merely stores data. The actual damage occurs later when an internal process uses that stored URL as a network target. An attacker registers a webhook with a callbackUrl pointing to internal infrastructure. When events fire, the backend connects to those internal services from inside the network perimeter, using the application's network position rather than the attacker's own. If the outbound client attaches credentials or client certificates, those may be carried as well. This is SSRF with a time delay, which makes it harder to detect and correlate with the original malicious registration.
Hardened Baseline with Boundary Validation
The fix involves treating callbackUrl as untrusted input and validating it before persistence or use.
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record WebhookSubscriptionRequest(
@NotBlank @Size(max = 100) String eventType,
@NotBlank @Size(max = 2048) String callbackUrl
) {
}
The request DTO deliberately has no persistence identifier, so a caller cannot turn subscription creation into an unintended update by supplying an existing entity ID.
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/webhooks")
public class SecureWebhookController {
private static final Logger log = LoggerFactory.getLogger(SecureWebhookController.class);
private final WebhookSubscriptionRepository repository;
private final RestClient restClient;
private final SsrfGuard ssrfGuard;
public SecureWebhookController(WebhookSubscriptionRepository repository,
RestClient restClient,
SsrfGuard ssrfGuard) {
this.repository = repository;
this.restClient = restClient;
this.ssrfGuard = ssrfGuard;
}
@PostMapping("/subscribe")
public ResponseEntity<?> subscribe(@Valid @RequestBody WebhookSubscriptionRequest request) {
SsrfGuard.ResolvedTarget target;
try {
target = ssrfGuard.validateAndResolve(request.callbackUrl());
} catch (IllegalArgumentException ex) {
return ResponseEntity.badRequest().body("Callback URL validation failed");
}
WebhookSubscription subscription = new WebhookSubscription();
subscription.setEventType(request.eventType());
subscription.setCallbackUrl(target.originalUri().toString());
repository.save(subscription);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
@PostMapping("/trigger/{eventType}")
public ResponseEntity<Void> trigger(@PathVariable String eventType,
@RequestBody Map<String, Object> payload) {
List<WebhookSubscription> subscriptions = repository.findByEventType(eventType);
for (WebhookSubscription sub : subscriptions) {
try {
SsrfGuard.ResolvedTarget target =
ssrfGuard.validateAndResolve(sub.getCallbackUrl());
restClient.post()
.uri(target.originalUri())
.contentType(MediaType.APPLICATION_JSON)
.body(payload)
.exchange((request, response) -> {
if (!response.getStatusCode().is2xxSuccessful()) {
throw new IOException("Unexpected webhook response");
}
return null;
});
} catch (IllegalArgumentException ex) {
log.warn("Skipping webhook {} due to validation failure: {}",
sub.getId(), ex.getMessage());
} catch (RestClientException ex) {
log.error("Webhook delivery failed for subscription {}", sub.getId(), ex);
}
}
return ResponseEntity.noContent().build();
}
}
Why This Is More Secure
The URL is normalized and validated at the trust boundary, which is the point where external input enters the system. The stored value is normalized but remains untrusted. Re-validation before use catches scenarios where the allow list, DNS, or network policy changes after initial registration. Attackers cannot register direct internal or metadata targets as long as the guard rules are correctly maintained. To close the remaining DNS window, webhook delivery must adapt the pinned connector from Section 6; egress filtering remains the backstop.
3. SSRF with File Downloads and Protocol Abuse
Another pattern that frequently introduces SSRF is allowing users to provide URLs for files that the server downloads and processes. Common examples include avatar images, invoice imports, or document fetching.
Vulnerable File Downloader
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@Service
public class VulnerableAvatarService {
public Path downloadAvatar(String url) throws IOException {
URI target = URI.create(url);
try (InputStream in = target.toURL().openStream()) {
Path avatarFile = Files.createTempFile("avatar-", ".bin");
Files.copy(in, avatarFile, StandardCopyOption.REPLACE_EXISTING);
return avatarFile;
}
}
}
Why This Is Vulnerable
Converting a raw URI to a URL can dispatch to schemes beyond HTTP. When the server process has read access to local files, an attacker can supply a URI like file:///etc/shadow instead of a legitimate avatar URL. The backend copies the sensitive file into its processing area. A later step may display, log, transform, or forward the contents, leaking data directly or through side channels.
Hardened File Downloader Baseline
The solution reuses the SsrfGuard component, potentially with a narrower allow list specific to avatar sources.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
@Service
public class SafeAvatarService {
private static final long MAX_AVATAR_BYTES = 5L * 1024 * 1024;
private static final Logger log = LoggerFactory.getLogger(SafeAvatarService.class);
private final RestClient restClient;
private final SsrfGuard ssrfGuard;
public SafeAvatarService(RestClient restClient, SsrfGuard ssrfGuard) {
this.restClient = restClient;
this.ssrfGuard = ssrfGuard;
}
public Path downloadAvatar(String rawUrl) throws IOException {
SsrfGuard.ResolvedTarget target;
try {
target = ssrfGuard.validateAndResolve(rawUrl);
} catch (IllegalArgumentException ex) {
throw new IOException("URL validation failed: " + ex.getMessage(), ex);
}
try {
return restClient.get()
.uri(target.originalUri())
.exchange((request, response) -> {
if (!response.getStatusCode().is2xxSuccessful()) {
throw new IOException("Unexpected upstream status");
}
MediaType contentType = response.getHeaders().getContentType();
if (contentType == null || !"image".equals(contentType.getType())) {
throw new IOException("Avatar response is not an image");
}
long length = response.getHeaders().getContentLength();
if (length > MAX_AVATAR_BYTES) {
throw new IOException("Avatar exceeds size limit");
}
Path avatarFile = Files.createTempFile("avatar-", ".img");
boolean complete = false;
try (InputStream in = response.getBody();
OutputStream out = Files.newOutputStream(avatarFile)) {
byte[] buffer = new byte[8192];
long total = 0;
int read;
while ((read = in.read(buffer)) != -1) {
total += read;
if (total > MAX_AVATAR_BYTES) {
throw new IOException("Avatar exceeds size limit");
}
out.write(buffer, 0, read);
}
if (total == 0) {
throw new IOException("Avatar download returned empty response");
}
complete = true;
return avatarFile;
} finally {
if (!complete) {
Files.deleteIfExists(avatarFile);
}
}
});
} catch (RestClientException ex) {
log.error("Failed to download avatar from {}", target.originalUri(), ex);
throw new IOException("Avatar download failed", ex);
}
}
}
Why This Is More Secure
The guard, rather than the absence of a URL object, eliminates non-HTTP schemes. All scheme, host, port, and IP checks are centralized. The downloader accepts only image responses, checks a declared length when present, enforces the same 5 MiB limit while streaming, and removes a partial temporary file on failure. An HTTP Content-Type is not proof of file type, so downstream image processing must also verify the actual file signature and decode it safely. The caller owns the successful file's lifecycle. As with the proxy and webhook baselines, production fetching should adapt the pinned connector from Section 6.
4. Integrating SSRF Defenses with Spring Security
So far we have treated SSRF as a validation problem. In a production codebase, integrating this logic with Spring Security makes the protection more visible and auditable.
Enable Method Security
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}
Define a Security Evaluator Bean
import org.springframework.stereotype.Component;
@Component("ssrf")
public class SsrfSecurityEvaluator {
private final SsrfGuard ssrfGuard;
public SsrfSecurityEvaluator(SsrfGuard ssrfGuard) {
this.ssrfGuard = ssrfGuard;
}
public boolean isAllowedUrl(String url) {
if (url == null || url.isBlank()) {
return false;
}
try {
ssrfGuard.validateAndResolve(url);
return true;
} catch (IllegalArgumentException ex) {
return false;
}
}
}
Annotate Controllers with PreAuthorize
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.parameters.P;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/reporting")
public class ReportingController {
private final ReportService reportService;
public ReportingController(ReportService reportService) {
this.reportService = reportService;
}
@PostMapping("/send")
@PreAuthorize("@ssrf.isAllowedUrl(#request.callbackUrl())")
public ResponseEntity<Void> sendReport(
@P("request") @Valid @RequestBody ReportRequest request) {
// ReportService must validate again immediately before it connects.
reportService.generateAndSend(request);
return ResponseEntity.accepted().build();
}
}
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ReportRequest(
@NotBlank @Size(max = 2048) String callbackUrl
) {
}
What This Approach Provides
The security aspect becomes explicit. When reviewing the controller, the SSRF check is immediately visible in the annotation. Security logic stays centralized in a component rather than scattered across controllers. The SsrfSecurityEvaluator can be unit tested independently of HTTP and web layers. Spring Security's @P makes the SpEL parameter name reliable even when compiler parameter metadata is unavailable.
The annotation is defense in depth, not the enforcement point: it returns only a boolean, not the normalized and resolved target, and DNS can change before the service connects. ReportService must call the guard again at the outbound sink and use the bounded, redirect-disabled, pinned client. A generic OncePerRequestFilter that guesses parameter names such as url, callback, or target is too broad and can miss JSON bodies or non-controller sinks.
5. SSRF and WebClient in Reactive Stacks
When working with Spring WebFlux and WebClient, the same principles apply. The implementation differs slightly to accommodate the reactive programming model.
Vulnerable WebClient Usage
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
@Service
public class VulnerableWebClientService {
private final WebClient webClient;
public VulnerableWebClientService(WebClient.Builder builder) {
this.webClient = builder.build();
}
public Mono<String> proxy(String url) {
return webClient.get()
.uri(url)
.retrieve()
.bodyToMono(String.class);
}
}
Hardened WebClient Baseline Configuration
import io.netty.channel.ChannelOption;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
@Configuration
public class WebClientConfig {
@Bean
public WebClient safeWebClient() {
reactor.netty.http.client.HttpClient httpClient =
reactor.netty.http.client.HttpClient.create()
.followRedirect(false)
.noProxy()
.compress(false)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.responseTimeout(Duration.ofSeconds(10));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(configurer ->
configurer.defaultCodecs().maxInMemorySize(1024 * 1024))
.build();
}
}
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.time.Duration;
@Service
public class SafeWebClientService {
private final WebClient webClient;
private final SsrfGuard ssrfGuard;
public SafeWebClientService(@Qualifier("safeWebClient") WebClient safeWebClient, SsrfGuard ssrfGuard) {
this.webClient = safeWebClient;
this.ssrfGuard = ssrfGuard;
}
public Mono<String> proxy(String rawUrl) {
return Mono.fromCallable(() -> ssrfGuard.validateAndResolve(rawUrl))
// InetAddress.getAllByName is blocking; keep it off the event loop.
.subscribeOn(Schedulers.boundedElastic())
.flatMap(target -> webClient.get()
.uri(target.originalUri())
.retrieve()
.bodyToMono(String.class))
// responseTimeout limits gaps between reads; this limits the whole operation.
.timeout(Duration.ofSeconds(12));
}
}
The same core principles hold: centralized validation, strict scheme and host allow lists, IPv4 and IPv6 checks, direct/no-proxy routing, redirect control, connect/read/total timeouts, and a 1 MiB codec limit. Moving the JDK resolver onto boundedElastic prevents it from blocking the WebFlux event loop. The next section replaces the second DNS lookup with an actual pinned connection.
6. DNS Rebinding and Time-of-Check Issues
One subtle attack vector that the basic SsrfGuard does not fully address is DNS rebinding. In this attack, an attacker controls a DNS server that initially returns a safe external IP address during validation. When the actual HTTP request occurs moments later, the DNS server returns an internal IP address instead. The time gap between validation and request creates a window for exploitation.
Mitigating DNS rebinding means binding validation to the connection. A short DNS TTL does not help, and checking DNS after a request only discovers the problem after the client may already have reached an internal service. Java's standard HttpClient has no public per-request DNS resolver hook, so this example uses Reactor Netty's explicit remote-address support.
import io.netty.channel.ChannelOption;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.netty.http.Http11SslContextSpec;
import javax.net.ssl.SNIHostName;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
import java.time.Duration;
@Component
public class PinnedWebClient {
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
private final SsrfGuard ssrfGuard;
public PinnedWebClient(SsrfGuard ssrfGuard) {
this.ssrfGuard = ssrfGuard;
}
public Mono<String> getText(String rawUrl) {
return Mono.fromCallable(() -> ssrfGuard.validateAndResolve(rawUrl))
.subscribeOn(Schedulers.boundedElastic())
.flatMap(this::getText)
.timeout(Duration.ofSeconds(12));
}
private Mono<String> getText(SsrfGuard.ResolvedTarget target) {
InetAddress selectedAddress = target.addresses().getFirst();
InetSocketAddress pinnedPeer = pinnedPeer(target, selectedAddress);
reactor.netty.http.client.HttpClient httpClient =
reactor.netty.http.client.HttpClient.newConnection()
.host(target.host())
.port(target.port())
.remoteAddress(() -> pinnedPeer)
.followRedirect(false)
.noProxy()
.compress(false)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.responseTimeout(Duration.ofSeconds(10));
if ("https".equalsIgnoreCase(target.originalUri().getScheme())) {
Http11SslContextSpec tls = Http11SslContextSpec.forClient();
httpClient = httpClient.secure(ssl -> ssl.sslContext(tls)
.serverNames(new SNIHostName(target.host())));
}
WebClient pinnedClient = WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(configurer -> configurer.defaultCodecs()
.maxInMemorySize(MAX_RESPONSE_BYTES))
.build();
return pinnedClient.get()
// A relative request target cannot replace the configured peer address.
.uri(target.requestTarget())
.retrieve()
.bodyToMono(String.class);
}
private static InetSocketAddress pinnedPeer(
SsrfGuard.ResolvedTarget target, InetAddress selectedAddress) {
try {
// Associates the validated bytes with the original host without DNS.
InetAddress namedAddress = InetAddress.getByAddress(
target.host(), selectedAddress.getAddress());
return new InetSocketAddress(namedAddress, target.port());
} catch (UnknownHostException impossible) {
throw new IllegalStateException("Validated address has invalid length", impossible);
}
}
}
newConnection() prevents a pooled connection for one validation result from being reused for another. InetAddress.getByAddress(host, bytes) associates the already validated bytes with the original hostname without resolving DNS again. Reactor Netty therefore sends the correct HTTP Host; HTTPS uses that hostname for SNI and its default hostname verification. The socket itself connects to the pinned bytes. The guard rejects authority-like paths, and the request uses only the original relative path and query, so it cannot replace the configured peer with another authority.
This per-request client favors clarity over throughput. A high-volume implementation can pool connections only when the pool is keyed by the original hostname, port, and validated IP, with a safe DNS-refresh policy. Default-deny egress filtering should still make internal and metadata networks unreachable.
Wrapping Part 1
The patterns and code examples here give developers the tools to write SSRF-resistant code. Individual secure coding practices are strongest when reinforced by organizational systems, but centralized validation, strict scheme and host allow lists, conservative IPv4/IPv6 checks, connection-level pinning, bounded responses, and redirect control close the most common application-layer paths. Network egress controls remain the backstop when an application check fails.





