Skip to main content

HTTP Client SSRF Mitigation in Spring Boot 4.1: The InetAddressFilter Everyone Will Configure Backwards

Spring Boot 4.1 added InetAddressFilter, a one-bean SSRF control for every auto-configured HTTP client. The release notes call it a way to “block” addresses; the reference documentation says it only allows addresses that match. They are opposite instructions, and acting on the first one produces a service that still leaks internal data and can no longer reach the internet. A working exploit, the allow-versus-block inversion reproduced end to end, why internalAddresses().negate() is not externalAddresses(), how the strength of the filter depends on which HTTP client is on your classpath, and the vararg overload that silently matches nothing.

Every application that fetches a URL somebody else supplied has this bug until it is fixed. Link previews. Webhook validators. “Import from URL”. Server-side PDF rendering. Each one is a function that takes a destination from a stranger and dials it from inside your network, where the firewall thinks you are trustworthy. Spring Boot 4.1 shipped a control for it: InetAddressFilter. Declare one bean and every auto-configured HTTP client refuses to connect to an address it does not match. That last sentence is the whole article, and the word that matters is match. The 4.1 release notes describe the filter as something that “can block outgoing requests to specific addresses”. The reference documentation says it “will only allow outgoing calls to addresses that match the filter”. Those are opposite instructions, and the release-notes sentence is the one that travels — it is shorter, it is what the announcement says, and “block the private ranges” is what anybody would assume an SSRF control does. Acting on it produces a service that still leaks internal data and can no longer reach the internet, which I have reproduced end to end below, along with two more ways the filter quietly does nothing.
Verified against. JDK 25 (Temurin 25.0.4.1+1) · Spring Boot 4.1.1 · Spring Framework 7.0.9 · Apache HttpComponents 5.6.4 · Tomcat 11.0.24. The 4.1 line went GA on 10 June 2026 and InetAddressFilter is @since 4.1.0. Versions were read from maven-metadata.xml on Maven Central, not from release announcements. Every transcript below came from a run of the companion project.
If you are here because…Start at
you want to see SSRF actually workThe exploit
you configured the filter and internal calls still succeedAllow, not block
every outbound call started failing after you added itAllow, not block and Composing filters
you added the bean and nothing changed at allThree ways it silently does nothing
your context stopped startingThree ways it silently does nothing
you want to know how strong this actually isWhere the filter runs

The exploit, in full

The vulnerable endpoint is deliberately boring. It is the code you have written:
@GetMapping("/preview")
public ResponseEntity<Map<String, Object>> preview(@RequestParam String url) {
    String body = this.restClient.get().uri(url).retrieve().body(String.class);
    return ResponseEntity.ok(Map.of("outcome", "FETCHED", "url", url, "body", body));
}
Next door sits something that has no authentication, because the network was supposed to be the authentication:
@GetMapping("/internal/credentials")
public Map<String, String> credentials() {
    return Map.of("AccessKeyId", "ASIA-EXAMPLE-NOT-REAL", ...);
}
With no filter bean at all, every target works:
http://127.0.0.1:8080/internal/credentials    FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-...
http://localhost:8080/internal/credentials    FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-...
http://[::1]:8080/internal/credentials        FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-...
http://172.16.10.3:8080/internal/credentials  FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-...
http://example.com/                           FETCHED | <!doctype html>...
Four targets rather than one, because the extra three are where hand-written defences leak. localhost defeats a check written against the literal string 127.0.0.1. [::1] defeats a check that only considers IPv4. And 172.16.10.3 is the container’s own address on its network interface — the same process, reached over a route that a loopback-only rule does not cover. In a real deployment that row is the pod next door. The last row matters as much as the others. Any mitigation has to leave it working, and one of the configurations below does not.
Why URL validation cannot work. 0x7f.0.0.1, 2130706433, 127.1, a hostname you control that resolves to 127.0.0.1, and a redirect from a public URL to a private one all reach loopback without the string 127.0.0.1 appearing in the request. The check has to happen at address-resolution time, on the resolved address, in the same lookup the connection will use. That is exactly what InetAddressFilter is, and it is why it belongs in the HTTP client rather than in a validator over your request parameters.

Allow, not block

