116 lines
4.9 KiB
Markdown
116 lines
4.9 KiB
Markdown
[← The exploit](02-the-exploit.md) · [Module README](../README.md) · [Where the filter runs →](04-where-the-filter-runs.md)
|
|
|
|
# 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:
|
|
|
|
```java
|
|
InetAddressFilter.of("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16");
|
|
```
|
|
|
|
From [`docs/output/exploit-by-profile.txt`](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:
|
|
|
|
```java
|
|
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`](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 →](04-where-the-filter-runs.md)
|