1
0

Add the ssrf module

This commit is contained in:
2026-08-29 09:31:09 +05:30
parent 0fceb2cd4e
commit 4e37a54e92
28 changed files with 1391 additions and 0 deletions

View File

@@ -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 |
| [`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 |
| [`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`
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
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
`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

87
ssrf/README.md Normal file
View 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.

View File

@@ -0,0 +1,46 @@
[Module README](../README.md) &middot; [The exploit &rarr;](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 &rarr;](02-the-exploit.md)

View File

@@ -0,0 +1,49 @@
[&larr; What SSRF costs you](01-what-ssrf-costs-you.md) &middot; [Module README](../README.md) &middot; [Allow, not block &rarr;](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 &rarr;](03-allow-not-block.md)

View File

@@ -0,0 +1,115 @@
[&larr; The exploit](02-the-exploit.md) &middot; [Module README](../README.md) &middot; [Where the filter runs &rarr;](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 &rarr;](04-where-the-filter-runs.md)

View File

@@ -0,0 +1,70 @@
[&larr; Allow, not block](03-allow-not-block.md) &middot; [Module README](../README.md) &middot; [Wiring it up &rarr;](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 &rarr;](05-wiring-it-up.md)

View File

@@ -0,0 +1,98 @@
[&larr; Where the filter runs](04-where-the-filter-runs.md) &middot; [Module README](../README.md) &middot; [Operating it &rarr;](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 &rarr;](06-operating-it.md)

View File

@@ -0,0 +1,67 @@
[&larr; Wiring it up](05-wiring-it-up.md) &middot; [Module README](../README.md) &middot; [Composing filters &rarr;](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 &rarr;](07-composing-filters.md)

View File

@@ -0,0 +1,67 @@
[&larr; Operating it](06-operating-it.md) &middot; [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)

View 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.

View 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:,"

View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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

View 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() {
}
}

View File

@@ -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 &mdash; 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;
}
}

View 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 &mdash; "will only allow outgoing calls to addresses that
* match the filter" &mdash; 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 &mdash; 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)} &mdash; 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);
}
}

View 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() {
}
}

View File

@@ -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");
}
}

View File

@@ -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()));
}
}
}

View 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) &mdash; no filter at all. The exploit works.</li>
* <li>{@code docsfilter} &mdash; {@code InetAddressFilter.externalAddresses()}, the filter the
* Spring Boot reference documentation puts in its own example.</li>
* <li>{@code blocklist} &mdash; {@code InetAddressFilter.of(privateRanges)}, which is what you
* write if you believe the release notes' word &ldquo;block&rdquo;. It is inverted.</li>
* <li>{@code hardened} &mdash; external addresses with the RFC 1918 ranges explicitly removed.</li>
* <li>{@code twofilters} &mdash; 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);
}
}

View 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

View File

@@ -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 &mdash; 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'");
}
}