Read matches as permit. The mechanism is four lines of FilteredAddresses: the resolved addresses are filtered through the predicate, the survivors are what the connection uses, and if nothing survives you get a FilteredHostException.
T orElseThrow(Supplier<String> message, InetAddressFilter filter) {
    if (this.result == null || this.check.test(this.result)) {
        throw new FilteredHostException(message.get(), filter);
    }
    return this.result;
}
So here is what happens if you believe the word “block” and name the ranges you want forbidden:
@Bean
InetAddressFilter httpClientInetAddressFilter() {
    return InetAddressFilter.of("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16");
}
http://127.0.0.1:8080/internal/credentials    BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
http://172.16.10.3:8080/internal/credentials  FETCHED           | {"Expiration":"2026-08-...
http://example.com/                           BLOCKED_BY_FILTER | Filtered host 'example.com'
Read those three lines together. The RFC 1918 target — the one the configuration exists to forbid — succeeds. The legitimate call to example.com fails. The configuration achieved the exact opposite of its intent in both directions simultaneously. The cruel part is the first line. Loopback still blocks, because loopback is not in the list either. So the exploit everybody tests with stops working, and the change looks like it worked.
The same three CIDR ranges, two ways round externalAddresses() — correct 10.0.0.0/8→ no match → DENIED 172.16.0.0/12→ no match → DENIED 127.0.0.1→ no match → DENIED example.com→ match → ALLOWED Attack blocked. Service still works. This is the reference documentation’s own example. of(10/8, 172.16/12, 192.168/16) — inverted 10.0.0.0/8→ match → ALLOWED 172.16.0.0/12→ match → ALLOWED 127.0.0.1→ no match → DENIED example.com→ no match → DENIED Attack succeeds. Service cannot reach the internet. Loopback still blocks, so it looks like it worked.

What the factory methods really contain

specialPurpose() is documented as “special purpose IP addresses as defined by RFC 6890”. Its constant pool holds 25 CIDR strings, and none of them is an RFC 1918 range. It still matches 10.0.0.1, because the method is not only that list:
specialPurpose() = of(<the 25 RFC 6890 CIDRs>).or(InternalInetAddressFilter.instance)
and InternalInetAddressFilter is isLoopbackAddress() || isLinkLocalAddress() || isSiteLocalAddress(), plus an IPv6 arm that decodes NAT64-embedded addresses and re-tests the embedded IPv4 — so 64:ff9b::a00:1, which is 10.0.0.1 wearing a hat, is caught too. The RFC 1918 coverage comes from the JDK’s own predicates, not from the registry. Worth knowing before you build anything on top of that method, because its name and its javadoc both suggest it is the registry and only the registry.

internalAddresses().negate() is not externalAddresses()

They look interchangeable. Running every factory method against fifteen addresses says otherwise (full matrix in the repo):
AddressexternalAddresses()internalAddresses().negate()
100.64.0.1 (carrier-grade NAT)falsetrue
0.0.0.0falsetrue
192.0.2.1 (TEST-NET-1)falsetrue
224.0.0.1 (multicast)falsetrue
internalAddresses() is routable().and(InternalInetAddressFilter.instance) — loopback, link-local, site-local, and nothing else. Negating it allows everything that is none of those, which includes CGNAT space. On a mobile or ISP-adjacent network that is emphatically not “the public internet”. externalAddresses() is routable().andNot(multicast(), specialPurpose()), a stricter and different statement. Prefer it.
The four-line summary. Only call the public internet: InetAddressFilter.externalAddresses(). Only call these destinations: InetAddressFilter.of("203.0.113.0/24"). Public internet minus a range: externalAddresses().andNot("203.0.113.0/24"). Never: InetAddressFilter.of(<the ranges you want to forbid>).

Where the filter runs depends on your HTTP client

One bean, four insertion points. Boot picks by what is on the classpath:
ClientClass that applies the filterHook
Apache HttpComponentsHttpComponentsFilteredDnsResolverDnsResolver
JDK HttpClientJdkFilteredProxySelectorProxySelector
JettyJettyFilteredSocketAddressResolverSocketAddressResolver
Reactor NettyReactorFilteredResolvedAddressSelectorresolved-address selector
Three are name-resolution hooks. The JDK one is not, because java.net.http.HttpClient exposes no resolver — so Boot filters in the ProxySelector, which is handed a URI and nothing else. That is not cosmetic.
Two hooks, two strengths Apache HttpComponents — filters inside the DnsResolver resolve(host) [10.0.0.1, 93.184.216.34] → filter → [93.184.216.34] → connect Same lookup. The connection uses exactly the vetted addresses. Partial filtering; throws only if none survive. JDK HttpClient — filters inside the ProxySelector select(URI) getByName(host) — ONE address → allow/deny → second lookup, then connect All-or-nothing, and the address vetted is not necessarily the address dialled. That gap is a DNS-rebinding window.
HttpComponentsFilteredDnsResolver.resolve keeps the addresses that match and returns the survivors, throwing only when nothing is left. JdkFilteredProxySelector.select has no addresses to work with, so it calls InetAddress.getByName itself — which returns one address — and between that decision and the socket there is a second resolution.
If this filter is load-bearing, put httpclient5 on the classpath. None of this is Spring’s fault — the JDK client offers no better hook — but it means the strength of your SSRF mitigation depends on a dependency you probably chose for other reasons. A hostname with a short TTL that answers public once and private next is defeated by the Apache path and not by the JDK one.

A second consequence on the JDK path: resolve swallows UnknownHostException and returns null, which reads as “does not match”. So a hostname that does not resolve is reported as Filtered host 'typo.example'. Whoever debugs that goes and reads the allow-list, which is the wrong file.

Three ways it silently does nothing

1. The starter does not bring it

spring-boot-starter-web alone does not put InetAddressFilter on the classpath, and does not give you an auto-configured RestClient.Builder either — Boot 4 split the modules apart. The compile error is the good outcome:
cannot find symbol
  symbol:   class FilteredHostException
