4.9 KiB
← The exploit · Module README · Where the filter runs →
3. matches means allow
This is the chapter that matters. Get this backwards and you ship a service that still leaks and also cannot make its own outbound calls.
What the sources say
The Spring Boot 4.1 release notes:
Both reactive and blocking HTTP clients can now be configured with an
InetAddressFilterwhich can block outgoing requests to specific addresses.
The reference documentation, one click further in:
To limit the address that a client can call, you can use an
InetAddressFilterwhich will only allow outgoing calls to addresses that match the filter.
Those describe opposite configurations, and the release-notes sentence is the one that got
copied into the write-ups. The reference documentation is the correct one, and the bytecode
agrees with it. FilteredAddresses.of(stream, predicate) filters the resolved addresses
through the predicate and keeps what matches; Filtered.orElseThrow raises
FilteredHostException when nothing is left:
T orElseThrow(Supplier<String>, InetAddressFilter):
if (result == null || check.test(result)) throw new FilteredHostException(...)
return result
So: the filter is an allow-list. An address that matches is permitted. An address that does not match is dropped, and if every address is dropped the call fails.
The inversion, run
The blocklist profile is what you write if you act on the word "block" — name the private
ranges you want forbidden:
InetAddressFilter.of("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16");
From docs/output/exploit-by-profile.txt:
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 was written to
forbid — succeeds. The legitimate call to example.com fails. The configuration
achieved precisely the opposite of its intent in both directions.
The loopback row still blocks, which is the cruel part: the naive exploit everyone tests with stops working, so the change looks like it worked.
What the factory methods actually 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:
0.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 192.0.0.0/24 192.0.0.0/29
192.0.2.0/24 192.88.99.0/24 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24
240.0.0.0/4 255.255.255.255/32 ::/128 ::1/128 64:ff9b::/96 100::/64
2001::/23 2001::/32 2001:2::/48 2001:db8::/32 2001:10::/28 2002::/16
fc00::/7 fe80::/10
It still matches 10.0.0.1, because the method is not just that list:
specialPurpose() = of(<the 25 CIDRs>).or(InternalInetAddressFilter.instance)
and InternalInetAddressFilter is isLoopbackAddress() || isLinkLocalAddress() || isSiteLocalAddress(), plus an IPv6 arm that also decodes NAT64-embedded addresses
(64:ff9b::a00:1 is 10.0.0.1 wearing a hat) and re-tests the embedded IPv4. The RFC 1918
coverage comes from the JDK's own predicates, not from the registry list.
That is worth knowing before you build anything on top of specialPurpose(), because its name
and its javadoc both suggest it is the RFC 6890 registry and only the registry.
internalAddresses().negate() is not externalAddresses()
They look interchangeable. They are not, and
docs/output/filter-matrix.txt has the rows:
| Address | externalAddresses() |
internalAddresses().negate() |
|---|---|---|
100.64.0.1 (CGNAT) |
false |
true |
0.0.0.0 |
false |
true |
192.0.2.1 (TEST-NET-1) |
false |
true |
224.0.0.1 (multicast) |
false |
true |
internalAddresses() is routable().and(InternalInetAddressFilter.instance) — loopback,
link-local and site-local, nothing else. Negating it allows everything that is none of those,
and "none of those" includes carrier-grade NAT space, which on a mobile or ISP-adjacent network
is emphatically not the public internet.
externalAddresses() is routable().andNot(multicast(), specialPurpose()), which is a
different and stricter statement. Prefer it.
The short version
| Intent | Write |
|---|---|
| only call the public internet | InetAddressFilter.externalAddresses() |
| only call these destinations | InetAddressFilter.of("203.0.113.0/24", "198.51.100.7") |
| public internet minus a range | externalAddresses().andNot("203.0.113.0/24") |
| never | InetAddressFilter.of(<the ranges you want to forbid>) |