From 4e37a54e92c104d87307adc244b15dbb51a92319 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Sat, 29 Aug 2026 09:31:09 +0530 Subject: [PATCH] Add the ssrf module --- README.md | 8 ++ ssrf/README.md | 87 ++++++++++++ ssrf/docs/01-what-ssrf-costs-you.md | 46 +++++++ ssrf/docs/02-the-exploit.md | 49 +++++++ ssrf/docs/03-allow-not-block.md | 115 ++++++++++++++++ ssrf/docs/04-where-the-filter-runs.md | 70 ++++++++++ ssrf/docs/05-wiring-it-up.md | 98 +++++++++++++ ssrf/docs/06-operating-it.md | 67 +++++++++ ssrf/docs/07-composing-filters.md | 67 +++++++++ ssrf/docs/output/and-varargs-trap.txt | 11 ++ ssrf/docs/output/exploit-by-profile.txt | 55 ++++++++ ssrf/docs/output/filter-matrix.txt | 20 +++ ssrf/docs/output/tests.txt | 3 + ssrf/docs/output/two-filter-beans.txt | 6 + ssrf/pom.xml | 60 ++++++++ ssrf/scripts/exploit.sh | 17 +++ ssrf/scripts/run-all.sh | 35 +++++ ssrf/scripts/run.sh | 23 ++++ ssrf/scripts/stop.sh | 14 ++ .../java/com/ankurm/ssrf/AndVarargsTrap.java | 48 +++++++ .../ankurm/ssrf/DiagnosticsController.java | 55 ++++++++ .../com/ankurm/ssrf/FilterConfiguration.java | 100 ++++++++++++++ .../java/com/ankurm/ssrf/FilterMatrix.java | 77 +++++++++++ .../ankurm/ssrf/InternalAdminController.java | 27 ++++ .../ankurm/ssrf/LinkPreviewController.java | 59 ++++++++ .../com/ankurm/ssrf/SsrfDemoApplication.java | 30 ++++ ssrf/src/main/resources/application.yaml | 14 ++ .../http/client/WhereTheFilterRunsTests.java | 130 ++++++++++++++++++ 28 files changed, 1391 insertions(+) create mode 100644 ssrf/README.md create mode 100644 ssrf/docs/01-what-ssrf-costs-you.md create mode 100644 ssrf/docs/02-the-exploit.md create mode 100644 ssrf/docs/03-allow-not-block.md create mode 100644 ssrf/docs/04-where-the-filter-runs.md create mode 100644 ssrf/docs/05-wiring-it-up.md create mode 100644 ssrf/docs/06-operating-it.md create mode 100644 ssrf/docs/07-composing-filters.md create mode 100644 ssrf/docs/output/and-varargs-trap.txt create mode 100644 ssrf/docs/output/exploit-by-profile.txt create mode 100644 ssrf/docs/output/filter-matrix.txt create mode 100644 ssrf/docs/output/tests.txt create mode 100644 ssrf/docs/output/two-filter-beans.txt create mode 100644 ssrf/pom.xml create mode 100755 ssrf/scripts/exploit.sh create mode 100755 ssrf/scripts/run-all.sh create mode 100755 ssrf/scripts/run.sh create mode 100755 ssrf/scripts/stop.sh create mode 100644 ssrf/src/main/java/com/ankurm/ssrf/AndVarargsTrap.java create mode 100644 ssrf/src/main/java/com/ankurm/ssrf/DiagnosticsController.java create mode 100644 ssrf/src/main/java/com/ankurm/ssrf/FilterConfiguration.java create mode 100644 ssrf/src/main/java/com/ankurm/ssrf/FilterMatrix.java create mode 100644 ssrf/src/main/java/com/ankurm/ssrf/InternalAdminController.java create mode 100644 ssrf/src/main/java/com/ankurm/ssrf/LinkPreviewController.java create mode 100644 ssrf/src/main/java/com/ankurm/ssrf/SsrfDemoApplication.java create mode 100644 ssrf/src/main/resources/application.yaml create mode 100644 ssrf/src/test/java/org/springframework/boot/http/client/WhereTheFilterRunsTests.java diff --git a/README.md b/README.md index e997325..47bc7a7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ssrf/README.md b/ssrf/README.md new file mode 100644 index 0000000..5e030f8 --- /dev/null +++ b/ssrf/README.md @@ -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()` 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. diff --git a/ssrf/docs/01-what-ssrf-costs-you.md b/ssrf/docs/01-what-ssrf-costs-you.md new file mode 100644 index 0000000..1ec093d --- /dev/null +++ b/ssrf/docs/01-what-ssrf-costs-you.md @@ -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) diff --git a/ssrf/docs/02-the-exploit.md b/ssrf/docs/02-the-exploit.md new file mode 100644 index 0000000..50470c7 --- /dev/null +++ b/ssrf/docs/02-the-exploit.md @@ -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 | ... +``` + +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) diff --git a/ssrf/docs/03-allow-not-block.md b/ssrf/docs/03-allow-not-block.md new file mode 100644 index 0000000..b94f3f4 --- /dev/null +++ b/ssrf/docs/03-allow-not-block.md @@ -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, 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().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()` | + +[Where the filter runs →](04-where-the-filter-runs.md) diff --git a/ssrf/docs/04-where-the-filter-runs.md b/ssrf/docs/04-where-the-filter-runs.md new file mode 100644 index 0000000..684cc4a --- /dev/null +++ b/ssrf/docs/04-where-the-filter-runs.md @@ -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) diff --git a/ssrf/docs/05-wiring-it-up.md b/ssrf/docs/05-wiring-it-up.md new file mode 100644 index 0000000..1ef0a17 --- /dev/null +++ b/ssrf/docs/05-wiring-it-up.md @@ -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) diff --git a/ssrf/docs/06-operating-it.md b/ssrf/docs/06-operating-it.md new file mode 100644 index 0000000..b48b8aa --- /dev/null +++ b/ssrf/docs/06-operating-it.md @@ -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) diff --git a/ssrf/docs/07-composing-filters.md b/ssrf/docs/07-composing-filters.md new file mode 100644 index 0000000..6dffa73 --- /dev/null +++ b/ssrf/docs/07-composing-filters.md @@ -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) diff --git a/ssrf/docs/output/and-varargs-trap.txt b/ssrf/docs/output/and-varargs-trap.txt new file mode 100644 index 0000000..eba80b3 --- /dev/null +++ b/ssrf/docs/output/and-varargs-trap.txt @@ -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. diff --git a/ssrf/docs/output/exploit-by-profile.txt b/ssrf/docs/output/exploit-by-profile.txt new file mode 100644 index 0000000..a33c84a --- /dev/null +++ b/ssrf/docs/output/exploit-by-profile.txt @@ -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 | Example DomainExample DomainExample DomainExample Domain + 4.0.0 + + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + ssrf-inet-address-filter + 1.0 + jar + + + 25 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-restclient + + + + org.apache.httpcomponents.client5 + httpclient5 + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/ssrf/scripts/exploit.sh b/ssrf/scripts/exploit.sh new file mode 100755 index 0000000..3387b7f --- /dev/null +++ b/ssrf/scripts/exploit.sh @@ -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 ; 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/" diff --git a/ssrf/scripts/run-all.sh b/ssrf/scripts/run-all.sh new file mode 100755 index 0000000..9789bb8 --- /dev/null +++ b/ssrf/scripts/run-all.sh @@ -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/ diff --git a/ssrf/scripts/run.sh b/ssrf/scripts/run.sh new file mode 100755 index 0000000..e23d860 --- /dev/null +++ b/ssrf/scripts/run.sh @@ -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 diff --git a/ssrf/scripts/stop.sh b/ssrf/scripts/stop.sh new file mode 100755 index 0000000..670fe74 --- /dev/null +++ b/ssrf/scripts/stop.sh @@ -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 diff --git a/ssrf/src/main/java/com/ankurm/ssrf/AndVarargsTrap.java b/ssrf/src/main/java/com/ankurm/ssrf/AndVarargsTrap.java new file mode 100644 index 0000000..60907cc --- /dev/null +++ b/ssrf/src/main/java/com/ankurm/ssrf/AndVarargsTrap.java @@ -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. + * + *

{@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 docs/07-composing-filters.md + */ +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() { + } + +} diff --git a/ssrf/src/main/java/com/ankurm/ssrf/DiagnosticsController.java b/ssrf/src/main/java/com/ankurm/ssrf/DiagnosticsController.java new file mode 100644 index 0000000..9cb1b06 --- /dev/null +++ b/ssrf/src/main/java/com/ankurm/ssrf/DiagnosticsController.java @@ -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. + * + *

{@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 filters; + + private final ObjectProvider settings; + + DiagnosticsController(ObjectProvider filters, + ObjectProvider settings) { + this.filters = filters; + this.settings = settings; + } + + @GetMapping("/diag/filter") + public Map filter(@RequestParam(defaultValue = "127.0.0.1") String host) + throws Exception { + Map 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 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; + } + +} diff --git a/ssrf/src/main/java/com/ankurm/ssrf/FilterConfiguration.java b/ssrf/src/main/java/com/ankurm/ssrf/FilterConfiguration.java new file mode 100644 index 0000000..d44e4cc --- /dev/null +++ b/ssrf/src/main/java/com/ankurm/ssrf/FilterConfiguration.java @@ -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. + * + *

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 docs/03-allow-not-block.md + */ +@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. + * + *

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); + } + +} diff --git a/ssrf/src/main/java/com/ankurm/ssrf/FilterMatrix.java b/ssrf/src/main/java/com/ankurm/ssrf/FilterMatrix.java new file mode 100644 index 0000000..b775d75 --- /dev/null +++ b/ssrf/src/main/java/com/ankurm/ssrf/FilterMatrix.java @@ -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. + * + *

The column that matters is {@code externalAddresses()} against the RFC 1918 rows. + * + * @see docs/03-allow-not-block.md + */ +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 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() { + } + +} diff --git a/ssrf/src/main/java/com/ankurm/ssrf/InternalAdminController.java b/ssrf/src/main/java/com/ankurm/ssrf/InternalAdminController.java new file mode 100644 index 0000000..1d2e70b --- /dev/null +++ b/ssrf/src/main/java/com/ankurm/ssrf/InternalAdminController.java @@ -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. + * + *

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 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"); + } + +} diff --git a/ssrf/src/main/java/com/ankurm/ssrf/LinkPreviewController.java b/ssrf/src/main/java/com/ankurm/ssrf/LinkPreviewController.java new file mode 100644 index 0000000..662573f --- /dev/null +++ b/ssrf/src/main/java/com/ankurm/ssrf/LinkPreviewController.java @@ -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. + * + *

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 docs/02-the-exploit.md + */ +@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> 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())); + } + } + +} diff --git a/ssrf/src/main/java/com/ankurm/ssrf/SsrfDemoApplication.java b/ssrf/src/main/java/com/ankurm/ssrf/SsrfDemoApplication.java new file mode 100644 index 0000000..e29d9d4 --- /dev/null +++ b/ssrf/src/main/java/com/ankurm/ssrf/SsrfDemoApplication.java @@ -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. + * + *

Profiles select the outbound filter: + *

    + *
  • (none) — no filter at all. The exploit works.
  • + *
  • {@code docsfilter} — {@code InetAddressFilter.externalAddresses()}, the filter the + * Spring Boot reference documentation puts in its own example.
  • + *
  • {@code blocklist} — {@code InetAddressFilter.of(privateRanges)}, which is what you + * write if you believe the release notes' word “block”. It is inverted.
  • + *
  • {@code hardened} — external addresses with the RFC 1918 ranges explicitly removed.
  • + *
  • {@code twofilters} — two filter beans, to show what the context does about it.
  • + *
+ * + * @see docs/02-the-exploit.md + */ +@SpringBootApplication +public class SsrfDemoApplication { + + public static void main(String[] args) { + SpringApplication.run(SsrfDemoApplication.class, args); + } + +} diff --git a/ssrf/src/main/resources/application.yaml b/ssrf/src/main/resources/application.yaml new file mode 100644 index 0000000..a8017b5 --- /dev/null +++ b/ssrf/src/main/resources/application.yaml @@ -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 diff --git a/ssrf/src/test/java/org/springframework/boot/http/client/WhereTheFilterRunsTests.java b/ssrf/src/test/java/org/springframework/boot/http/client/WhereTheFilterRunsTests.java new file mode 100644 index 0000000..cef31da --- /dev/null +++ b/ssrf/src/test/java/org/springframework/boot/http/client/WhereTheFilterRunsTests.java @@ -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. + * + *

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 docs/04-where-the-filter-runs.md + */ +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 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 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 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'"); + } + +}