Add spring-boot-starter-restclient (or -webclient), which pulls in spring-boot-restclient and through it spring-boot-http-client.

2. A client you built yourself

The bean reaches auto-configured builders. A RestClient.create() or a new RestTemplate() written inside your own class is not one, and no bean will change that. Inject RestClient.Builder, or apply the filter by hand:
HttpClientSettings settings = HttpClientSettings.defaults()
        .withInetAddressFilter(InetAddressFilter.externalAddresses());
ClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.jdk().build(settings);
And be clear about the boundary: this is a control on Spring’s HTTP clients, not an egress policy for the JVM. A JDBC URL, a raw URL.openStream(), an SDK with its own transport and a ProcessBuilder running curl all go straight past it. If you need the general thing, it belongs in the network.

3. Two beans, and a diagnostic that blames something else

HttpClientAutoConfiguration reads the filter with ObjectProvider.getIfAvailable(), which is not “pick one”. Two beans and the context does not start:
No qualifying bean of type 'org.springframework.boot.http.client.InetAddressFilter' available:
expected single matching bean but found 2: firstFilter,secondFilter
but the framed message Boot prints underneath — the part everyone actually reads — names something four levels away:
Description:
Parameter 0 of method restClientBuilder in ...RestClientAutoConfiguration required a single
bean, but 2 were found:
The words InetAddressFilter do not appear in it. If you are merging two starters or two shared config modules, this will look like a RestClient problem.
There is no property for this. HttpClientSettingsProperties carries redirects, connectTimeout, readTimeout, cookieHandling and ssl. There is no spring.http.clients.inet-address-filter. So the filter cannot be enabled per environment from a config server and cannot be turned off during an incident without a deploy. If you need a switch, put the bean behind your own @ConditionalOnProperty and decide the default deliberately.

Composing filters, and the vararg that matches nothing

and, or, andNot and negate each have a String... overload, and one of them does not mean what the symmetry suggests:
public default InetAddressFilter and(String... addresses) {
    return and(Arrays.stream(addresses).map(IpAddress::of).map(...).toList());
}
Each address becomes its own filter, and and(Collection) folds the list with logical AND. So and("104.16.0.0/12", "172.64.0.0/13") asks for an address inside both ranges. Nothing is inside two disjoint ranges, so the filter matches nothing and every outbound call fails:
address under test: 104.20.23.154  (inside 104.16.0.0/12, outside 172.64.0.0/13)

of("104.16.0.0/12")                                            -> true
of("104.16.0.0/12", "172.64.0.0/13")                           -> true

externalAddresses().and("104.16.0.0/12")                       -> true
externalAddresses().and("104.16.0.0/12", "172.64.0.0/13")      -> false
externalAddresses().and(of("104.16.0.0/12", "172.64.0.0/13"))  -> true
of ORs its addresses; and does not. The javadoc says the addresses are ANDed with the filter “in any form supported by of(String...)”, which reads as though they are combined the way of combines them. That phrase is about the format of each string. Whenever you pass more than one address to and, wrap them in of first. One address is safe; two silently is not. There is no warning, no log line, and the symptom is that everything is blocked — which looks like the filter working. andNot(a, b) and or(a, b) are both fine.

Operating it

FilteredHostException is a plain RuntimeException. Uncaught in a controller it is a bare HTTP 500 whose default body does not name the host, so the first production symptom is a 500 with nothing useful in it. Catch it:
catch (FilteredHostException ex) {
    log.warn("outbound call to {} blocked by {}", ex.getHost(), ex.getFilter());
    return ResponseEntity.status(HttpStatus.BAD_GATEWAY).build();
}
getHost() is the host string from the URI, not the resolved address — Filtered host 'localhost', never Filtered host '127.0.0.1'. Do not echo the message back to the caller: it confirms which hosts are unreachable, which turns your error response into a network scanner. Alert on the exception, because it is either an attack or an outage and you want to know which. The outage is the one that will bite first: an allow-list pinned to IP ranges fails the day the destination changes its DNS. The allow-list profile in the companion repo was written against 93.184.216.34 — example.com’s address for a decade — and every call failed, because example.com now sits behind Cloudflare. Finally, the filter is a @FunctionalInterface with no Spring dependencies, so the allow/deny decision is a unit test with no context and no network:
assertThat(InetAddressFilter.externalAddresses()
        .matches(InetAddress.getByName("169.254.169.254"))).isFalse();
Run your own filter through a table like that before you deploy it. The four-row disagreement above is not something you would find by reading.

Should you use it at all?

Yes, and it is not sufficient. InetAddressFilter is one bean and it closes the specific hole where a Spring HTTP client dials an attacker-chosen address. That is genuinely worth having and there is no reason not to add it. But it covers Spring’s HTTP clients and nothing else, it is only as strong as the client you happen to have, and it cannot be changed without a deploy. It belongs alongside egress rules in the network and authentication on the internal services — not instead of them. The instinct to treat “we added the filter” as closing the SSRF ticket is the thing to resist.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.