How to Block SSRF in Spring Boot 4.1 with InetAddressFilter
Writing
SECURITY
Published August 26, 20269 min read

How to Block SSRF in Spring Boot 4.1 with InetAddressFilter

Learn how to block SSRF in Spring Boot 4.1 with the new InetAddressFilter on RestClient and WebClient, covering cloud metadata, internal IPs, and tests.

Rabinarayan Patra - Software Development Engineer

By Rabinarayan Patra

SDE II at Amazon

spring-boot-ssrf-mitigationinetaddressfilterspring-boot-4-1ssrfspring-securityhttp-client

A user pastes a URL into your app. Maybe it's an avatar image, maybe a webhook endpoint, maybe an "import from URL" box. Your Spring Boot service dutifully fetches it with a RestClient. Looks harmless.

Now the user types http://169.254.169.254/latest/meta-data/iam/security-credentials/. On most clouds, that's the metadata endpoint, and it hands back temporary IAM credentials to whatever asks from inside the VPC. Your server just asked. That's SSRF, and it has leaked real production credentials at real companies.

For years the fix in Java was to write your own address checks by hand and hope you covered every case. Spring Boot 4.1 changes that. It ships an InetAddressFilter you can attach to any outbound HTTP client, blocking and reactive, that refuses to connect to internal addresses. This post covers what SSRF actually is, the exact InetAddressFilter API (I pulled it straight from the spring-boot-http-client source), how to wire it on RestClient and WebClient, how to test that it blocks the metadata endpoint, and the cases it still won't save you from.

What is SSRF, and why are outbound HTTP clients the weak point?

SSRF is an attack where the attacker controls the destination of a request your server makes, so your server becomes a proxy into networks the attacker can't reach directly. The weak point is any outbound HTTP client that fetches a URL derived from user input.

The reason it's so dangerous is trust. Your service runs inside a private network. It can reach the database, internal admin panels, service-mesh sidecars, and the cloud metadata endpoint, none of which are exposed to the internet. When an attacker can pick the URL, they borrow that network position. A feature as boring as "fetch the user's avatar from a URL" becomes a way to read http://10.0.0.5:8080/actuator/env or pull IAM credentials from 169.254.169.254.

Here's the kind of code that ships in almost every app:

// Looks innocent. This is the SSRF sink.
@PostMapping("/webhooks")
public void register(@RequestBody WebhookRequest request) {
    // request.url() came from the user
    String body = restClient.get()
        .uri(request.url())
        .retrieve()
        .body(String.class);
    process(body);
}

Nothing here validates where request.url() points. Blocklisting strings like localhost or 127.0.0.1 doesn't work either, because 2130706433, 0x7f000001, 0, and a DNS name that resolves to a private IP all reach loopback too. The only check that holds up is on the resolved IP address, right before you connect. That's exactly where Spring Boot 4.1 now lets you plug in.

What is the InetAddressFilter in Spring Boot 4.1?

InetAddressFilter is a functional interface in org.springframework.boot.http.client, new in Spring Boot 4.1, that decides which resolved IP addresses an outbound client may connect to. It has one abstract method, matches(InetAddress), and a set of static factories and combinators for building rules.

The key thing to get right is the polarity: an address that matches is allowed. An address that does not match is blocked, and the client throws a FilteredHostException instead of connecting. So externalAddresses() means "match (allow) external addresses, block everything else."

These are the factory methods you'll actually use:

import org.springframework.boot.http.client.InetAddressFilter;
 
// Allow only external (public) addresses. Blocks loopback, private, link-local, ULA.
InetAddressFilter external = InetAddressFilter.externalAddresses();
 
// The inverse: match internal addresses only.
InetAddressFilter internal = InetAddressFilter.internalAddresses();
 
// Allow a specific CIDR block (IPv4 or IPv6), nothing else.
InetAddressFilter allowList = InetAddressFilter.of("203.0.113.0/24");
 
// Compose: allow external, but also exclude one known-bad host.
InetAddressFilter strict = InetAddressFilter.externalAddresses()
    .andNot("203.0.113.7");

What counts as "internal" is defined in the framework, not left to you. Reading InternalInetAddressFilter in the source, an address is internal if isLoopbackAddress(), isLinkLocalAddress(), or isSiteLocalAddress() returns true, plus IPv6 Unique Local Addresses in fc00::/7, and it unwraps NAT64 (64:ff9b::/96) addresses to re-check the embedded IPv4. Concretely, externalAddresses() blocks all of this:

