1
0

Add the ssrf module

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

View File

@@ -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)

View 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 &rarr;](03-allow-not-block.md)

View File

@@ -0,0 +1,115 @@
[&larr; The exploit](02-the-exploit.md) &middot; [Module README](../README.md) &middot; [Where the filter runs &rarr;](04-where-the-filter-runs.md)
# 3. `matches` means allow
This is the chapter that matters. Get this backwards and you ship a service that still leaks and
also cannot make its own outbound calls.
## What the sources say
The Spring Boot 4.1 release notes:
> Both reactive and blocking HTTP clients can now be configured with an `InetAddressFilter`
> which can **block** outgoing requests to specific addresses.
The reference documentation, one click further in:
> To limit the address that a client can call, you can use an `InetAddressFilter` which will
> **only allow** outgoing calls to addresses that match the filter.
Those describe opposite configurations, and the release-notes sentence is the one that got
copied into the write-ups. The reference documentation is the correct one, and the bytecode
agrees with it. `FilteredAddresses.of(stream, predicate)` filters the resolved addresses
*through* the predicate and keeps what matches; `Filtered.orElseThrow` raises
`FilteredHostException` when nothing is left:
```
T orElseThrow(Supplier<String>, InetAddressFilter):
if (result == null || check.test(result)) throw new FilteredHostException(...)
return result
```
So: **the filter is an allow-list. An address that matches is permitted. An address that does
not match is dropped, and if every address is dropped the call fails.**
## The inversion, run
The `blocklist` profile is what you write if you act on the word "block" — name the private
ranges you want forbidden:
```java
InetAddressFilter.of("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16");
```
From [`docs/output/exploit-by-profile.txt`](output/exploit-by-profile.txt):
```
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
http://172.16.10.3:8080/internal/credentials FETCHED | {"Expiration":"2026-08-...
http://example.com/ BLOCKED_BY_FILTER | Filtered host 'example.com'
```
Read those three lines together. The RFC 1918 target — the one the configuration was written to
forbid — **succeeds**. The legitimate call to `example.com` **fails**. The configuration
achieved precisely the opposite of its intent in both directions.
The loopback row still blocks, which is the cruel part: the naive exploit everyone tests with
stops working, so the change looks like it worked.
## What the factory methods actually contain
`specialPurpose()` is documented as "special purpose IP addresses as defined by RFC 6890". Its
constant pool holds 25 CIDR strings, and **none of them is an RFC 1918 range**:
```
0.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 192.0.0.0/24 192.0.0.0/29
192.0.2.0/24 192.88.99.0/24 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24
240.0.0.0/4 255.255.255.255/32 ::/128 ::1/128 64:ff9b::/96 100::/64
2001::/23 2001::/32 2001:2::/48 2001:db8::/32 2001:10::/28 2002::/16
fc00::/7 fe80::/10
```
It still matches `10.0.0.1`, because the method is not just that list:
```java
specialPurpose() = of(<the 25 CIDRs>).or(InternalInetAddressFilter.instance)
```
and `InternalInetAddressFilter` is `isLoopbackAddress() || isLinkLocalAddress() ||
isSiteLocalAddress()`, plus an IPv6 arm that also decodes **NAT64-embedded** addresses
(`64:ff9b::a00:1` is `10.0.0.1` wearing a hat) and re-tests the embedded IPv4. The RFC 1918
coverage comes from the JDK's own predicates, not from the registry list.
That is worth knowing before you build anything on top of `specialPurpose()`, because its name
and its javadoc both suggest it is the RFC 6890 registry and only the registry.
## `internalAddresses().negate()` is not `externalAddresses()`
They look interchangeable. They are not, and
[`docs/output/filter-matrix.txt`](output/filter-matrix.txt) has the rows:
| Address | `externalAddresses()` | `internalAddresses().negate()` |
|---|---|---|
| `100.64.0.1` (CGNAT) | `false` | **`true`** |
| `0.0.0.0` | `false` | **`true`** |
| `192.0.2.1` (TEST-NET-1) | `false` | **`true`** |
| `224.0.0.1` (multicast) | `false` | **`true`** |
`internalAddresses()` is `routable().and(InternalInetAddressFilter.instance)` — loopback,
link-local and site-local, nothing else. Negating it allows everything that is none of those,
and "none of those" includes carrier-grade NAT space, which on a mobile or ISP-adjacent network
is emphatically not the public internet.
`externalAddresses()` is `routable().andNot(multicast(), specialPurpose())`, which is a
different and stricter statement. Prefer it.
## The short version
| Intent | Write |
|---|---|
| only call the public internet | `InetAddressFilter.externalAddresses()` |
| only call these destinations | `InetAddressFilter.of("203.0.113.0/24", "198.51.100.7")` |
| public internet minus a range | `externalAddresses().andNot("203.0.113.0/24")` |
| **never** | `InetAddressFilter.of(<the ranges you want to forbid>)` |
[Where the filter runs &rarr;](04-where-the-filter-runs.md)

