Spring Boot SSRF Mitigation with InetAddressFilter
Spring Boot applications often make HTTP requests to external services using clients such as RestClient, WebClient, or other HTTP client implementations. When the application controls the target URL, making the request is generally straightforward. However, accepting a URL from a user or another untrusted source can introduce a security vulnerability known as Server-Side Request Forgery (SSRF). For example, an endpoint that accepts a URL and asks the server to retrieve its contents could allow an attacker to make requests to internal services if the URL is not properly validated. Spring Boot provides InetAddressFilter to restrict the IP addresses an HTTP client can connect to. It can be used as a global SSRF protection mechanism and can also be combined with tenant-specific address policies.
1. Understanding SSRF in the Context of HTTP Clients
SSRF occurs when an attacker can influence a server into making an HTTP request to an unintended destination. Suppose an application exposes the following API: GET /fetch?url=https://example.com. The application may internally execute:
restClient.get()
.uri(url)
.retrieve()
.body(String.class);
The problem is that the server, rather than the user’s browser, performs the network request. An attacker could try to provide an internal address such as:
http://127.0.0.1:8080/admin http://localhost:8080/actuator
They could also attempt to target private network ranges such as:
10.0.0.10 192.168.1.20 172.16.0.15
In cloud environments, SSRF can be particularly dangerous because internal endpoints may expose application or infrastructure information that should not be publicly accessible. The important point is that validating only the URL string is not always sufficient. A hostname can resolve to an IP address that should not be reachable by the application. This is where IP address filtering becomes useful.
1.1 Why IP Address Filtering Helps
An application may receive a URL such as: https://example.com. The hostname must eventually be resolved to an IP address before the connection is established. An IP address filter allows the HTTP client to make the final connection decision based on the resolved address. Spring Boot’s InetAddressFilter.externalAddresses() provides a convenient policy for allowing external, non-private addresses. The filter excludes internal and special-purpose address ranges. This provides a useful baseline for applications that should only make outbound calls to external services.
1.2 Global Filtering vs Tenant-Level Filtering
Global and tenant-level filters serve different purposes and can be combined to provide layered SSRF protection.
| Filter | Purpose | Example |
|---|---|---|
| Global filter | Provides the application’s baseline SSRF protection. | externalAddresses() |
| Tenant filter | Restricts a tenant to its approved destination ranges. | 203.0.113.0/24 |
The global policy should generally be managed centrally by the application or platform security team and applied consistently across tenants. Tenant-level policies can then add further restrictions based on each tenant’s business and integration requirements. By combining both policies, a request must satisfy the global security rules as well as the specific tenant’s allowed destination ranges.
1.3 Why Hostname Validation Alone Is Not Enough
Checking the hostname or URL string alone is not sufficient for reliable SSRF protection. A hostname represents a logical destination, but the actual network connection is ultimately made to an IP address obtained through DNS resolution. For example, an application might reject obvious values such as:
localhost 127.0.0.1 internal.example.com
However, an attacker may use a hostname that appears external but resolves to an internal or otherwise restricted IP address. Therefore, SSRF protection should consider the resolved IP address rather than relying only on string-based hostname checks. This is one reason an IP-level InetAddressFilter is useful: the HTTP client can apply the configured policy to the address used for the connection.
2. Implementing Global and Tenant-Level SSRF Protection
2.1 Adding the Spring Web Dependency
To use Spring’s HTTP client APIs in the application, add the Spring Web starter to the project’s pom.xml file. The following dependency provides the required Spring Web components.
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies>
The spring-boot-starter-web dependency adds Spring Web and the HTTP client APIs required for the example. In this example, we use RestClient, which provides a simple synchronous API for making HTTP requests.
2.2 Creating the Spring Boot Application
The main application class serves as the entry point for the Spring Boot application. Create SsrfDemoApplication.java with the following code.
package com.example.ssrf;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class SsrfDemoApplication {
public static void main(String[] args) {
SpringApplication.run(SsrfDemoApplication.class, args);
}
}
The @SpringBootApplication annotation enables Spring Boot’s auto-configuration and component scanning. The main() method starts the application by calling SpringApplication.run(), which creates the Spring application context and starts the embedded web server.
2.3 Creating the Tenant Context
The application needs to identify the tenant associated with the current request so that tenant-specific SSRF rules can be applied. For this example, a simple ThreadLocal-based context is used to store the current tenant ID.
package com.example.ssrf;
public final class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
private TenantContext() {
}
public static void setTenantId(String tenantId) {
CURRENT_TENANT.set(tenantId);
}
public static String getTenantId() {
return CURRENT_TENANT.get();
}
public static void clear() {
CURRENT_TENANT.remove();
}
}
The setTenantId() method stores the tenant ID for the current thread, while getTenantId() retrieves it when the application needs to apply tenant-specific rules. The clear() method removes the value after the request is complete, which helps prevent tenant information from being accidentally reused by another request when threads are reused.
2.4 Capturing the Tenant Identifier
The application needs to identify the tenant before processing each request so that tenant-specific SSRF rules can be applied. For this example, the tenant identifier is read from the X-Tenant-Id request header.
package com.example.ssrf;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String tenantId = request.getHeader("X-Tenant-Id");
TenantContext.setTenantId(tenantId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception exception) {
TenantContext.clear();
}
}
The preHandle() method reads the tenant ID from the request header and stores it in TenantContext before the request is processed. The afterCompletion() method clears the context after the request completes, ensuring that the tenant information does not remain available when the same thread is reused for another request.
2.5 Registering the Tenant Interceptor
The tenant interceptor must be registered with Spring MVC so that it runs for incoming requests and establishes the tenant context before the request reaches the controller.
package com.example.ssrf;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final TenantInterceptor tenantInterceptor;
public WebConfig(TenantInterceptor tenantInterceptor) {
this.tenantInterceptor = tenantInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantInterceptor);
}
}
The WebConfig class implements WebMvcConfigurer and registers TenantInterceptor through addInterceptors(). This ensures that the interceptor is invoked for incoming requests, allowing the application to identify the tenant before processing the request and clear the context after it completes.
2.6 Creating a Tenant-Aware RestClient
The HTTP client needs to enforce both global SSRF protection and tenant-specific network restrictions. The following factory creates a RestClient with a combined InetAddressFilter based on the current tenant.
package com.example.ssrf;
import java.util.Map;
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.HttpClientSettings;
import org.springframework.boot.http.client.InetAddressFilter;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class TenantRestClientFactory {
private final Map<String, InetAddressFilter> tenantFilters = Map.of(
"tenant-a", InetAddressFilter.of("203.0.113.0/24"),
"tenant-b", InetAddressFilter.of("198.51.100.0/24")
);
public RestClient create(String tenantId) {
InetAddressFilter tenantFilter = tenantFilters.get(tenantId);
if (tenantFilter == null) {
throw new IllegalArgumentException("Unknown tenant: " + tenantId);
}
// Global SSRF protection.
InetAddressFilter globalFilter = InetAddressFilter.externalAddresses();
// Both policies must match.
InetAddressFilter combinedFilter = globalFilter.and(tenantFilter);
HttpClientSettings settings = HttpClientSettings.defaults().withInetAddressFilter(combinedFilter);
ClientHttpRequestFactory requestFactory = ClientHttpRequestFactoryBuilder.jdk().build(settings);
return RestClient.builder().requestFactory(requestFactory).build();
}
}
The tenantFilters map defines the network ranges allowed for each tenant. The externalAddresses() filter provides the global SSRF protection, while and() combines it with the tenant-specific filter so that both policies must allow the destination. The resulting filter is applied through HttpClientSettings when the request factory is created, and the configured request factory is then used to build the tenant-specific RestClient.
2.7 Creating the URL Service
The service uses the tenant information stored in TenantContext to obtain the appropriate tenant-specific RestClient. This ensures that every outbound request is made using the SSRF policies configured for that tenant.
package com.example.ssrf;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
@Service
public class UrlService {
private final TenantRestClientFactory restClientFactory;
public UrlService(TenantRestClientFactory restClientFactory) {
this.restClientFactory = restClientFactory;
}
public String fetch(String url) {
String tenantId = TenantContext.getTenantId();
if (tenantId == null) {
throw new IllegalStateException("Tenant context is not available");
}
RestClient restClient = restClientFactory.create(tenantId);
return restClient.get().uri(url).retrieve().body(String.class);
}
}
The fetch() method first retrieves the current tenant ID from TenantContext and rejects the request if the tenant context is unavailable. It then creates a tenant-specific RestClient through TenantRestClientFactory and uses it to make the HTTP request. Because the client was created with the combined global and tenant-specific address filters, the outbound request is subject to both SSRF protection policies.
2.8 Creating the URL Controller
The controller exposes the /fetch endpoint and accepts the target URL as a request parameter. It delegates the actual HTTP request to UrlService, keeping the controller focused on handling the incoming request.
package com.example.ssrf;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UrlController {
private final UrlService urlService;
public UrlController(UrlService urlService) {
this.urlService = urlService;
}
@GetMapping("/fetch")
public String fetch(@RequestParam String url) {
return urlService.fetch(url);
}
}
The fetch() method reads the URL from the url request parameter and passes it to UrlService. The service then uses the current tenant context and the configured address filters before making the outbound request. This keeps the SSRF protection logic outside the controller and ensures that all requests follow the same filtering rules.
2.9 Code Run and Output
Once the application is configured, start it using Maven: mvn spring-boot:run. The application starts on the default port 8080. A request can then be sent with the tenant identifier and target URL.
curl -H "X-Tenant-Id: tenant-a" \
"http://localhost:8080/fetch?url=https://example.com"
The request first reaches the TenantInterceptor, which reads the tenant identifier from the request and stores it in TenantContext. The UrlService then retrieves the tenant ID and asks TenantRestClientFactory to create a tenant-specific RestClient. The factory combines the global SSRF protection with the tenant-specific InetAddressFilter, ensuring that the target address satisfies both policies before the connection is established. If the destination is allowed, the configured RestClient sends the HTTP request to the target server and returns its response to the caller. If the target resolves to an address rejected by either filter, the HTTP client blocks the connection instead of making the outbound request.
For example, common internal addresses such as the following should be rejected when they do not satisfy the configured filters:
127.0.0.1 10.0.0.10 172.16.0.15 192.168.1.20 ::1
This demonstrates how a global SSRF policy can be combined with tenant-specific network restrictions so that an outbound request must satisfy both policies before the connection is established.
3. Conclusion
Spring Boot applications that make HTTP requests to user-supplied URLs should apply appropriate SSRF protection before establishing outbound connections. Using InetAddressFilter allows the application to restrict the IP addresses that an HTTP client can connect to. By combining a global filter with tenant-specific filters, applications can enforce common SSRF protection while also applying different network access policies for individual tenants. This approach helps prevent requests to unintended internal or private destinations while keeping the outbound HTTP client configuration centralized and consistent.




