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