View File

@@ -0,0 +1,70 @@
[&larr; Allow, not block](03-allow-not-block.md) &middot; [Module README](../README.md) &middot; [Wiring it up &rarr;](05-wiring-it-up.md)
# 4. Where the filter runs depends on your HTTP client
One `InetAddressFilter` bean, four different insertion points. Boot picks the one that fits
whichever client is on the classpath:
| Client | Class that applies the filter | Hook |
|---|---|---|
| Apache HttpComponents | `HttpComponentsFilteredDnsResolver` | `DnsResolver` |
| JDK `HttpClient` | `JdkFilteredProxySelector` | `ProxySelector` |
| Jetty | `JettyFilteredSocketAddressResolver` | `SocketAddressResolver` |
| Reactor Netty | `ReactorFilteredResolvedAddressSelector` | resolved-address selector |
Three of those are name-resolution hooks. The JDK one is not, because `java.net.http.HttpClient`
does not expose a resolver — so Boot filters in the `ProxySelector`, which is consulted per
request and is handed a `URI` and nothing else.
That difference is not cosmetic. It is pinned down by
[`WhereTheFilterRunsTests`](../src/test/java/org/springframework/boot/http/client/WhereTheFilterRunsTests.java),
which lives in `org.springframework.boot.http.client` because both classes are package-private.
## Apache filters the set; the JDK filters the name
`HttpComponentsFilteredDnsResolver.resolve` calls the delegate, keeps the addresses that match,
and returns the survivors. A host resolving to one private and one public address yields a
one-element array containing the public one, and the connection proceeds:
```java
assertThat(filtered.resolve("mixed.example")).hasSize(1)
.extracting(InetAddress::getHostAddress).containsExactly("93.184.216.34");
```
It throws only when nothing survives. The connection then uses exactly the addresses that were
vetted, in the same lookup — there is no second resolution and therefore no window.
`JdkFilteredProxySelector.select` has no addresses to work with, so it does its own lookup:
```java
private @Nullable InetAddress resolve(String host) {
try { return InetAddress.getByName(host); }
catch (UnknownHostException ex) { return null; }
}
```
`getByName` returns **one** address. The decision is all-or-nothing, and the address that was
vetted is not necessarily the address the connection later opens. Between `select()` and the
socket there is a second resolution, which is the classic DNS-rebinding window: a hostname whose
record has a short TTL and answers with a public address once and a private address next.
Nothing about this is Spring's fault — the JDK client offers no better hook — but it means the
strength of your SSRF mitigation depends on a dependency you may not have thought of as a
security control. **If this filter is load-bearing, put `httpclient5` on the classpath.**
## A typo reads as a policy violation
`resolve` swallows `UnknownHostException` and returns `null`; `matchesResolvedHost` reads `null`
as "does not match". So on the JDK path:
```java
assertThatExceptionOfType(FilteredHostException.class)
.isThrownBy(() -> filtered.select(URI.create("http://no-such-host.invalid/")))
.withMessage("Filtered host 'no-such-host.invalid'");
```
A hostname that does not resolve is reported as **filtered**, not as unknown. Someone debugging
that message will go and read the allow-list, which is the wrong file. Worth knowing before it
costs you an afternoon.
[Wiring it up &rarr;](05-wiring-it-up.md)

