Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ Each subdirectory is an **independent** Spring Boot application with its own Gra
| [`easy-paging-demo`](easy-paging-demo/) | Annotation-driven offset pagination with `@AutoPaginate` (Spring Boot + MyBatis + H2) | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) |
| [`easy-paging-keyset-demo`](easy-paging-keyset-demo/) | Cursor (keyset) pagination with `@KeysetPaginate` — composite `(time, id)` key, stable under writes, no `OFFSET`/`COUNT(*)` | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) |
| [`easy-paging-postgres-demo`](easy-paging-postgres-demo/) | Same starter against **real PostgreSQL** — Docker Compose for `bootRun`, Testcontainers + `@ServiceConnection` for tests, no local DB install | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) |
| [`ssrf-guard-demo`](ssrf-guard-demo/) | SSRF (Server-Side Request Forgery) protection across three Spring HTTP clients (RestClient, RestTemplate, WebClient) — same `UrlPolicy` for all. 15-pattern attack matrix endpoint, Micrometer metrics. | [`kr.devslab:ssrf-guard:3.0.0`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard) |
| [`ssrf-guard-springai-demo`](ssrf-guard-springai-demo/) | ⭐ **LLM agent SSRF defense.** Wraps every Spring AI `ToolCallback` so URL-shaped tool arguments are validated before the LLM-driven `fetch_url` runs. Fake-LLM driver makes the demo runnable offline (no API key). | [`kr.devslab:ssrf-guard-springai:3.0.0`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-springai) |
| [`ssrf-guard-feign-demo`](ssrf-guard-feign-demo/) | Spring Cloud OpenFeign `RequestInterceptor` — same `UrlPolicy` applied to `@FeignClient` calls. Two `@FeignClient` interfaces (one whitelisted, one not) to show the block path. | [`kr.devslab:ssrf-guard-feign:3.0.0`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-feign) |
| [`ssrf-guard-jdkhttp-demo`](ssrf-guard-jdkhttp-demo/) | `java.net.http.HttpClient` (Java 11+) wrapper — no Spring required by the library. Three-line wiring in `main()`. | [`kr.devslab:ssrf-guard-jdkhttp:3.0.0`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-jdkhttp) |
| [`ssrf-guard-okhttp-demo`](ssrf-guard-okhttp-demo/) | OkHttp `Interceptor` + `Dns` integration — also no Spring needed. Three-line wiring on `OkHttpClient.Builder`. | [`kr.devslab:ssrf-guard-okhttp:3.0.0`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-okhttp) |

## Conventions

Expand Down
162 changes: 162 additions & 0 deletions ssrf-guard-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# ssrf-guard-demo

