Add the ssrf module
This commit is contained in:
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:
|
||||
Reference in New Issue
Block a user