View File

@@ -0,0 +1,98 @@
[&larr; Where the filter runs](04-where-the-filter-runs.md) &middot; [Module README](../README.md) &middot; [Operating it &rarr;](06-operating-it.md)
# 5. Wiring it up, and the three ways it silently does nothing
## The bean
```java
@Configuration(proxyBeanMethods = false)
public class OutboundConfiguration {
@Bean
InetAddressFilter httpClientInetAddressFilter() {
return InetAddressFilter.externalAddresses();
}
}
```
`HttpClientAutoConfiguration.httpClientSettings` reads it and folds it into the shared
`HttpClientSettings`:
```java
InetAddressFilter filter = inetAddressFilter.getIfAvailable();
HttpClientSettings settings = (filter != null)
? HttpClientSettings.defaults().withInetAddressFilter(filter)
: HttpClientSettings.defaults();
```
Note that `HttpClientSettings.defaults()` is the all-null record — the default filter is `null`,
not `all()`.
## There is no property for it
`HttpClientSettingsProperties` carries `redirects`, `connectTimeout`, `readTimeout`,
`cookieHandling` and `ssl`. There is no `spring.http.clients.inet-address-filter`. Configuration
is a bean or an explicit `HttpClientSettings`, and nothing else — so it cannot be turned on per
environment from a config server, and it cannot be turned off in an incident without a deploy.
Plan for that: put the filter behind a `@Profile` or a `@ConditionalOnProperty` yourself if you
need a switch.
## Failure 1 — the starter does not bring it
`spring-boot-starter-web` alone does **not** put `InetAddressFilter` on the classpath, and does
not give you an auto-configured `RestClient.Builder` either. Boot 4 split the HTTP client
modules apart. The compile error is the good outcome:
```
cannot find symbol
symbol: class FilteredHostException
```
Add `spring-boot-starter-restclient` (or `-webclient`), which pulls in `spring-boot-restclient`
and through it `spring-boot-http-client`. This module's
[`pom.xml`](../pom.xml) does exactly that.
## Failure 2 — a client you built yourself
The filter reaches auto-configured builders. A `RestClient.create()` or a `new RestTemplate()`
written inside your own class is not one, and no bean will change it. That is why
[`LinkPreviewController`](../src/main/java/com/ankurm/ssrf/LinkPreviewController.java) takes
`RestClient.Builder` in its constructor.
For a hand-built client, apply the filter yourself:
```java
HttpClientSettings settings = HttpClientSettings.defaults()
.withInetAddressFilter(InetAddressFilter.externalAddresses());
ClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.jdk().build(settings);
```
And note what is still not covered: anything that opens a socket without going through a Spring
HTTP client. A JDBC URL, a raw `URL.openStream()`, an SDK with its own transport, a
`ProcessBuilder` running `curl`. `InetAddressFilter` is a control on Spring's HTTP clients, not
an egress policy for the JVM. If you need the latter, it belongs in the network.
## Failure 3 — two beans, and a diagnostic that blames the wrong thing
`getIfAvailable()` is not "pick one". Two `InetAddressFilter` beans and the context does not
start — see [`docs/output/two-filter-beans.txt`](output/two-filter-beans.txt):
```
No qualifying bean of type 'org.springframework.boot.http.client.InetAddressFilter' available:
expected single matching bean but found 2: firstFilter,secondFilter
```
but the framed message Boot prints underneath names something four levels away:
```
Description:
Parameter 0 of method restClientBuilder in ...RestClientAutoConfiguration required a single
bean, but 2 were found:
```
The words `InetAddressFilter` do not appear in the part everyone reads. If you are merging two
starters or two shared config modules, this failure will look like a `RestClient` problem.
[Operating it &rarr;](06-operating-it.md)

