Add the ssrf module
This commit is contained in:
115
ssrf/docs/03-allow-not-block.md
Normal file
115
ssrf/docs/03-allow-not-block.md
Normal file
@@ -0,0 +1,115 @@
|
||||
[← 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)
|
||||
Reference in New Issue
Block a user