Runnable example for [`ssrf-guard`](https://github.com/devslab-kr/ssrf-guard) — SSRF (Server-Side Request Forgery) protection for the JVM.

One Spring Boot app shows **all three Spring HTTP clients** wired through the same `UrlPolicy`:

- `RestClient` (Spring 6.1+) via the meta `kr.devslab:ssrf-guard:3.0.0` artifact
- `RestTemplate` via `kr.devslab:ssrf-guard-resttemplate:3.0.0`
- `WebClient` (WebFlux) via `kr.devslab:ssrf-guard-webclient:3.0.0`

Plus a `/attacks` endpoint that lists every SSRF bypass pattern the guard catches, with copy-paste curls for each.

## Prerequisites

- JDK 21+
- An internet-reachable host that's whitelisted (`httpbin.org` by default — used to show the allowed path actually reaches the network).

## Run

```bash
cd ssrf-guard-demo
./gradlew bootRun
```

App comes up on `http://localhost:8080`.

## Try it

### Allowed — RestClient hits the real httpbin.org

```bash
curl 'http://localhost:8080/fetch?url=https://httpbin.org/get' | jq
```

```json
{
"status": "allowed",
"client": "RestClient",
"url": "https://httpbin.org/get",
"bodyPreview": "{\n \"args\": {}, \n \"headers\": { ... }, \n ..."
}
```

### Blocked — AWS metadata theft attempt (the canonical SSRF→cloud-takeover)

```bash
curl 'http://localhost:8080/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/' | jq
```

```json
{
"status": "blocked",
"client": "RestClient",
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"reason": "blocked_ip_literal",
"message": "IP-literal host blocked (rejectIpLiteralHosts=true): 169.254.169.254"
}
```

### Blocked — host not in the whitelist

```bash
curl 'http://localhost:8080/fetch?url=https://evil.com/' | jq
# reason: "blocked_host"
```

### Blocked — decimal-encoded loopback (`2130706433` == `127.0.0.1`)

```bash
curl 'http://localhost:8080/fetch?url=http://2130706433/' | jq
# reason: "blocked_ip_literal"
```

### Blocked — userinfo (`user:pass@host`)

```bash
curl 'http://localhost:8080/fetch?url=https://user:pass@httpbin.org/get' | jq
# reason: "blocked_userinfo"
```

### Blocked — redirect to AWS metadata (4th defense layer)

```bash
curl 'http://localhost:8080/fetch?url=https://httpbin.org/redirect-to?url=http://169.254.169.254/' | jq
# Caught at hop 2 — even though the initial host is whitelisted, the redirect
# strategy re-validates every URL change.
```

### Same attacks, same outcome, different HTTP client

```bash
curl 'http://localhost:8080/fetch-resttemplate?url=http://169.254.169.254/'
curl 'http://localhost:8080/fetch-webclient?url=http://169.254.169.254/'
```

Identical `reason` field — all three HTTP clients are wrapped by the same `UrlPolicy` bean.

### The full attack matrix (15 entries)

```bash
curl http://localhost:8080/attacks | jq
```

Returns every attack pattern with its `expectedReason` and pre-built `tryRestClient` / `tryRestTemplate` / `tryWebClient` curl strings. Pipe a single one into `bash`:

```bash
curl -s http://localhost:8080/attacks \
| jq -r '.attacks[] | select(.name == "aws-metadata-credentials") | .tryRestClient' \
| bash | jq
```

### Observability — Micrometer metrics

```bash
# After running a few of the curls above:
curl -s http://localhost:8080/actuator/metrics/ssrf_guard_blocked_total | jq
curl -s http://localhost:8080/actuator/prometheus | grep ssrf_guard
```

You'll see counters per `reason` tag (`blocked_host`, `blocked_ip_literal`, `blocked_private_ip`, `blocked_userinfo`, `blocked_redirect`, ...) and a separate `ssrf_guard_allowed_total` for the requests that passed.

## What to read

| File | Why |
| --- | --- |
| `build.gradle.kts` | The only dependencies beyond the standard starters are `kr.devslab:ssrf-guard:3.0.0`, `kr.devslab:ssrf-guard-resttemplate:3.0.0`, `kr.devslab:ssrf-guard-webclient:3.0.0` — no manual configuration class needed |
| `application.yml` | Every `ssrf.guard.*` knob in one place with comments |
| `web/FetchController.java` | The whole RestClient story — three lines of setup, the guard runs invisibly |
| `web/FetchResttemplateController.java` | Same shape for RestTemplate — no migration needed for legacy code |
| `web/FetchWebClientController.java` | Reactive variant; demonstrates `SsrfGuardException` flowing through `Mono.onErrorResume` |
| `web/AttackDemoController.java` | Catalog of attack patterns + expected `BlockReason` for each |

## Loosening the whitelist (sanity check)

Want to confirm the guard is actually doing anything? Edit `application.yml`:

```yaml
ssrf:
guard:
enabled: false
```

Restart and run the AWS metadata curl again — it'll now actually hit `169.254.169.254` (and time out on most networks, but the guard isn't intervening anymore).

## Verify the build

```bash
./gradlew build
```

Runs the smoke test in `SsrfGuardDemoApplicationTests`, which boots the app and asserts:

1. A whitelisted URL passes through (`status=allowed`),
2. An attack URL is blocked with the right `reason` tag,
3. The actuator metrics endpoint exposes `ssrf_guard_blocked_total` after the block.

## Further reading

- ssrf-guard docs site: <https://ssrf-guard.devslab.kr/>
- ssrf-guard repo: <https://github.com/devslab-kr/ssrf-guard>
- Java SSRF training (attack patterns referenced by `AttackDemoController`): <https://github.com/JoyChou93/java-sec-code>
- OWASP SSRF top 10: <https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/>
43 changes: 43 additions & 0 deletions ssrf-guard-demo/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
plugins {
java
id("org.springframework.boot") version "3.5.3"
id("io.spring.dependency-management") version "1.1.6"
}

group = "kr.devslab.examples"
version = "0.0.1-SNAPSHOT"

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

repositories {
mavenCentral()
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.springframework.boot:spring-boot-starter-actuator")

// The libraries this demo showcases — one app, three HTTP-client modules.
// The meta `ssrf-guard` artifact transitively pulls in `-core`, `-httpclient5`,
// and `-restclient`. The `-resttemplate` and `-webclient` modules are
// additive and reuse the same UrlPolicy / SsrfGuardMetrics beans.
implementation("kr.devslab:ssrf-guard:3.0.0")
implementation("kr.devslab:ssrf-guard-resttemplate:3.0.0")
implementation("kr.devslab:ssrf-guard-webclient:3.0.0")

// Micrometer Prometheus registry — turns SSRF Guard's counters into
// /actuator/prometheus output so you can curl the metrics in the demo.
runtimeOnly("io.micrometer:micrometer-registry-prometheus")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.named<Test>("test") {
useJUnitPlatform()
}
3 changes: 3 additions & 0 deletions ssrf-guard-demo/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
Binary file added ssrf-guard-demo/gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
7 changes: 7 additions & 0 deletions ssrf-guard-demo/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Loading
Loading