View File

@@ -0,0 +1,67 @@
[&larr; Wiring it up](05-wiring-it-up.md) &middot; [Module README](../README.md) &middot; [Composing filters &rarr;](07-composing-filters.md)
# 6. Operating it
## What the caller sees
`FilteredHostException` is a plain `RuntimeException`. Uncaught in a controller it is a bare
**HTTP 500**, and Boot's default error body does not name the host — so the first symptom in
production is a 500 with nothing useful in the response and a stack trace in the log.
Catch it. It carries the two things you want:
```java
catch (FilteredHostException ex) {
log.warn("outbound call to {} blocked by {}", ex.getHost(), ex.getFilter());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(...);
}
```
`getHost()` is the host **string from the URI**, not the resolved address — `Filtered host
'localhost'`, not `Filtered host '127.0.0.1'`. On the Apache path the resolved addresses were
available and were not kept, so if you want to log which address triggered it you have to
resolve again yourself.
A 4xx is arguably the better answer when the URL came from a user, since the request is what is
wrong. Do not echo `ex.getMessage()` back to them: it confirms which hosts are unreachable,
which turns your error response into a network scanner. Log it, return something bland.
## Alert on it
A `FilteredHostException` is either an attack or an outage, and you want to know which. Both are
worth paging on eventually, but the second is the one that will bite you first: an allow-list
pinned to IP ranges fails the day the destination changes its DNS. The `allowlist` profile in
this module was written against `93.184.216.34`, which was example.com's address for a decade
and is not any more.
## Testing it
The filter is a `@FunctionalInterface` with no Spring dependencies, so the allow/deny decision
is a unit test — no context, no network:
```java
assertThat(InetAddressFilter.externalAddresses()
.matches(InetAddress.getByName("169.254.169.254"))).isFalse();
```
[`FilterMatrix`](../src/main/java/com/ankurm/ssrf/FilterMatrix.java) is that idea with a
table around it. Run it against your own filter before you deploy it; the four-row disagreement
in [chapter 3](03-allow-not-block.md) is not something you would find by reading.
## The diagnostic endpoint
[`/diag/filter?host=...`](../src/main/java/com/ankurm/ssrf/DiagnosticsController.java) reports
whether a filter bean is present, whether the settings picked it up, and the verdict on every
address the host resolves to:
```json
{ "filterBeanPresent": true, "settingsCarryFilter": true, "host": "example.com",
"resolvesTo": { "172.66.147.243": true, "104.20.23.154": true } }
```
That answers the question you actually have when an outbound call fails, which is not "what does
my configuration say" but "what does the running context think". **Delete it before shipping**:
it is an oracle for your outbound allow-list and a host-resolution service for anyone who finds
it.
[Composing filters &rarr;](07-composing-filters.md)

View File

