1
0
Files
spring-security-demo/ssrf/docs/03-allow-not-block.md
2026-08-29 09:31:09 +05:30

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 InetAddressFilter which 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 InetAddressFilter which 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>)

Where the filter runs →