RangeExampleWhy it's dangerous
Loopback 127.0.0.0/8, ::1127.0.0.1, 0x7f000001Local admin ports, actuator
Link-local 169.254.0.0/16, fe80::/10169.254.169.254Cloud metadata, IAM credentials
Private 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/1610.0.0.5Internal services, databases
IPv6 ULA fc00::/7fd00::1Internal IPv6 services

That link-local row is the one people miss when they roll their own filter. The metadata endpoint at 169.254.169.254 is link-local, and externalAddresses() blocks it for free.

How do you block SSRF on a blocking RestClient?

You build an InetAddressFilter, attach it to HttpClientSettings, build a request factory from those settings, and hand that factory to your RestClient. The filter then runs on every connection the client makes.

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.web.client.RestClient;
 
@Configuration
public class SecureRestClientConfig {
 
    @Bean
    RestClient outboundRestClient() {
        InetAddressFilter onlyExternal = InetAddressFilter.externalAddresses();
 
        HttpClientSettings settings = HttpClientSettings.defaults()
            .withInetAddressFilter(onlyExternal);
 
        ClientHttpRequestFactory requestFactory =
            ClientHttpRequestFactoryBuilder.jdk().build(settings);
 
        return RestClient.builder()
            .requestFactory(requestFactory)
            .build();
    }
}

HttpClientSettings is an immutable record, so withInetAddressFilter returns a new copy. The same settings object also carries timeouts, redirects, and SSL bundles, so you can chain .withTimeouts(...) and friends on the same builder.

ClientHttpRequestFactoryBuilder has a factory per client library: jdk(), reactor(), jetty(), and httpComponents(), plus detect() if you want Spring to pick whatever is on the classpath. The filtering wiring is built into each one, so the choice is about which HTTP library you prefer, not whether the filter works.

Now the webhook handler from earlier is safe without changing a line of its own logic, because the injected RestClient refuses internal addresses:

public WebhookResult fetch(String userSuppliedUrl) {
    // Throws FilteredHostException if userSuppliedUrl resolves internally
    return outboundRestClient.get()
        .uri(userSuppliedUrl)
        .retrieve()
        .body(WebhookResult.class);
}

How do you block SSRF on a reactive WebClient?

The reactive path is the same shape, but you build a ClientHttpConnector instead of a request factory and pass it to WebClient.builder().clientConnector(...). The HttpClientSettings and InetAddressFilter are identical.

import org.springframework.boot.http.client.HttpClientSettings;
import org.springframework.boot.http.client.InetAddressFilter;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
 
@Configuration
public class SecureWebClientConfig {
 
    @Bean
    WebClient outboundWebClient() {
        HttpClientSettings settings = HttpClientSettings.defaults()
            .withInetAddressFilter(InetAddressFilter.externalAddresses());
 
        ClientHttpConnector connector =
            ClientHttpConnectorBuilder.reactor().build(settings);
 
        return WebClient.builder()
            .clientConnector(connector)
            .build();
    }
}

ClientHttpConnectorBuilder mirrors the blocking builder with reactor(), jetty(), httpComponents(), jdk(), and detect(). If you're on the default WebFlux stack, reactor() is the one you want. A blocked request surfaces as an error signal in the reactive stream, so it shows up in onError, not as a thrown exception on the calling thread.

One detail worth knowing: the filter runs at the connector's DNS-resolution and connect layer, not as a string check on the URL. That means it sees the address the client is actually about to connect to, including the target of an HTTP redirect. A 302 that points your client at http://10.0.0.5/ gets filtered on the redirect hop, not just the first request.

How do you test that the filter blocks the cloud metadata endpoint?

Write a test that points the secured client at 169.254.169.254 and assert it fails with a FilteredHostException. Because the client never opens a socket, the test is fast and doesn't touch the network.

import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
import org.junit.jupiter.api.Test;
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.FilteredHostException;
import org.springframework.boot.http.client.HttpClientSettings;
import org.springframework.boot.http.client.InetAddressFilter;
import org.springframework.web.client.RestClient;
 
class SsrfFilterTests {
 
    private RestClient secureClient() {
        HttpClientSettings settings = HttpClientSettings.defaults()
            .withInetAddressFilter(InetAddressFilter.externalAddresses());
        return RestClient.builder()
            .requestFactory(ClientHttpRequestFactoryBuilder.jdk().build(settings))
            .build();
    }
 