@@ -0,0 +1,67 @@
[&larr; Operating it](06-operating-it.md) &middot; [Module README](../README.md)
# 7. Composing filters, and the vararg that matches nothing
`InetAddressFilter` has `and`, `or`, `andNot` and `negate`, each with three overloads. The
`String...` overloads do not mean what the symmetry suggests.
## `of(a, b)` ORs. `and(a, b)` does not.
```java
public default InetAddressFilter and(String... addresses) {
return and(Arrays.stream(addresses).map(IpAddress::of).map(...).toList());
}
```
Each address becomes **its own filter**, and `and(Collection)` folds the whole list with logical
AND. So `and("104.16.0.0/12", "172.64.0.0/13")` asks for an address inside *both* ranges. No
address is inside two disjoint ranges, so the filter matches nothing and every outbound call
fails.
Run [`AndVarargsTrap`](../src/main/java/com/ankurm/ssrf/AndVarargsTrap.java) —
[`docs/output/and-varargs-trap.txt`](output/and-varargs-trap.txt):
```
address under test: 104.20.23.154 (inside 104.16.0.0/12, outside 172.64.0.0/13)
of("104.16.0.0/12") -> true
of("104.16.0.0/12", "172.64.0.0/13") -> true
externalAddresses().and("104.16.0.0/12") -> true
externalAddresses().and("104.16.0.0/12", "172.64.0.0/13") -> false
externalAddresses().and(of("104.16.0.0/12", "172.64.0.0/13")) -> true
```
The javadoc says the addresses are ANDed with the filter "in any form supported by
`of(String...)`", which reads as though they are combined the way `of` combines them. They are
not: that phrase is about the format of each string.
**Rule: whenever you pass more than one address to `and`, wrap them in `of` first.** One address
is safe; two silently is not. There is no warning, no log line, and the symptom is that
everything is blocked — which looks like the filter working.
`andNot(a, b)` is fine, because "not a AND not b" is what you want from a subtraction, and it is
how `externalAddresses()` itself is built:
```java
externalAddresses() = routable().andNot(multicast(), specialPurpose())
```
`or(a, b)` is also fine.
## A useful shape
Public internet, minus a range you know is hostile, plus one internal service you legitimately
call:
```java
InetAddressFilter.externalAddresses()
.andNot("203.0.113.0/24")
.or(InetAddressFilter.of("10.20.30.40"));
```
Read it left to right and check it against `FilterMatrix` before you believe it. Boolean
composition of allow-lists is the kind of thing that is obvious while you write it and wrong
when you read it back.
[Module README](../README.md)

View File

@@ -0,0 +1,11 @@
address under test: 104.20.23.154 (inside 104.16.0.0/12, outside 172.64.0.0/13)
of("104.16.0.0/12") -> true
of("104.16.0.0/12", "172.64.0.0/13") -> true
externalAddresses().and("104.16.0.0/12") -> true
externalAddresses().and("104.16.0.0/12", "172.64.0.0/13") -> false
externalAddresses().and(of("104.16.0.0/12", "172.64.0.0/13")) -> true
The fourth line is the trap. One address is one filter; two addresses
are two filters ANDed, and no address is inside two disjoint ranges.

View File

@@ -0,0 +1,55 @@
===================================================================================
PROFILE: (none) - no InetAddressFilter bean
===================================================================================
target outcome
-----------------------------------------------------------------------------------------
http://127.0.0.1:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-EXAMPLE-NOT-REAL","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","Expir
http://localhost:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-EXAMPLE-NOT-REAL","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","Expir
http://[::1]:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-EXAMPLE-NOT-REAL","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","Expir
http://172.16.10.3:8080/internal/credentials FETCHED | {"SecretAccessKey":"wJalrXUtnFEMI-EXAMPLE-NOT-REAL","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","Expir
http://example.com/ FETCHED | <!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"
===================================================================================
PROFILE: docsfilter
===================================================================================
target outcome
-----------------------------------------------------------------------------------------
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
http://localhost:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host 'localhost'
http://[::1]:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '[::1]'
http://172.16.10.3:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '172.16.10.3'
http://example.com/ FETCHED | <!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"
===================================================================================
PROFILE: blocklist
===================================================================================
target outcome
-----------------------------------------------------------------------------------------
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
http://localhost:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host 'localhost'
http://[::1]:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '[::1]'
http://172.16.10.3:8080/internal/credentials FETCHED | {"Expiration":"2026-08-29T23:59:59Z","AccessKeyId":"ASIA-EXAMPLE-NOT-REAL","SecretAccessKey":"wJ
http://example.com/ BLOCKED_BY_FILTER | Filtered host 'example.com'
===================================================================================
PROFILE: negated
===================================================================================
target outcome
-----------------------------------------------------------------------------------------
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
http://localhost:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host 'localhost'
http://[::1]:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '[::1]'
http://172.16.10.3:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '172.16.10.3'
http://example.com/ FETCHED | <!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"
===================================================================================
PROFILE: allowlist
===================================================================================
target outcome
-----------------------------------------------------------------------------------------
http://127.0.0.1:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '127.0.0.1'
http://localhost:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host 'localhost'
http://[::1]:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '[::1]'
http://172.16.10.3:8080/internal/credentials BLOCKED_BY_FILTER | Filtered host '172.16.10.3'
http://example.com/ FETCHED | <!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"

