Add the ssrf module
This commit is contained in:
@@ -12,6 +12,7 @@ by that module's `scripts/run-all.sh`, never typed by hand.
|
|||||||
| [`filter-chain/`](filter-chain/README.md) | [The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/) | Every filter in the default chain and its order number, where a custom filter actually lands, and how to read the TRACE log |
|
| [`filter-chain/`](filter-chain/README.md) | [The Spring Security Filter Chain Explained](https://ankurm.com/spring-security-filter-chain-explained/) | Every filter in the default chain and its order number, where a custom filter actually lands, and how to read the TRACE log |
|
||||||
| [`cors-csrf/`](cors-csrf/README.md) | [CORS, CSRF and SameSite in Spring Boot 4](https://ankurm.com/spring-boot-4-cors-csrf-samesite/) | Why MVC-layer CORS does not fix a security-layer preflight rejection, what `csrf.spa()` assigns, and the cookie a browser silently refuses to store |
|
| [`cors-csrf/`](cors-csrf/README.md) | [CORS, CSRF and SameSite in Spring Boot 4](https://ankurm.com/spring-boot-4-cors-csrf-samesite/) | Why MVC-layer CORS does not fix a security-layer preflight rejection, what `csrf.spa()` assigns, and the cookie a browser silently refuses to store |
|
||||||
| [`service-to-service/`](service-to-service/README.md) | [Securing Spring Boot Microservices: Token Relay, Service-to-Service JWT and mTLS](https://ankurm.com/spring-boot-microservices-token-relay-mtls/) | Whose identity arrives at the last service under relay, client credentials and token exchange — and what a resource server does not check by default |
|
| [`service-to-service/`](service-to-service/README.md) | [Securing Spring Boot Microservices: Token Relay, Service-to-Service JWT and mTLS](https://ankurm.com/spring-boot-microservices-token-relay-mtls/) | Whose identity arrives at the last service under relay, client credentials and token exchange — and what a resource server does not check by default |
|
||||||
|
| [`ssrf/`](ssrf/README.md) | [HTTP Client SSRF Mitigation in Spring Boot 4.1](https://ankurm.com/spring-boot-4-1-ssrf-inetaddressfilter/) | A working SSRF exploit against a link-preview endpoint, and the `InetAddressFilter` that stops it — including the two ways of configuring it that silently do the opposite |
|
||||||
|
|
||||||
They are related more closely than they look. `filter-chain` is about how an `Authentication`
|
They are related more closely than they look. `filter-chain` is about how an `Authentication`
|
||||||
gets into `SecurityContextHolder` in the first place and in what order; `context-propagation` is
|
gets into `SecurityContextHolder` in the first place and in what order; `context-propagation` is
|
||||||
@@ -21,6 +22,13 @@ thread it ends up on. An `@Async` method carrying `@PreAuthorize` fails with
|
|||||||
the third — and a custom authentication filter that never populated the context in the first
|
the third — and a custom authentication filter that never populated the context in the first
|
||||||
place fails the same way, for reasons that belong to the first.
|
place fails the same way, for reasons that belong to the first.
|
||||||
|
|
||||||
|
`ssrf` is the outbound counterpart to all of them. Every other module asks what a request
|
||||||
|
arriving at this application is allowed to do; this one asks where this application is allowed
|
||||||
|
to send a request, which turns out to be the question an attacker cares about once they have
|
||||||
|
found an endpoint that fetches a URL. Its filter is not part of Spring Security at all — it
|
||||||
|
is a Boot 4.1 HTTP-client control — and that is worth noticing, because a `SecurityFilterChain`
|
||||||
|
has nothing to say about it.
|
||||||
|
|
||||||
`service-to-service` is the same question one process further out: `cors-csrf` and
|
`service-to-service` is the same question one process further out: `cors-csrf` and
|
||||||
`context-propagation` ask whether an identity survives a thread or a browser boundary, and this
|
`context-propagation` ask whether an identity survives a thread or a browser boundary, and this
|
||||||
one asks whether it survives an HTTP boundary — and what the service on the far side bothers to
|
one asks whether it survives an HTTP boundary — and what the service on the far side bothers to
|
||||||
|
|||||||
87
ssrf/README.md
Normal file
87
ssrf/README.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# `ssrf` — SSRF mitigation with `InetAddressFilter`, vulnerable endpoint included
|
||||||
|
|
||||||
|
Companion project for
|
||||||
|
[**HTTP Client SSRF Mitigation in Spring Boot 4.1: The `InetAddressFilter` Everyone Will
|
||||||
|
Configure Backwards**](https://ankurm.com/spring-boot-4-1-ssrf-inetaddressfilter/) on ankurm.com.
|
||||||
|
|
||||||
|
One application, one deliberately vulnerable endpoint, and five filter configurations selected
|
||||||
|
by Spring profile. Every transcript under [`docs/output/`](docs/output/) came from running it;
|
||||||
|
`./scripts/run-all.sh` regenerates all of them.
|
||||||
|
|
||||||
|
## Versions
|
||||||
|
|
||||||
|
| | Version | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| JDK | 25 (Temurin 25.0.4.1+1) | current LTS |
|
||||||
|
| Spring Boot | **4.1.1** | 4.1.0 GA was 10 June 2026; `InetAddressFilter` is `@since 4.1.0` |
|
||||||
|
| Spring Framework | 7.0.9 | Boot-managed |
|
||||||
|
| Apache HttpComponents | 5.6.4 | on the classpath deliberately — see [chapter 4](docs/04-where-the-filter-runs.md) |
|
||||||
|
| Tomcat | 11.0.24 | |
|
||||||
|
| JUnit Jupiter / AssertJ | Boot-managed | 4 assertions |
|
||||||
|
|
||||||
|
Versions were read from `repo1.maven.org/.../maven-metadata.xml`, not from release
|
||||||
|
announcements.
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/run.sh # no filter bean at all
|
||||||
|
./scripts/exploit.sh # four targets, four sets of credentials
|
||||||
|
|
||||||
|
./scripts/run.sh docsfilter # InetAddressFilter.externalAddresses()
|
||||||
|
./scripts/exploit.sh # internal targets blocked, example.com still works
|
||||||
|
|
||||||
|
./scripts/run.sh blocklist # the inversion
|
||||||
|
./scripts/exploit.sh # RFC 1918 target succeeds, example.com fails
|
||||||
|
|
||||||
|
./scripts/run-all.sh # regenerate everything under docs/output/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
| Profile | Filter bean | What it shows |
|
||||||
|
|---|---|---|
|
||||||
|
| *(none)* | — | the exploit, working |
|
||||||
|
| `docsfilter` | `externalAddresses()` | the reference documentation's recommendation, and it is correct |
|
||||||
|
| `blocklist` | `of(RFC1918)` | the release notes' word "block", acted on: attack succeeds, legitimate call fails |
|
||||||
|
| `negated` | `internalAddresses().negate()` | looks equivalent to `externalAddresses()`, differs on four rows |
|
||||||
|
| `allowlist` | `externalAddresses().and(of(...))` | naming your destinations, and what that costs when their DNS changes |
|
||||||
|
| `twofilters` | two beans | the context does not start, and the diagnostic blames `RestClient` |
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /preview?url=` | the vulnerable fetcher |
|
||||||
|
| `GET /internal/credentials` | the thing that must not be reachable |
|
||||||
|
| `GET /diag/filter?host=` | what the running context decided. Delete before shipping |
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
1. [What SSRF actually costs you](docs/01-what-ssrf-costs-you.md)
|
||||||
|
2. [The exploit, start to finish](docs/02-the-exploit.md)
|
||||||
|
3. [`matches` means allow](docs/03-allow-not-block.md) — the one that matters
|
||||||
|
4. [Where the filter runs depends on your HTTP client](docs/04-where-the-filter-runs.md)
|
||||||
|
5. [Wiring it up, and the three ways it silently does nothing](docs/05-wiring-it-up.md)
|
||||||
|
6. [Operating it](docs/06-operating-it.md)
|
||||||
|
7. [Composing filters, and the vararg that matches nothing](docs/07-composing-filters.md)
|
||||||
|
|
||||||
|
## Captured output
|
||||||
|
|
||||||
|
| File | Produced by |
|
||||||
|
|---|---|
|
||||||
|
| [`filter-matrix.txt`](docs/output/filter-matrix.txt) | `FilterMatrix` — 15 addresses × 9 filters |
|
||||||
|
| [`exploit-by-profile.txt`](docs/output/exploit-by-profile.txt) | `run-all.sh` — five profiles, five targets each |
|
||||||
|
| [`and-varargs-trap.txt`](docs/output/and-varargs-trap.txt) | `AndVarargsTrap` |
|
||||||
|
| [`two-filter-beans.txt`](docs/output/two-filter-beans.txt) | the `twofilters` startup failure |
|
||||||
|
| [`tests.txt`](docs/output/tests.txt) | `WhereTheFilterRunsTests` |
|
||||||
|
|
||||||
|
## The three findings worth carrying away
|
||||||
|
|
||||||
|
1. **`matches` means allow.** The release notes say "block"; the reference documentation says
|
||||||
|
"only allow". The second is right. Writing `of(<ranges to forbid>)` produces a filter that
|
||||||
|
permits exactly what you meant to stop.
|
||||||
|
2. **`internalAddresses().negate()` is not `externalAddresses()`.** They disagree on CGNAT
|
||||||
|
space, `0.0.0.0`, TEST-NET-1 and multicast — the negation allows all four.
|
||||||
|
3. **`and("a", "b")` matches nothing.** Each address becomes a separate filter and they are
|
||||||
|
ANDed. Wrap multiple addresses in `of(...)` first.
|
||||||
46
ssrf/docs/01-what-ssrf-costs-you.md
Normal file
46
ssrf/docs/01-what-ssrf-costs-you.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
[Module README](../README.md) · [The exploit →](02-the-exploit.md)
|
||||||
|
|
||||||
|
# 1. What SSRF actually costs you
|
||||||
|
|
||||||
|
Server-Side Request Forgery is not a parsing bug. Every line of
|
||||||
|
[`LinkPreviewController`](../src/main/java/com/ankurm/ssrf/LinkPreviewController.java) is
|
||||||
|
correct in isolation. The vulnerability is architectural: a process that will fetch a URL of the
|
||||||
|
caller's choosing sits inside a network where some destinations are privileged, and privilege in
|
||||||
|
that network is decided by source address.
|
||||||
|
|
||||||
|
That is why SSRF is so consistently severe. The attacker does not need to reach your internal
|
||||||
|
service — they need your service to reach it, and it already can.
|
||||||
|
|
||||||
|
The canonical prize is the cloud instance metadata service on `169.254.169.254`, which hands
|
||||||
|
short-lived role credentials to anything on the instance that asks, with no authentication. But
|
||||||
|
the ordinary case is duller and more common: an internal admin API, an unauthenticated actuator,
|
||||||
|
a `/metrics` endpoint, an Elasticsearch cluster, a Redis instance, a sidecar's admin port.
|
||||||
|
|
||||||
|
## The features that are this bug
|
||||||
|
|
||||||
|
If your service does any of these with a user-supplied URL, you have this shape:
|
||||||
|
|
||||||
|
- link previews and URL unfurling
|
||||||
|
- webhook registration and its "send a test event" button
|
||||||
|
- avatar or document "import from URL"
|
||||||
|
- server-side PDF and screenshot rendering
|
||||||
|
- XML parsing with external entities enabled
|
||||||
|
- anything that follows a redirect it did not choose
|
||||||
|
|
||||||
|
## What Boot 4.1 changed
|
||||||
|
|
||||||
|
Before 4.1 you wrote the defence yourself: resolve the host, check the address against your own
|
||||||
|
list of forbidden ranges, and hope you did it in the same lookup the connection would later use.
|
||||||
|
That last part is where hand-rolled checks fail — see
|
||||||
|
[chapter 4](04-where-the-filter-runs.md).
|
||||||
|
|
||||||
|
Boot 4.1 added
|
||||||
|
[`InetAddressFilter`](https://docs.spring.io/spring-boot/4.1/api/java/org/springframework/boot/http/client/InetAddressFilter.html),
|
||||||
|
declared once as a bean and applied by `HttpClientAutoConfiguration` to every auto-configured
|
||||||
|
HTTP client. The check moves down into the client's own name resolution, which is the only place
|
||||||
|
it can be both mandatory and correctly timed.
|
||||||
|
|
||||||
|
It is a real improvement and it is easy to configure backwards. The next three chapters are
|
||||||
|
about that.
|
||||||
|
|
||||||
|
[The exploit →](02-the-exploit.md)
|
||||||
49
ssrf/docs/02-the-exploit.md
Normal file
49
ssrf/docs/02-the-exploit.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
[← What SSRF costs you](01-what-ssrf-costs-you.md) · [Module README](../README.md) · [Allow, not block →](03-allow-not-block.md)
|
||||||
|
|
||||||
|
# 2. The exploit, start to finish
|
||||||
|
|
||||||
|
Run the application with no profile, so there is no `InetAddressFilter` bean at all:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/run.sh
|
||||||
|
./scripts/exploit.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The transcript is committed at
|
||||||
|
[`docs/output/exploit-by-profile.txt`](output/exploit-by-profile.txt). The first block is this
|
||||||
|
one:
|
||||||
|
|
||||||
|
```
|
||||||
|
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, four sets of credentials, and the legitimate outbound call still works. That last
|
||||||
|
row matters as much as the others: any mitigation has to leave it intact, and one of the
|
||||||
|
configurations in [chapter 3](03-allow-not-block.md) does not.
|
||||||
|
|
||||||
|
## Why four targets and not one
|
||||||
|
|
||||||
|
`127.0.0.1` is the one every tutorial blocks. The others are why a hand-written check usually
|
||||||
|
leaks:
|
||||||
|
|
||||||
|
| Target | What it defeats |
|
||||||
|
|---|---|
|
||||||
|
| `localhost` | checks written against the literal string `127.0.0.1` |
|
||||||
|
| `[::1]` | checks that only ever consider IPv4 |
|
||||||
|
| `172.16.10.3` | checks that stop at loopback and forget RFC 1918 |
|
||||||
|
|
||||||
|
The fourth is this container's own address on its network interface. It is the same process,
|
||||||
|
reached the same way, over a route that a loopback-only rule does not cover. In a real
|
||||||
|
deployment it is the pod next door.
|
||||||
|
|
||||||
|
Blocking by string is hopeless in a way that is easy to underestimate. `0x7f.0.0.1`,
|
||||||
|
`2130706433`, `127.1`, a DNS name 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 anywhere
|
||||||
|
in the request. This is why the check belongs at address-resolution time and not in a validator
|
||||||
|
over the URL — which is exactly what `InetAddressFilter` is.
|
||||||
|
|
||||||
|
[Allow, not block →](03-allow-not-block.md)
|
||||||
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)
|
||||||
70
ssrf/docs/04-where-the-filter-runs.md
Normal file
70
ssrf/docs/04-where-the-filter-runs.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
[← Allow, not block](03-allow-not-block.md) · [Module README](../README.md) · [Wiring it up →](05-wiring-it-up.md)
|
||||||
|
|
||||||
|
# 4. Where the filter runs depends on your HTTP client
|
||||||
|
|
||||||
|
One `InetAddressFilter` bean, four different insertion points. Boot picks the one that fits
|
||||||
|
whichever client is on the classpath:
|
||||||
|
|
||||||
|
| Client | Class that applies the filter | Hook |
|
||||||
|
|---|---|---|
|
||||||
|
| Apache HttpComponents | `HttpComponentsFilteredDnsResolver` | `DnsResolver` |
|
||||||
|
| JDK `HttpClient` | `JdkFilteredProxySelector` | `ProxySelector` |
|
||||||
|
| Jetty | `JettyFilteredSocketAddressResolver` | `SocketAddressResolver` |
|
||||||
|
| Reactor Netty | `ReactorFilteredResolvedAddressSelector` | resolved-address selector |
|
||||||
|
|
||||||
|
Three of those are name-resolution hooks. The JDK one is not, because `java.net.http.HttpClient`
|
||||||
|
does not expose a resolver — so Boot filters in the `ProxySelector`, which is consulted per
|
||||||
|
request and is handed a `URI` and nothing else.
|
||||||
|
|
||||||
|
That difference is not cosmetic. It is pinned down by
|
||||||
|
[`WhereTheFilterRunsTests`](../src/test/java/org/springframework/boot/http/client/WhereTheFilterRunsTests.java),
|
||||||
|
which lives in `org.springframework.boot.http.client` because both classes are package-private.
|
||||||
|
|
||||||
|
## Apache filters the set; the JDK filters the name
|
||||||
|
|
||||||
|
`HttpComponentsFilteredDnsResolver.resolve` calls the delegate, keeps the addresses that match,
|
||||||
|
and returns the survivors. A host resolving to one private and one public address yields a
|
||||||
|
one-element array containing the public one, and the connection proceeds:
|
||||||
|
|
||||||
|
```java
|
||||||
|
assertThat(filtered.resolve("mixed.example")).hasSize(1)
|
||||||
|
.extracting(InetAddress::getHostAddress).containsExactly("93.184.216.34");
|
||||||
|
```
|
||||||
|
|
||||||
|
It throws only when nothing survives. The connection then uses exactly the addresses that were
|
||||||
|
vetted, in the same lookup — there is no second resolution and therefore no window.
|
||||||
|
|
||||||
|
`JdkFilteredProxySelector.select` has no addresses to work with, so it does its own lookup:
|
||||||
|
|
||||||
|
```java
|
||||||
|
private @Nullable InetAddress resolve(String host) {
|
||||||
|
try { return InetAddress.getByName(host); }
|
||||||
|
catch (UnknownHostException ex) { return null; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`getByName` returns **one** address. The decision is all-or-nothing, and the address that was
|
||||||
|
vetted is not necessarily the address the connection later opens. Between `select()` and the
|
||||||
|
socket there is a second resolution, which is the classic DNS-rebinding window: a hostname whose
|
||||||
|
record has a short TTL and answers with a public address once and a private address next.
|
||||||
|
|
||||||
|
Nothing about 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 may not have thought of as a
|
||||||
|
security control. **If this filter is load-bearing, put `httpclient5` on the classpath.**
|
||||||
|
|
||||||
|
## A typo reads as a policy violation
|
||||||
|
|
||||||
|
`resolve` swallows `UnknownHostException` and returns `null`; `matchesResolvedHost` reads `null`
|
||||||
|
as "does not match". So on the JDK path:
|
||||||
|
|
||||||
|
```java
|
||||||
|
assertThatExceptionOfType(FilteredHostException.class)
|
||||||
|
.isThrownBy(() -> filtered.select(URI.create("http://no-such-host.invalid/")))
|
||||||
|
.withMessage("Filtered host 'no-such-host.invalid'");
|
||||||
|
```
|
||||||
|
|
||||||
|
A hostname that does not resolve is reported as **filtered**, not as unknown. Someone debugging
|
||||||
|
that message will go and read the allow-list, which is the wrong file. Worth knowing before it
|
||||||
|
costs you an afternoon.
|
||||||
|
|
||||||
|
[Wiring it up →](05-wiring-it-up.md)
|
||||||
98
ssrf/docs/05-wiring-it-up.md
Normal file
98
ssrf/docs/05-wiring-it-up.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
[← Where the filter runs](04-where-the-filter-runs.md) · [Module README](../README.md) · [Operating it →](06-operating-it.md)
|
||||||
|
|
||||||
|
# 5. Wiring it up, and the three ways it silently does nothing
|
||||||
|
|
||||||
|
## The bean
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
public class OutboundConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
InetAddressFilter httpClientInetAddressFilter() {
|
||||||
|
return InetAddressFilter.externalAddresses();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`HttpClientAutoConfiguration.httpClientSettings` reads it and folds it into the shared
|
||||||
|
`HttpClientSettings`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
InetAddressFilter filter = inetAddressFilter.getIfAvailable();
|
||||||
|
HttpClientSettings settings = (filter != null)
|
||||||
|
? HttpClientSettings.defaults().withInetAddressFilter(filter)
|
||||||
|
: HttpClientSettings.defaults();
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that `HttpClientSettings.defaults()` is the all-null record — the default filter is `null`,
|
||||||
|
not `all()`.
|
||||||
|
|
||||||
|
## There is no property for it
|
||||||
|
|
||||||
|
`HttpClientSettingsProperties` carries `redirects`, `connectTimeout`, `readTimeout`,
|
||||||
|
`cookieHandling` and `ssl`. There is no `spring.http.clients.inet-address-filter`. Configuration
|
||||||
|
is a bean or an explicit `HttpClientSettings`, and nothing else — so it cannot be turned on per
|
||||||
|
environment from a config server, and it cannot be turned off in an incident without a deploy.
|
||||||
|
|
||||||
|
Plan for that: put the filter behind a `@Profile` or a `@ConditionalOnProperty` yourself if you
|
||||||
|
need a switch.
|
||||||
|
|
||||||
|
## Failure 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 HTTP client
|
||||||
|
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`. This module's
|
||||||
|
[`pom.xml`](../pom.xml) does exactly that.
|
||||||
|
|
||||||
|
## Failure 2 — a client you built yourself
|
||||||
|
|
||||||
|
The filter reaches auto-configured builders. A `RestClient.create()` or a `new RestTemplate()`
|
||||||
|
written inside your own class is not one, and no bean will change it. That is why
|
||||||
|
[`LinkPreviewController`](../src/main/java/com/ankurm/ssrf/LinkPreviewController.java) takes
|
||||||
|
`RestClient.Builder` in its constructor.
|
||||||
|
|
||||||
|
For a hand-built client, apply the filter yourself:
|
||||||
|
|
||||||
|
```java
|
||||||
|
HttpClientSettings settings = HttpClientSettings.defaults()
|
||||||
|
.withInetAddressFilter(InetAddressFilter.externalAddresses());
|
||||||
|
ClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.jdk().build(settings);
|
||||||
|
```
|
||||||
|
|
||||||
|
And note what is still not covered: anything that opens a socket without going through a Spring
|
||||||
|
HTTP client. A JDBC URL, a raw `URL.openStream()`, an SDK with its own transport, a
|
||||||
|
`ProcessBuilder` running `curl`. `InetAddressFilter` is a control on Spring's HTTP clients, not
|
||||||
|
an egress policy for the JVM. If you need the latter, it belongs in the network.
|
||||||
|
|
||||||
|
## Failure 3 — two beans, and a diagnostic that blames the wrong thing
|
||||||
|
|
||||||
|
`getIfAvailable()` is not "pick one". Two `InetAddressFilter` beans and the context does not
|
||||||
|
start — see [`docs/output/two-filter-beans.txt`](output/two-filter-beans.txt):
|
||||||
|
|
||||||
|
```
|
||||||
|
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 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 the part everyone reads. If you are merging two
|
||||||
|
starters or two shared config modules, this failure will look like a `RestClient` problem.
|
||||||
|
|
||||||
|
[Operating it →](06-operating-it.md)
|
||||||
67
ssrf/docs/06-operating-it.md
Normal file
67
ssrf/docs/06-operating-it.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
[← Wiring it up](05-wiring-it-up.md) · [Module README](../README.md) · [Composing filters →](07-composing-filters.md)
|
||||||
|
|
||||||
|
# 6. Operating it
|
||||||
|
|
||||||
|
## What the caller sees
|
||||||
|
|
||||||
|
`FilteredHostException` is a plain `RuntimeException`. Uncaught in a controller it is a bare
|
||||||
|
**HTTP 500**, and Boot's default error body does not name the host — so the first symptom in
|
||||||
|
production is a 500 with nothing useful in the response and a stack trace in the log.
|
||||||
|
|
||||||
|
Catch it. It carries the two things you want:
|
||||||
|
|
||||||
|
```java
|
||||||
|
catch (FilteredHostException ex) {
|
||||||
|
log.warn("outbound call to {} blocked by {}", ex.getHost(), ex.getFilter());
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(...);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`getHost()` is the host **string from the URI**, not the resolved address — `Filtered host
|
||||||
|
'localhost'`, not `Filtered host '127.0.0.1'`. On the Apache path the resolved addresses were
|
||||||
|
available and were not kept, so if you want to log which address triggered it you have to
|
||||||
|
resolve again yourself.
|
||||||
|
|
||||||
|
A 4xx is arguably the better answer when the URL came from a user, since the request is what is
|
||||||
|
wrong. Do not echo `ex.getMessage()` back to them: it confirms which hosts are unreachable,
|
||||||
|
which turns your error response into a network scanner. Log it, return something bland.
|
||||||
|
|
||||||
|
## Alert on it
|
||||||
|
|
||||||
|
A `FilteredHostException` is either an attack or an outage, and you want to know which. Both are
|
||||||
|
worth paging on eventually, but the second is the one that will bite you first: an allow-list
|
||||||
|
pinned to IP ranges fails the day the destination changes its DNS. The `allowlist` profile in
|
||||||
|
this module was written against `93.184.216.34`, which was example.com's address for a decade
|
||||||
|
and is not any more.
|
||||||
|
|
||||||
|
## Testing it
|
||||||
|
|
||||||
|
The filter is a `@FunctionalInterface` with no Spring dependencies, so the allow/deny decision
|
||||||
|
is a unit test — no context, no network:
|
||||||
|
|
||||||
|
```java
|
||||||
|
assertThat(InetAddressFilter.externalAddresses()
|
||||||
|
.matches(InetAddress.getByName("169.254.169.254"))).isFalse();
|
||||||
|
```
|
||||||
|
|
||||||
|
[`FilterMatrix`](../src/main/java/com/ankurm/ssrf/FilterMatrix.java) is that idea with a
|
||||||
|
table around it. Run it against your own filter before you deploy it; the four-row disagreement
|
||||||
|
in [chapter 3](03-allow-not-block.md) is not something you would find by reading.
|
||||||
|
|
||||||
|
## The diagnostic endpoint
|
||||||
|
|
||||||
|
[`/diag/filter?host=...`](../src/main/java/com/ankurm/ssrf/DiagnosticsController.java) reports
|
||||||
|
whether a filter bean is present, whether the settings picked it up, and the verdict on every
|
||||||
|
address the host resolves to:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "filterBeanPresent": true, "settingsCarryFilter": true, "host": "example.com",
|
||||||
|
"resolvesTo": { "172.66.147.243": true, "104.20.23.154": true } }
|
||||||
|
```
|
||||||
|
|
||||||
|
That answers the question you actually have when an outbound call fails, which is not "what does
|
||||||
|
my configuration say" but "what does the running context think". **Delete it before shipping**:
|
||||||
|
it is an oracle for your outbound allow-list and a host-resolution service for anyone who finds
|
||||||
|
it.
|
||||||
|
|
||||||
|
[Composing filters →](07-composing-filters.md)
|
||||||
67
ssrf/docs/07-composing-filters.md
Normal file
67
ssrf/docs/07-composing-filters.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
[← Operating it](06-operating-it.md) · [Module README](../README.md)
|
||||||
|
|
||||||
|
# 7. Composing filters, and the vararg that matches nothing
|
||||||
|
|
||||||
|
`InetAddressFilter` has `and`, `or`, `andNot` and `negate`, each with three overloads. The
|
||||||
|
`String...` overloads do not mean what the symmetry suggests.
|
||||||
|
|
||||||
|
## `of(a, b)` ORs. `and(a, b)` does not.
|
||||||
|
|
||||||
|
```java
|
||||||
|
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 whole list with logical
|
||||||
|
AND. So `and("104.16.0.0/12", "172.64.0.0/13")` asks for an address inside *both* ranges. No
|
||||||
|
address is inside two disjoint ranges, so the filter matches nothing and every outbound call
|
||||||
|
fails.
|
||||||
|
|
||||||
|
Run [`AndVarargsTrap`](../src/main/java/com/ankurm/ssrf/AndVarargsTrap.java) —
|
||||||
|
[`docs/output/and-varargs-trap.txt`](output/and-varargs-trap.txt):
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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. They are
|
||||||
|
not: that phrase is about the format of each string.
|
||||||
|
|
||||||
|
**Rule: 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)` is fine, because "not a AND not b" is what you want from a subtraction, and it is
|
||||||
|
how `externalAddresses()` itself is built:
|
||||||
|
|
||||||
|
```java
|
||||||
|
externalAddresses() = routable().andNot(multicast(), specialPurpose())
|
||||||
|
```
|
||||||
|
|
||||||
|
`or(a, b)` is also fine.
|
||||||
|
|
||||||
|
## A useful shape
|
||||||
|
|
||||||
|
Public internet, minus a range you know is hostile, plus one internal service you legitimately
|
||||||
|
call:
|
||||||
|
|
||||||
|
```java
|
||||||
|
InetAddressFilter.externalAddresses()
|
||||||
|
.andNot("203.0.113.0/24")
|
||||||
|
.or(InetAddressFilter.of("10.20.30.40"));
|
||||||
|
```
|
||||||
|
|
||||||
|
Read it left to right and check it against `FilterMatrix` before you believe it. Boolean
|
||||||
|
composition of allow-lists is the kind of thing that is obvious while you write it and wrong
|
||||||
|
when you read it back.
|
||||||
|
|
||||||
|
[Module README](../README.md)
|
||||||
11
ssrf/docs/output/and-varargs-trap.txt
Normal file
11
ssrf/docs/output/and-varargs-trap.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
The fourth line is the trap. One address is one filter; two addresses
|
||||||
|
are two filters ANDed, and no address is inside two disjoint ranges.
|
||||||
55
ssrf/docs/output/exploit-by-profile.txt
Normal file
55
ssrf/docs/output/exploit-by-profile.txt
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
===================================================================================
|
||||||
|
PROFILE: (none) - no InetAddressFilter bean
|
||||||
|
===================================================================================
|
||||||
|
target outcome
|
||||||
|
-----------------------------------------------------------------------------------------
|
||||||
|
http://127.0.0.1:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-EXAMPLE-NOT-REAL","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","Expir
|
||||||
|
http://localhost:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-EXAMPLE-NOT-REAL","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","Expir
|
||||||
|
http://[::1]:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-EXAMPLE-NOT-REAL","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","Expir
|
||||||
|
http://172.16.10.3:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-EXAMPLE-NOT-REAL","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","Expir
|
||||||
|
http://example.com/ FETCHED | <!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"
|
||||||
|
|
||||||
|
===================================================================================
|
||||||
|
PROFILE: docsfilter
|
||||||
|
===================================================================================
|
||||||
|
target outcome
|
||||||
|
-----------------------------------------------------------------------------------------
|
||||||
|
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
|
||||||
|
http://localhost:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host 'localhost'
|
||||||
|
http://[::1]:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '[::1]'
|
||||||
|
http://172.16.10.3:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '172.16.10.3'
|
||||||
|
http://example.com/ FETCHED | <!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"
|
||||||
|
|
||||||
|
===================================================================================
|
||||||
|
PROFILE: blocklist
|
||||||
|
===================================================================================
|
||||||
|
target outcome
|
||||||
|
-----------------------------------------------------------------------------------------
|
||||||
|
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
|
||||||
|
http://localhost:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host 'localhost'
|
||||||
|
http://[::1]:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '[::1]'
|
||||||
|
http://172.16.10.3:8080/internal/credentials FETCHED | {"Expiration":"2026-08-29T23:59:59Z","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","SecretAccessKey":"wJ
|
||||||
|
http://example.com/ BLOCKED_BY_FILTER | Filtered host 'example.com'
|
||||||
|
|
||||||
|
===================================================================================
|
||||||
|
PROFILE: negated
|
||||||
|
===================================================================================
|
||||||
|
target outcome
|
||||||
|
-----------------------------------------------------------------------------------------
|
||||||
|
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
|
||||||
|
http://localhost:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host 'localhost'
|
||||||
|
http://[::1]:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '[::1]'
|
||||||
|
http://172.16.10.3:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '172.16.10.3'
|
||||||
|
http://example.com/ FETCHED | <!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"
|
||||||
|
|
||||||
|
===================================================================================
|
||||||
|
PROFILE: allowlist
|
||||||
|
===================================================================================
|
||||||
|
target outcome
|
||||||
|
-----------------------------------------------------------------------------------------
|
||||||
|
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
|
||||||
|
http://localhost:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host 'localhost'
|
||||||
|
http://[::1]:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '[::1]'
|
||||||
|
http://172.16.10.3:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '172.16.10.3'
|
||||||
|
http://example.com/ FETCHED | <!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"
|
||||||
|
|
||||||
20
ssrf/docs/output/filter-matrix.txt
Normal file
20
ssrf/docs/output/filter-matrix.txt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
true = the filter MATCHES the address = the call is ALLOWED through.
|
||||||
|
A row that is false in the active filter's column throws FilteredHostException.
|
||||||
|
|
||||||
|
address | all() | none() | routable() | multicast() | specialPurpose() | internalAddresses() | externalAddresses() | internalAddresses().negate()| of(RFC1918) [the inversion]
|
||||||
|
----------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------
|
||||||
|
127.0.0.1 | true | false | true | false | true | true | false | false | false
|
||||||
|
169.254.169.254 | true | false | true | false | true | true | false | false | false
|
||||||
|
10.0.0.1 | true | false | true | false | true | true | false | false | true
|
||||||
|
172.16.10.3 | true | false | true | false | true | true | false | false | true
|
||||||
|
192.168.1.1 | true | false | true | false | true | true | false | false | true
|
||||||
|
100.64.0.1 | true | false | true | false | true | false | false | true | false
|
||||||
|
0.0.0.0 | true | false | false | false | true | false | false | true | false
|
||||||
|
192.0.2.1 | true | false | true | false | true | false | false | true | false
|
||||||
|
224.0.0.1 | true | false | true | true | false | false | false | true | false
|
||||||
|
93.184.216.34 | true | false | true | false | false | false | true | true | false
|
||||||
|
::1 | true | false | true | false | true | true | false | false | false
|
||||||
|
fc00::1 | true | false | true | false | true | true | false | false | false
|
||||||
|
fe80::1 | true | false | true | false | true | true | false | false | false
|
||||||
|
64:ff9b::a00:1 | true | false | true | false | true | true | false | false | false
|
||||||
|
2606:2800:220:1::1 | true | false | true | false | false | false | true | true | false
|
||||||
3
ssrf/docs/output/tests.txt
Normal file
3
ssrf/docs/output/tests.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[INFO] Running org.springframework.boot.http.client.WhereTheFilterRunsTests
|
||||||
|
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.221 s -- in org.springframework.boot.http.client.WhereTheFilterRunsTests
|
||||||
|
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
|
||||||
6
ssrf/docs/output/two-filter-beans.txt
Normal file
6
ssrf/docs/output/two-filter-beans.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
$ java -cp ... SsrfDemoApplication --spring.profiles.active=twofilters
|
||||||
|
2026-08-29T09:28:27.206+05:30 WARN 445 --- [ssrf-inet-address-filter] [ main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'linkPreviewController' defined in file [/tmp/work/ssd/ssrf/target/classes/com/ankurm/ssrf/LinkPreviewController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'restClientBuilder' defined in class path resource [org/springframework/boot/restclient/autoconfigure/RestClientAutoConfiguration.class]: Unsatisfied dependency expressed through method 'restClientBuilder' parameter 0: Error creating bean with name 'restClientBuilderConfigurer' defined in class path resource [org/springframework/boot/restclient/autoconfigure/RestClientAutoConfiguration.class]: Failed to instantiate [org.springframework.boot.restclient.autoconfigure.RestClientBuilderConfigurer]: Factory method 'restClientBuilderConfigurer' threw exception with message: Error creating bean with name 'httpClientSettings' defined in class path resource [org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfiguration.class]: Failed to instantiate [org.springframework.boot.http.client.HttpClientSettings]: Factory method 'httpClientSettings' threw exception with message: No qualifying bean of type 'org.springframework.boot.http.client.InetAddressFilter' available: expected single matching bean but found 2: firstFilter,secondFilter
|
||||||
|
APPLICATION FAILED TO START
|
||||||
|
Description:
|
||||||
|
Parameter 0 of method restClientBuilder in org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration required a single bean, but 2 were found:
|
||||||
|
Action:
|
||||||
60
ssrf/pom.xml
Normal file
60
ssrf/pom.xml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<!-- Inheriting spring-boot-starter-parent so every version below is Boot-managed. The
|
||||||
|
feature this module demonstrates - org.springframework.boot.http.client.InetAddressFilter
|
||||||
|
- is new in Boot 4.1 and lives in spring-boot-http-client, which arrives transitively
|
||||||
|
with spring-boot-starter-web. See docs/01-what-ssrf-costs-you.md. -->
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.1</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>ssrf-inet-address-filter</artifactId>
|
||||||
|
<version>1.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Boot 4 modularised the HTTP client story: spring-boot-starter-web does NOT bring an
|
||||||
|
auto-configured RestClient.Builder, and it does not put InetAddressFilter on the
|
||||||
|
classpath either. This starter is what pulls in spring-boot-restclient and, through
|
||||||
|
it, spring-boot-http-client. See docs/05-wiring-it-up.md. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-restclient</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Present ONLY so the apacheclient profile has a second request factory to select.
|
||||||
|
Which client is on the classpath changes WHERE the filter runs - a DNS resolver for
|
||||||
|
Apache, a ProxySelector for the JDK client. See docs/04-where-the-filter-runs.md. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||||
|
<artifactId>httpclient5</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
17
ssrf/scripts/exploit.sh
Executable file
17
ssrf/scripts/exploit.sh
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Drive the vulnerable endpoint against the four targets that matter, printing the outcome of
|
||||||
|
# each. Run it after ./scripts/run.sh <profile>; the profile decides the answers.
|
||||||
|
set -eu
|
||||||
|
PRIVATE_IP="$(hostname -I | awk '{print $1}')"
|
||||||
|
probe() {
|
||||||
|
printf '%-58s ' "$1"
|
||||||
|
curl -s --max-time 10 -G http://127.0.0.1:8080/preview --data-urlencode "url=$1" \
|
||||||
|
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["outcome"], "|", d.get("body", d.get("message",""))[:96])'
|
||||||
|
}
|
||||||
|
echo "target outcome"
|
||||||
|
echo "-----------------------------------------------------------------------------------------"
|
||||||
|
probe "http://127.0.0.1:8080/internal/credentials"
|
||||||
|
probe "http://localhost:8080/internal/credentials"
|
||||||
|
probe "http://[::1]:8080/internal/credentials"
|
||||||
|
probe "http://${PRIVATE_IP}:8080/internal/credentials"
|
||||||
|
probe "http://example.com/"
|
||||||
35
ssrf/scripts/run-all.sh
Executable file
35
ssrf/scripts/run-all.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerate every file under docs/output/. Nothing in the article or the chapters was typed by
|
||||||
|
# hand; it all came from here.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
mvn -B -q compile
|
||||||
|
[ -f target/cp.txt ] || mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=runtime
|
||||||
|
CP="target/classes:$(cat target/cp.txt)"
|
||||||
|
|
||||||
|
java -cp "$CP" com.ankurm.ssrf.FilterMatrix > docs/output/filter-matrix.txt
|
||||||
|
java -cp "$CP" com.ankurm.ssrf.AndVarargsTrap > docs/output/and-varargs-trap.txt
|
||||||
|
|
||||||
|
{
|
||||||
|
for p in "" docsfilter blocklist negated allowlist; do
|
||||||
|
echo "==================================================================================="
|
||||||
|
echo "PROFILE: ${p:-(none) - no InetAddressFilter bean}"
|
||||||
|
echo "==================================================================================="
|
||||||
|
./scripts/run.sh "$p" >/dev/null 2>&1 || { echo "FAILED TO START"; continue; }
|
||||||
|
./scripts/exploit.sh
|
||||||
|
echo
|
||||||
|
done
|
||||||
|
./scripts/stop.sh
|
||||||
|
} > docs/output/exploit-by-profile.txt 2>&1
|
||||||
|
|
||||||
|
# Two beans of the same type: the context does not start, and the diagnostic blames the wrong
|
||||||
|
# thing. Captured deliberately.
|
||||||
|
{
|
||||||
|
echo "\$ java -cp ... SsrfDemoApplication --spring.profiles.active=twofilters"
|
||||||
|
timeout 90 java -Xmx256m -cp "$CP" com.ankurm.ssrf.SsrfDemoApplication \
|
||||||
|
--spring.profiles.active=twofilters 2>&1 \
|
||||||
|
| grep -E 'expected single matching bean|APPLICATION FAILED|^Description|^Action|required a single bean|^\t- ' | head -20
|
||||||
|
} > docs/output/two-filter-beans.txt 2>&1
|
||||||
|
|
||||||
|
mvn -B test 2>&1 | grep -E 'Tests run:|WhereTheFilterRuns' > docs/output/tests.txt
|
||||||
|
echo "regenerated:"; ls -1 docs/output/
|
||||||
23
ssrf/scripts/run.sh
Executable file
23
ssrf/scripts/run.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start the application with the given profiles: ./scripts/run.sh docsfilter
|
||||||
|
#
|
||||||
|
# Uses a plain `java -cp` launch rather than `mvn spring-boot:run` so that only one JVM starts
|
||||||
|
# per application. The Maven plugin forks a second JVM, which matters when you are running
|
||||||
|
# several of these at once on a small machine.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
PROFILES="${1:-}"
|
||||||
|
[ -f target/cp.txt ] || mvn -B -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt -Dmdep.includeScope=runtime
|
||||||
|
[ -d target/classes ] || mvn -B -q compile
|
||||||
|
./scripts/stop.sh
|
||||||
|
ARGS=""
|
||||||
|
[ -n "$PROFILES" ] && ARGS="--spring.profiles.active=$PROFILES"
|
||||||
|
setsid nohup java -Xmx256m -cp "target/classes:$(cat target/cp.txt)" \
|
||||||
|
com.ankurm.ssrf.SsrfDemoApplication $ARGS > /tmp/ssrf-app.log 2>&1 < /dev/null &
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
curl -fs -o /dev/null http://127.0.0.1:8080/diag/filter && exit 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "application did not start; see /tmp/ssrf-app.log" >&2
|
||||||
|
tail -30 /tmp/ssrf-app.log >&2
|
||||||
|
exit 1
|
||||||
14
ssrf/scripts/stop.sh
Executable file
14
ssrf/scripts/stop.sh
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stop any running instance.
|
||||||
|
#
|
||||||
|
# Match a real JVM whose command line contains this application's main class, and exclude this
|
||||||
|
# shell and its parent explicitly. A bare grep for the class name is not enough: when the
|
||||||
|
# calling shell's own command line contains the class name - which it does whenever you paste a
|
||||||
|
# here-doc - the grep matches the shell and kills it.
|
||||||
|
set -eu
|
||||||
|
self=$$; parent=${PPID:-0}
|
||||||
|
for pid in $(ps -eo pid,ppid,comm,args | awk -v s="$self" -v p="$parent" \
|
||||||
|
'$1 != s && $1 != p && $3 ~ /^java/ && $0 ~ /com\.ankurm\.ssrf\.SsrfDemoApplication/ {print $1}'); do
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
sleep 1
|
||||||
48
ssrf/src/main/java/com/ankurm/ssrf/AndVarargsTrap.java
Normal file
48
ssrf/src/main/java/com/ankurm/ssrf/AndVarargsTrap.java
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package com.ankurm.ssrf;
|
||||||
|
|
||||||
|
import org.springframework.boot.http.client.InetAddressFilter;
|
||||||
|
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One address, two ways of writing the same intent, two different answers.
|
||||||
|
*
|
||||||
|
* <p>{@code of(String...)} ORs its addresses. {@code and(String...)} does not: it maps each
|
||||||
|
* address to a separate filter and folds the whole list with logical AND, so naming two disjoint
|
||||||
|
* ranges asks for an address inside both. Nothing warns you; the filter simply matches nothing
|
||||||
|
* and every outbound call fails with {@code FilteredHostException}.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../docs/07-composing-filters.md">docs/07-composing-filters.md</a>
|
||||||
|
*/
|
||||||
|
public final class AndVarargsTrap {
|
||||||
|
|
||||||
|
public static void main(String[] args) throws UnknownHostException {
|
||||||
|
InetAddress address = InetAddress.getByName("104.20.23.154");
|
||||||
|
System.out.println("address under test: " + address.getHostAddress()
|
||||||
|
+ " (inside 104.16.0.0/12, outside 172.64.0.0/13)");
|
||||||
|
System.out.println();
|
||||||
|
show("of(\"104.16.0.0/12\")", InetAddressFilter.of("104.16.0.0/12"), address);
|
||||||
|
show("of(\"104.16.0.0/12\", \"172.64.0.0/13\")",
|
||||||
|
InetAddressFilter.of("104.16.0.0/12", "172.64.0.0/13"), address);
|
||||||
|
System.out.println();
|
||||||
|
show("externalAddresses().and(\"104.16.0.0/12\")",
|
||||||
|
InetAddressFilter.externalAddresses().and("104.16.0.0/12"), address);
|
||||||
|
show("externalAddresses().and(\"104.16.0.0/12\", \"172.64.0.0/13\")",
|
||||||
|
InetAddressFilter.externalAddresses().and("104.16.0.0/12", "172.64.0.0/13"), address);
|
||||||
|
show("externalAddresses().and(of(\"104.16.0.0/12\", \"172.64.0.0/13\"))",
|
||||||
|
InetAddressFilter.externalAddresses()
|
||||||
|
.and(InetAddressFilter.of("104.16.0.0/12", "172.64.0.0/13")), address);
|
||||||
|
System.out.println();
|
||||||
|
System.out.println("The fourth line is the trap. One address is one filter; two addresses");
|
||||||
|
System.out.println("are two filters ANDed, and no address is inside two disjoint ranges.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void show(String expression, InetAddressFilter filter, InetAddress address) {
|
||||||
|
System.out.printf("%-62s -> %s%n", expression, filter.matches(address));
|
||||||
|
}
|
||||||
|
|
||||||
|
private AndVarargsTrap() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.ankurm.ssrf;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.boot.http.client.HttpClientSettings;
|
||||||
|
import org.springframework.boot.http.client.InetAddressFilter;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints what the running context actually decided, rather than what the configuration
|
||||||
|
* intended. Delete this before shipping: it is an oracle for the outbound allow-list.
|
||||||
|
*
|
||||||
|
* <p>{@code /diag/filter} is the useful one. Given a host it reports every address that host
|
||||||
|
* resolves to and whether the active filter matches it — which is the question you are
|
||||||
|
* really asking when an outbound call fails with a 500 and no explanation.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class DiagnosticsController {
|
||||||
|
|
||||||
|
private final ObjectProvider<InetAddressFilter> filters;
|
||||||
|
|
||||||
|
private final ObjectProvider<HttpClientSettings> settings;
|
||||||
|
|
||||||
|
DiagnosticsController(ObjectProvider<InetAddressFilter> filters,
|
||||||
|
ObjectProvider<HttpClientSettings> settings) {
|
||||||
|
this.filters = filters;
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/diag/filter")
|
||||||
|
public Map<String, Object> filter(@RequestParam(defaultValue = "127.0.0.1") String host)
|
||||||
|
throws Exception {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
InetAddressFilter filter = this.filters.getIfAvailable();
|
||||||
|
result.put("filterBeanPresent", filter != null);
|
||||||
|
result.put("filterBeanClass", filter == null ? "(none)" : filter.getClass().getName());
|
||||||
|
HttpClientSettings httpClientSettings = this.settings.getIfAvailable();
|
||||||
|
result.put("settingsCarryFilter",
|
||||||
|
httpClientSettings != null && httpClientSettings.inetAddressFilter() != null);
|
||||||
|
Map<String, Object> addresses = new LinkedHashMap<>();
|
||||||
|
for (InetAddress address : InetAddress.getAllByName(host)) {
|
||||||
|
addresses.put(address.getHostAddress(),
|
||||||
|
filter == null ? "no filter - allowed" : filter.matches(address));
|
||||||
|
}
|
||||||
|
result.put("host", host);
|
||||||
|
result.put("resolvesTo", addresses);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
100
ssrf/src/main/java/com/ankurm/ssrf/FilterConfiguration.java
Normal file
100
ssrf/src/main/java/com/ankurm/ssrf/FilterConfiguration.java
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package com.ankurm.ssrf;
|
||||||
|
|
||||||
|
import org.springframework.boot.http.client.InetAddressFilter;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four filter beans, one per profile.
|
||||||
|
*
|
||||||
|
* <p>Read {@code matches} as ALLOW, not BLOCK. {@code FilteredAddresses.of(stream, filter)}
|
||||||
|
* keeps the addresses the filter matches and throws {@link
|
||||||
|
* org.springframework.boot.http.client.FilteredHostException} when nothing survives. The
|
||||||
|
* reference documentation says so — "will only allow outgoing calls to addresses that
|
||||||
|
* match the filter" — but the 4.1 release notes say the filter "can block outgoing
|
||||||
|
* requests to specific addresses", and that sentence is what most of the write-ups copied.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../docs/03-allow-not-block.md">docs/03-allow-not-block.md</a>
|
||||||
|
*/
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
public class FilterConfiguration {
|
||||||
|
|
||||||
|
/** The RFC 1918 ranges. Note that Spring's {@code specialPurpose()} does NOT contain them. */
|
||||||
|
static final String[] PRIVATE_V4 = { "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reference documentation's own recommendation. It blocks loopback and link-local, so
|
||||||
|
* the naive exploit stops working — which is exactly why it is easy to believe it is
|
||||||
|
* enough. It does not block RFC 1918.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Profile("docsfilter")
|
||||||
|
InetAddressFilter docsFilter() {
|
||||||
|
return InetAddressFilter.externalAddresses();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What you write if you read "block" and act on it: name the ranges you want forbidden.
|
||||||
|
* This is an allow-list containing only the private ranges, so it permits precisely the
|
||||||
|
* traffic you were trying to stop and denies everything else, including every legitimate
|
||||||
|
* outbound call the service makes.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Profile("blocklist")
|
||||||
|
InetAddressFilter blockListMistake() {
|
||||||
|
return InetAddressFilter.of(PRIVATE_V4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks like {@code externalAddresses()} and is not. {@code internalAddresses()} is
|
||||||
|
* {@code routable().and(InternalInetAddressFilter.instance)} — loopback, link-local and
|
||||||
|
* site-local. Negating it therefore allows everything that is none of those, which includes
|
||||||
|
* CGNAT space (100.64.0.0/10), the documentation ranges, and multicast. The truth table in
|
||||||
|
* docs/output/filter-matrix.txt has the four rows where the two disagree.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Profile("negated")
|
||||||
|
InetAddressFilter negatedInternal() {
|
||||||
|
return InetAddressFilter.internalAddresses().negate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The strongest posture available, and the one worth reaching for when the set of legitimate
|
||||||
|
* destinations is known: name them. A deny-list is a guess about the whole internet; an
|
||||||
|
* allow-list is a statement about your own architecture.
|
||||||
|
*
|
||||||
|
* <p>It is also the most brittle, and this bean is the proof. It was first written against
|
||||||
|
* 93.184.216.34, the address example.com served from for a decade, and every call failed:
|
||||||
|
* example.com now sits behind Cloudflare. Pinning IP ranges means owning the consequences of
|
||||||
|
* somebody else's DNS change, so pair this with an alert on FilteredHostException rather than
|
||||||
|
* discovering it from a customer.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Profile("allowlist")
|
||||||
|
InetAddressFilter allowListFilter() {
|
||||||
|
// and(InetAddressFilter.of(a, b)), NOT and(a, b). The vararg-String overload turns each
|
||||||
|
// address into its own filter and ANDs them together, so and("104.16.0.0/12",
|
||||||
|
// "172.64.0.0/13") asks for an address inside BOTH ranges and matches nothing at all.
|
||||||
|
// This bean was written the wrong way first; see docs/output/and-varargs-trap.txt.
|
||||||
|
return InetAddressFilter.externalAddresses()
|
||||||
|
.and(InetAddressFilter.of("104.16.0.0/12", "172.64.0.0/13"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two beans of the same type. {@code HttpClientAutoConfiguration} reads the filter with
|
||||||
|
* {@code ObjectProvider.getIfAvailable()}, which is not the same thing as "pick one".
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Profile("twofilters")
|
||||||
|
InetAddressFilter firstFilter() {
|
||||||
|
return InetAddressFilter.externalAddresses();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@Profile("twofilters")
|
||||||
|
InetAddressFilter secondFilter() {
|
||||||
|
return InetAddressFilter.not(PRIVATE_V4);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
77
ssrf/src/main/java/com/ankurm/ssrf/FilterMatrix.java
Normal file
77
ssrf/src/main/java/com/ankurm/ssrf/FilterMatrix.java
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package com.ankurm.ssrf;
|
||||||
|
|
||||||
|
import org.springframework.boot.http.client.InetAddressFilter;
|
||||||
|
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates every factory method on {@link InetAddressFilter} against a list of addresses and
|
||||||
|
* prints the truth table. No Spring context, no network: this is the filters answering for
|
||||||
|
* themselves.
|
||||||
|
*
|
||||||
|
* <p>The column that matters is {@code externalAddresses()} against the RFC 1918 rows.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../docs/03-allow-not-block.md">docs/03-allow-not-block.md</a>
|
||||||
|
*/
|
||||||
|
public final class FilterMatrix {
|
||||||
|
|
||||||
|
private static final String[] ADDRESSES = {
|
||||||
|
"127.0.0.1", // loopback - the naive SSRF target
|
||||||
|
"169.254.169.254", // link-local - the cloud instance metadata service
|
||||||
|
"10.0.0.1", // RFC 1918
|
||||||
|
"172.16.10.3", // RFC 1918 - this container's own address
|
||||||
|
"192.168.1.1", // RFC 1918
|
||||||
|
"100.64.0.1", // CGNAT / RFC 6598
|
||||||
|
"0.0.0.0", // "this host"
|
||||||
|
"192.0.2.1", // TEST-NET-1
|
||||||
|
"224.0.0.1", // multicast
|
||||||
|
"93.184.216.34", // a public address
|
||||||
|
"::1", // IPv6 loopback
|
||||||
|
"fc00::1", // IPv6 unique local
|
||||||
|
"fe80::1", // IPv6 link-local
|
||||||
|
"64:ff9b::a00:1", // NAT64-embedded 10.0.0.1
|
||||||
|
"2606:2800:220:1::1", // a public IPv6 address
|
||||||
|
};
|
||||||
|
|
||||||
|
public static void main(String[] args) throws UnknownHostException {
|
||||||
|
Map<String, InetAddressFilter> filters = new LinkedHashMap<>();
|
||||||
|
filters.put("all()", InetAddressFilter.all());
|
||||||
|
filters.put("none()", InetAddressFilter.none());
|
||||||
|
filters.put("routable()", InetAddressFilter.routable());
|
||||||
|
filters.put("multicast()", InetAddressFilter.multicast());
|
||||||
|
filters.put("specialPurpose()", InetAddressFilter.specialPurpose());
|
||||||
|
filters.put("internalAddresses()", InetAddressFilter.internalAddresses());
|
||||||
|
filters.put("externalAddresses()", InetAddressFilter.externalAddresses());
|
||||||
|
filters.put("internalAddresses().negate()", InetAddressFilter.internalAddresses().negate());
|
||||||
|
filters.put("of(RFC1918) [the inversion]", InetAddressFilter.of(FilterConfiguration.PRIVATE_V4));
|
||||||
|
|
||||||
|
System.out.println("true = the filter MATCHES the address = the call is ALLOWED through.");
|
||||||
|
System.out.println("A row that is false in the active filter's column throws FilteredHostException.");
|
||||||
|
System.out.println();
|
||||||
|
System.out.printf("%-22s", "address");
|
||||||
|
for (String name : filters.keySet()) {
|
||||||
|
System.out.printf("| %-26s", name);
|
||||||
|
}
|
||||||
|
System.out.println();
|
||||||
|
System.out.print("-".repeat(22));
|
||||||
|
for (int i = 0; i < filters.size(); i++) {
|
||||||
|
System.out.print("+" + "-".repeat(27));
|
||||||
|
}
|
||||||
|
System.out.println();
|
||||||
|
for (String address : ADDRESSES) {
|
||||||
|
InetAddress inetAddress = InetAddress.getByName(address);
|
||||||
|
System.out.printf("%-22s", address);
|
||||||
|
for (InetAddressFilter filter : filters.values()) {
|
||||||
|
System.out.printf("| %-26s", filter.matches(inetAddress));
|
||||||
|
}
|
||||||
|
System.out.println();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private FilterMatrix() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.ankurm.ssrf;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The thing on the other side of the trust boundary. In a real estate this is an actuator
|
||||||
|
* endpoint, an internal admin API, a service mesh sidecar, or the cloud instance-metadata
|
||||||
|
* service on 169.254.169.254 that hands out short-lived credentials to anything that asks.
|
||||||
|
*
|
||||||
|
* <p>It has no authentication, because the network was supposed to be the authentication.
|
||||||
|
* That assumption is exactly what SSRF spends.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class InternalAdminController {
|
||||||
|
|
||||||
|
@GetMapping("/internal/credentials")
|
||||||
|
public Map<String, String> credentials() {
|
||||||
|
return Map.of("AccessKeyId", "ASIA-EXAMPLE-NOT-REAL",
|
||||||
|
"SecretAccessKey", "wJalrXUtnFEMI-EXAMPLE-NOT-REAL",
|
||||||
|
"Token", "IQoJb3JpZ2luX2VjE-EXAMPLE-NOT-REAL",
|
||||||
|
"Expiration", "2026-08-29T23:59:59Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.ankurm.ssrf;
|
||||||
|
|
||||||
|
import org.springframework.boot.http.client.FilteredHostException;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The vulnerable endpoint. It is deliberately ordinary: take a URL from the user, fetch it,
|
||||||
|
* return something about it. Link previews, webhook validators, avatar-by-URL uploaders, PDF
|
||||||
|
* renderers and "import from URL" features are all this function.
|
||||||
|
*
|
||||||
|
* <p>Nothing here is wrong in isolation. The vulnerability is that the destination is chosen by
|
||||||
|
* the caller and the process sits inside a network where some destinations are privileged.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../docs/02-the-exploit.md">docs/02-the-exploit.md</a>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
public class LinkPreviewController {
|
||||||
|
|
||||||
|
private final RestClient restClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note what is injected: the AUTO-CONFIGURED builder. That is the only reason an
|
||||||
|
* {@code InetAddressFilter} bean reaches this client. A {@code RestClient.create()} written
|
||||||
|
* by hand inside this class would be unfiltered no matter what beans exist.
|
||||||
|
*/
|
||||||
|
LinkPreviewController(RestClient.Builder builder) {
|
||||||
|
this.restClient = builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/preview")
|
||||||
|
public ResponseEntity<Map<String, Object>> preview(@RequestParam String url) {
|
||||||
|
try {
|
||||||
|
String body = this.restClient.get().uri(url).retrieve().body(String.class);
|
||||||
|
return ResponseEntity.ok(Map.of("outcome", "FETCHED", "url", url,
|
||||||
|
"bytes", body == null ? 0 : body.length(),
|
||||||
|
"body", body == null ? "" : body));
|
||||||
|
}
|
||||||
|
catch (FilteredHostException ex) {
|
||||||
|
// Caught here only so the transcripts are readable. Left uncaught, this is a
|
||||||
|
// RuntimeException and the client sees a bare HTTP 500 whose default body does not
|
||||||
|
// name the host - see docs/06-operating-it.md.
|
||||||
|
return ResponseEntity.status(502).body(Map.of("outcome", "BLOCKED_BY_FILTER",
|
||||||
|
"url", url, "host", ex.getHost(), "message", ex.getMessage()));
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
return ResponseEntity.status(502).body(Map.of("outcome", "ERROR", "url", url,
|
||||||
|
"exception", ex.getClass().getName(),
|
||||||
|
"message", String.valueOf(ex.getMessage()),
|
||||||
|
"cause", ex.getCause() == null ? "" : ex.getCause().getClass().getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
30
ssrf/src/main/java/com/ankurm/ssrf/SsrfDemoApplication.java
Normal file
30
ssrf/src/main/java/com/ankurm/ssrf/SsrfDemoApplication.java
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package com.ankurm.ssrf;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A link-preview service that fetches a user-supplied URL, next door to an internal admin
|
||||||
|
* endpoint that must never be reachable from outside. That is the whole shape of SSRF.
|
||||||
|
*
|
||||||
|
* <p>Profiles select the outbound filter:
|
||||||
|
* <ul>
|
||||||
|
* <li>(none) — no filter at all. The exploit works.</li>
|
||||||
|
* <li>{@code docsfilter} — {@code InetAddressFilter.externalAddresses()}, the filter the
|
||||||
|
* Spring Boot reference documentation puts in its own example.</li>
|
||||||
|
* <li>{@code blocklist} — {@code InetAddressFilter.of(privateRanges)}, which is what you
|
||||||
|
* write if you believe the release notes' word “block”. It is inverted.</li>
|
||||||
|
* <li>{@code hardened} — external addresses with the RFC 1918 ranges explicitly removed.</li>
|
||||||
|
* <li>{@code twofilters} — two filter beans, to show what the context does about it.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../docs/02-the-exploit.md">docs/02-the-exploit.md</a>
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
public class SsrfDemoApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(SsrfDemoApplication.class, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
14
ssrf/src/main/resources/application.yaml
Normal file
14
ssrf/src/main/resources/application.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: ssrf-inet-address-filter
|
||||||
|
main:
|
||||||
|
banner-mode: off
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
# Bind to every interface so the exploit can reach this application both on 127.0.0.1 and on
|
||||||
|
# the container's own RFC 1918 address. That pair is the whole point of docs/03.
|
||||||
|
address: 0.0.0.0
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: WARN
|
||||||
|
com.ankurm.ssrf: INFO
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package org.springframework.boot.http.client;
|
||||||
|
|
||||||
|
import org.apache.hc.client5.http.DnsResolver;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Proxy;
|
||||||
|
import java.net.ProxySelector;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The classes that actually apply an {@link InetAddressFilter} are package-private, so this test
|
||||||
|
* lives in Spring's package to reach them. It exists to pin down the difference the reference
|
||||||
|
* documentation does not mention: WHERE the filter runs depends on which HTTP client you have,
|
||||||
|
* and the two places have different powers.
|
||||||
|
*
|
||||||
|
* <p>Apache HttpComponents filters inside a {@link DnsResolver}, so it sees every address a host
|
||||||
|
* resolves to and can return the subset that survives. The JDK client has no DNS hook, so Boot
|
||||||
|
* filters inside a {@link ProxySelector}, which is handed a URI and nothing else — it has
|
||||||
|
* to resolve the host a second time, and {@code InetAddress.getByName} returns one address.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../../docs/04-where-the-filter-runs.md">docs/04-where-the-filter-runs.md</a>
|
||||||
|
*/
|
||||||
|
class WhereTheFilterRunsTests {
|
||||||
|
|
||||||
|
private static final InetAddressFilter EXTERNAL = InetAddressFilter.externalAddresses();
|
||||||
|
|
||||||
|
/** A host that resolves to one private and one public address, in that order. */
|
||||||
|
private static InetAddress[] mixed() throws UnknownHostException {
|
||||||
|
return new InetAddress[] { InetAddress.getByName("10.0.0.1"),
|
||||||
|
InetAddress.getByName("93.184.216.34") };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apacheResolverReturnsTheSurvivingSubsetRatherThanFailing() throws Exception {
|
||||||
|
DnsResolver delegate = new DnsResolver() {
|
||||||
|
@Override
|
||||||
|
public InetAddress[] resolve(String host) throws UnknownHostException {
|
||||||
|
return mixed();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<InetSocketAddress> resolve(String host, int port) throws UnknownHostException {
|
||||||
|
return List.of(new InetSocketAddress(mixed()[0], port),
|
||||||
|
new InetSocketAddress(mixed()[1], port));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String resolveCanonicalHostname(String host) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
DnsResolver filtered = new HttpComponentsFilteredDnsResolver(delegate, EXTERNAL);
|
||||||
|
|
||||||
|
// The private address is dropped and the connection proceeds to the public one. No
|
||||||
|
// exception: partial filtering is a thing here.
|
||||||
|
assertThat(filtered.resolve("mixed.example")).hasSize(1)
|
||||||
|
.extracting(InetAddress::getHostAddress).containsExactly("93.184.216.34");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void apacheResolverThrowsOnlyWhenNothingSurvives() {
|
||||||
|
DnsResolver delegate = new DnsResolver() {
|
||||||
|
@Override
|
||||||
|
public InetAddress[] resolve(String host) throws UnknownHostException {
|
||||||
|
return new InetAddress[] { InetAddress.getByName("10.0.0.1") };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<InetSocketAddress> resolve(String host, int port) throws UnknownHostException {
|
||||||
|
return List.of(new InetSocketAddress(InetAddress.getByName("10.0.0.1"), port));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String resolveCanonicalHostname(String host) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
DnsResolver filtered = new HttpComponentsFilteredDnsResolver(delegate, EXTERNAL);
|
||||||
|
|
||||||
|
assertThatExceptionOfType(FilteredHostException.class)
|
||||||
|
.isThrownBy(() -> filtered.resolve("private.example"))
|
||||||
|
.withMessage("Filtered host 'private.example'")
|
||||||
|
.satisfies((ex) -> assertThat(ex.getFilter()).isSameAs(EXTERNAL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void jdkProxySelectorDecidesFromASecondLookupOfItsOwn() {
|
||||||
|
ProxySelector delegate = new ProxySelector() {
|
||||||
|
@Override
|
||||||
|
public List<Proxy> select(URI uri) {
|
||||||
|
return List.of(Proxy.NO_PROXY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void connectFailed(URI uri, java.net.SocketAddress sa, java.io.IOException ioe) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ProxySelector filtered = new JdkFilteredProxySelector(delegate, EXTERNAL);
|
||||||
|
|
||||||
|
// It never sees an InetAddress from the caller - only the URI. It resolves the host
|
||||||
|
// itself, with InetAddress.getByName, which yields exactly one address. There is no
|
||||||
|
// "surviving subset" available at this layer, and the address it vetted is not
|
||||||
|
// necessarily the address the connection will later use.
|
||||||
|
assertThatExceptionOfType(FilteredHostException.class)
|
||||||
|
.isThrownBy(() -> filtered.select(URI.create("http://127.0.0.1:8080/x")))
|
||||||
|
.withMessage("Filtered host '127.0.0.1'");
|
||||||
|
assertThat(filtered.select(URI.create("http://93.184.216.34/"))).containsExactly(Proxy.NO_PROXY);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anUnresolvableHostIsReportedAsFilteredNotAsUnknown() {
|
||||||
|
ProxySelector delegate = ProxySelector.getDefault();
|
||||||
|
ProxySelector filtered = new JdkFilteredProxySelector(delegate, EXTERNAL);
|
||||||
|
|
||||||
|
// resolve() swallows UnknownHostException and returns null, which matchesResolvedHost
|
||||||
|
// reads as "does not match". A typo in a hostname therefore surfaces as "Filtered host",
|
||||||
|
// which sends you looking at your allow-list instead of at your DNS.
|
||||||
|
assertThatExceptionOfType(FilteredHostException.class)
|
||||||
|
.isThrownBy(() -> filtered.select(URI.create("http://no-such-host.invalid/")))
|
||||||
|
.withMessage("Filtered host 'no-such-host.invalid'");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user