    @Test
    void blocksCloudMetadataEndpoint() {
        RestClient client = secureClient();
 
        assertThatThrownBy(() -> client.get()
                .uri("http://169.254.169.254/latest/meta-data/")
                .retrieve()
                .body(String.class))
            .rootCause()
            .isInstanceOf(FilteredHostException.class);
    }
}

FilteredHostException carries the host it rejected through getHost() and the filter that rejected it through getFilter(), which is handy when you want to log blocked attempts. The client wraps it in its own transport exception, so I assert on the root cause rather than the top-level type. I'd add a second test that hits a public address and asserts it does not throw, so a future refactor can't silently disable the filter.

What are the limits you still have to handle?

The filter is opt-in and only covers the clients you build from HttpClientSettings, so it's not a substitute for thinking about your whole request path. It's a strong control, not a magic shield.

Three things it does not do for you:

  • It's off until you wire it. HttpClientSettings.defaults() has no filter. Every RestClient or WebClient you build the old way is still wide open. Audit for clients that bypass your secured beans.
  • It only sees Spring's HTTP clients. A raw java.net.http.HttpClient, an OkHttp instance, a database driver fetching a remote file, or any library that opens its own sockets will not go through this filter. SSRF lives anywhere an address comes from user input.
  • Block-lists are weaker than allow-lists. externalAddresses() blocks known-internal ranges, which is the right default for "fetch an arbitrary user URL." But if your client only ever talks to one partner API, prefer InetAddressFilter.of("203.0.113.0/24") so a new internal range or a routing quirk can't widen your exposure.

For the request paths you do control, this is the cleanest SSRF defense the framework has ever shipped, and it lines up with the least-privilege thinking I wrote about in my zero trust microservices guide. Set the filter once on a shared client bean, write the two tests, and the boring "fetch this URL" feature stops being a credential leak waiting to happen.

What I like most is that it pushes the check to the only place it's reliable: the resolved IP at connect time. No string parsing, no blocklist of clever loopback spellings, no Bouncy-Castle-style third-party dependency. Just a filter that says yes or no to an InetAddress. If you maintain a Spring Boot service that fetches anything on behalf of users, this is the first thing to turn on after you upgrade to 4.1.

For more on this feature, see the Spring Boot 4.1.0 release notes and the Spring Boot REST client reference docs. For the wider threat model and a checklist beyond Spring, the OWASP SSRF Prevention Cheat Sheet is the reference I keep open.

Keep Reading

Frequently Asked Questions

What is SSRF in Spring Boot?

SSRF (Server-Side Request Forgery) is an attack where a user tricks your Spring Boot service into making an HTTP request to an address the attacker chooses, usually an internal one. The classic target is the cloud metadata endpoint at 169.254.169.254, which can return IAM credentials. Spring Boot 4.1 adds an InetAddressFilter to block these outbound requests at the HTTP client layer.

What is the InetAddressFilter in Spring Boot 4.1?

InetAddressFilter is a functional interface in org.springframework.boot.http.client, added in Spring Boot 4.1, that decides which resolved IP addresses an outbound HTTP client is allowed to connect to. You build one with factory methods like InetAddressFilter.externalAddresses() and attach it through HttpClientSettings. Any request to a non-matching address fails with a FilteredHostException.

Does InetAddressFilter block the cloud metadata endpoint?

Yes. InetAddressFilter.externalAddresses() treats 169.254.169.254 as an internal link-local address and blocks it, along with loopback (127.0.0.0/8, ::1), private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), and IPv6 Unique Local Addresses (fc00::/7). It also unwraps NAT64-embedded addresses before checking them.

Is the InetAddressFilter enabled by default in Spring Boot 4.1?

No. The filter is opt-in. HttpClientSettings.defaults() ships with no address filter, so you have to call withInetAddressFilter(...) and build your RestClient or WebClient from those settings. Until you wire it in, your outbound clients behave exactly as they did before.

Rabinarayan Patra - Software Development Engineer

Rabinarayan Patra

SDE II at Amazon. Previously at ThoughtClan Technologies building systems that processed 700M+ daily transactions. I write about Java, Spring Boot, microservices, and the things I figure out along the way. More about me →

X (Twitter)LinkedIn

Stay in the loop

Get the latest articles on system design, frontend and backend development, and emerging tech trends, straight to your inbox. No spam.