View File

@@ -0,0 +1,20 @@
true = the filter MATCHES the address = the call is ALLOWED through.
A row that is false in the active filter's column throws FilteredHostException.
address | all() | none() | routable() | multicast() | specialPurpose() | internalAddresses() | externalAddresses() | internalAddresses().negate()| of(RFC1918) [the inversion]
----------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------+---------------------------
127.0.0.1 | true | false | true | false | true | true | false | false | false
169.254.169.254 | true | false | true | false | true | true | false | false | false
10.0.0.1 | true | false | true | false | true | true | false | false | true
172.16.10.3 | true | false | true | false | true | true | false | false | true
192.168.1.1 | true | false | true | false | true | true | false | false | true
100.64.0.1 | true | false | true | false | true | false | false | true | false
0.0.0.0 | true | false | false | false | true | false | false | true | false
192.0.2.1 | true | false | true | false | true | false | false | true | false
224.0.0.1 | true | false | true | true | false | false | false | true | false
93.184.216.34 | true | false | true | false | false | false | true | true | false
::1 | true | false | true | false | true | true | false | false | false
fc00::1 | true | false | true | false | true | true | false | false | false
fe80::1 | true | false | true | false | true | true | false | false | false
64:ff9b::a00:1 | true | false | true | false | true | true | false | false | false
2606:2800:220:1::1 | true | false | true | false | false | false | true | true | false

View File

@@ -0,0 +1,3 @@
[INFO] Running org.springframework.boot.http.client.WhereTheFilterRunsTests
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.221 s -- in org.springframework.boot.http.client.WhereTheFilterRunsTests
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0

View File

@@ -0,0 +1,6 @@
$ java -cp ... SsrfDemoApplication --spring.profiles.active=twofilters
2026-08-29T09:28:27.206+05:30 WARN 445 --- [ssrf-inet-address-filter] [ main] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'linkPreviewController' defined in file [/tmp/work/ssd/ssrf/target/classes/com/ankurm/ssrf/LinkPreviewController.class]: Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'restClientBuilder' defined in class path resource [org/springframework/boot/restclient/autoconfigure/RestClientAutoConfiguration.class]: Unsatisfied dependency expressed through method 'restClientBuilder' parameter 0: Error creating bean with name 'restClientBuilderConfigurer' defined in class path resource [org/springframework/boot/restclient/autoconfigure/RestClientAutoConfiguration.class]: Failed to instantiate [org.springframework.boot.restclient.autoconfigure.RestClientBuilderConfigurer]: Factory method 'restClientBuilderConfigurer' threw exception with message: Error creating bean with name 'httpClientSettings' defined in class path resource [org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfiguration.class]: Failed to instantiate [org.springframework.boot.http.client.HttpClientSettings]: Factory method 'httpClientSettings' threw exception with message: No qualifying bean of type 'org.springframework.boot.http.client.InetAddressFilter' available: expected single matching bean but found 2: firstFilter,secondFilter
APPLICATION FAILED TO START
Description:
Parameter 0 of method restClientBuilder in org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration required a single bean, but 2 were found:
Action: