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
2 changes: 2 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ Spring Boot 3.3–3.5 사용 중인 앱용. 스타터의 [`3.x` 브랜치](https
| [`ssrf-guard-feign-demo`](ssrf-guard-feign-demo/) | Spring Cloud OpenFeign `RequestInterceptor` — `@FeignClient` 호출에 동일 `UrlPolicy` 적용. 화이트리스트 / 비화이트리스트 `@FeignClient` 2개로 차단 경로 시연 | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-feign?label=kr.devslab%3Assrf-guard-feign)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-feign) |
| [`ssrf-guard-jdkhttp-demo`](ssrf-guard-jdkhttp-demo/) | `java.net.http.HttpClient`(Java 11+) 래퍼 — 라이브러리 자체엔 Spring 의존성 없음. `main()`에서 3줄 wiring | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-jdkhttp?label=kr.devslab%3Assrf-guard-jdkhttp)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-jdkhttp) |
| [`ssrf-guard-okhttp-demo`](ssrf-guard-okhttp-demo/) | OkHttp `Interceptor` + `Dns` — Spring 필요 없음. `OkHttpClient.Builder`에 3줄 wiring | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-okhttp?label=kr.devslab%3Assrf-guard-okhttp)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-okhttp) |
| [`ssrf-guard-httpclient5-demo`](ssrf-guard-httpclient5-demo/) | Apache HttpClient 5 — **DNS 시점** SSRF 게이트 (`SafeDnsResolver`) + `SafeRedirectStrategy`. Spring에서 wiring 코드 0줄 (모듈이 자체 자동설정 제공); Spring 없으면 5줄. TOCTOU 차단 방식: 동일 `InetAddress[]`로 검증=연결 | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-httpclient5?label=kr.devslab%3Assrf-guard-httpclient5)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-httpclient5) |
| [`ssrf-guard-native-image-demo`](ssrf-guard-native-image-demo/) | ⚡ **GraalVM 네이티브 이미지** 증명. `ssrf-guard:3.1.0` 끌고 `org.graalvm.buildtools.native` plugin 적용, `nativeCompile`이 JVM 빌드와 동일한 12개 공격 패턴을 차단하는 동작하는 네이티브 바이너리를 만든다는 시연. ssrf-guard 3.1.0의 `RuntimeHintsRegistrar` 엔트리가 완전함을 end-to-end 검증 | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard?label=kr.devslab%3Assrf-guard)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard) |

## 컨벤션

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ For apps still on Spring Boot 3.3–3.5. The starter's [`3.x` branch](https://gi
| [`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. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-feign?label=kr.devslab%3Assrf-guard-feign)](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()`. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-jdkhttp?label=kr.devslab%3Assrf-guard-jdkhttp)](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`. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-okhttp?label=kr.devslab%3Assrf-guard-okhttp)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-okhttp) |
| [`ssrf-guard-httpclient5-demo`](ssrf-guard-httpclient5-demo/) | Apache HttpClient 5 — **DNS-time** SSRF gate (`SafeDnsResolver`) + `SafeRedirectStrategy`. Zero wiring code in Spring (module ships its own autoconfig); five-line wiring outside Spring. The TOCTOU-closing approach: validate=connect on the same `InetAddress[]`. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard-httpclient5?label=kr.devslab%3Assrf-guard-httpclient5)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-httpclient5) |
| [`ssrf-guard-native-image-demo`](ssrf-guard-native-image-demo/) | ⚡ **GraalVM native-image** proof. Pulls `ssrf-guard:3.1.0`, applies the `org.graalvm.buildtools.native` plugin, demonstrates `nativeCompile` produces a working native binary that blocks the same 12-pattern attack matrix as the JVM build. End-to-end verification that ssrf-guard 3.1.0's `RuntimeHintsRegistrar` entries are complete. | [![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/ssrf-guard?label=kr.devslab%3Assrf-guard)](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard) |

## Conventions

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

[English](README.md) · **한국어**

[`ssrf-guard-httpclient5`](https://github.com/devslab-kr/ssrf-guard) — Apache HttpClient 5에 대한 SSRF 방어 실행 가능 예제.

**다른 ssrf-guard 데모와는 다른 모양.** Apache HttpClient 5는 SSRF 정책을 URL parse 시점이 아니라 **DNS 해석 시점**에 hook합니다. "URL 검사했음"과 "소켓 열림" 사이의 TOCTOU 윈도우를 닫는 정확한 게이트.

## 모듈이 하는 일

`HttpClients.custom()`의 두 확장 지점:

| 플러그인 지점 | 역할 |
| --- | --- |
| [`DnsResolver`](https://hc.apache.org/httpcomponents-client-5.4.x/current/httpclient5/apidocs/org/apache/hc/client5/http/DnsResolver.html) | `SafeDnsResolver` — 화이트리스트 외 호스트 거부, 해석된 IP에서 private/loopback/link-local/cloud-metadata 필터, 남는 게 없으면 `UnknownHostException` throw — `Socket.connect()` 자체가 안 일어남. |
| [`RedirectStrategy`](https://hc.apache.org/httpcomponents-client-5.4.x/current/httpclient5/apidocs/org/apache/hc/client5/http/protocol/RedirectStrategy.html) | `SafeRedirectStrategy` — 매 redirect 홉에서 scheme 검사 + 동일한 DNS 게이트 재실행. "`example.com` 화이트리스트, 그 다음 302 `http://169.254.169.254/`" 공격 차단. |

`SafeDnsResolver`가 반환하는 `InetAddress[]`는 HttpClient가 `Socket.connect()`에 그대로 전달하는 배열 — 검증과 연결 사이에 두 번째 DNS 조회 없음. TOCTOU 윈도우가 거기서 닫힙니다.

## 실행

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

## 시험해보기

```bash
# 허용 — 화이트리스트 호스트
curl 'http://localhost:8080/fetch?url=https://httpbin.org/get' | jq

# 차단 — AWS 메타데이터 (link-local IP가 DNS 게이트에서 필터됨)
curl 'http://localhost:8080/fetch?url=http://169.254.169.254/' | jq

# 차단 — 10진수 인코딩된 127.0.0.1
curl 'http://localhost:8080/fetch?url=http://2130706433/' | jq

# 차단 — 화이트리스트 외 호스트 (`SafeDnsResolver`가 사전 거부)
curl 'http://localhost:8080/fetch?url=https://evil.com/' | jq
```

차단된 응답 예:

```json
{
"client": "Apache HttpClient 5",
"url": "http://169.254.169.254/",
"status": "blocked",
"reason": "blocked_dns",
"message": "No allowed IP after filtering: 169.254.169.254"
}
```

`reason: "blocked_dns"`는 두 케이스를 커버:
- *"Host not in whitelist: <host>"* — `SafeDnsResolver`가 `InetAddress.getAllByName` 호출하기도 전에 거부.
- *"No allowed IP after filtering: <host>"* — DNS는 IP를 반환했지만 모두 차단 범위였음.

## 읽을 만한 파일

| 파일 | 왜 |
| --- | --- |
| `build.gradle.kts` | 의존성 둘: `kr.devslab:ssrf-guard-httpclient5:3.1.0` + `org.apache.httpcomponents.client5:httpclient5:5.4.1` |
| `SsrfGuardHttpClient5DemoApplication.java` | `@SpringBootApplication`만. 모듈의 자동 설정이 가드된 `CloseableHttpClient` 빈을 wire — **wiring 코드 0줄**. |
| `HttpClient5DemoController.java` | 표준 `client.execute(get, handler)` 호출 — 가드 참조 0 |
| `application.yml` | `ssrf.guard.*` 키: 화이트리스트, `block-private-networks`, `follow-redirects`, `allowed-schemes` |

## Spring 없이

모듈 자동 설정은 5줄 wrapping에 불과:

```java
HostPolicy hostPolicy = new HostPolicy(
List.of("api.partner.com"),
List.of());
SafeDnsResolver dns = new SafeDnsResolver(hostPolicy, /* blockPrivate */ true,
NoOpSsrfGuardMetrics.INSTANCE);

CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
.setDnsResolver(dns)
.build())
.setRedirectStrategy(new SafeRedirectStrategy(
dns, List.of("http", "https"), NoOpSsrfGuardMetrics.INSTANCE))
.build();
```

Spring 없는 모든 JVM 앱 (Quarkus, Helidon, Lambda, 순수 `main`)에 drop-in.

## 다른 ssrf-guard 데모와의 비교

| 데모 | URL 검사 시점 | 라이브러리 |
| --- | --- | --- |
| `ssrf-guard-demo` (RestClient / RestTemplate / WebClient) | URL parse 시점 (`ClientHttpRequestInterceptor`) + WebClient는 DNS 시점도 (reactor-netty AddressResolverGroup) | Spring HTTP 스택 |
| `ssrf-guard-feign-demo` | URL parse 시점 (`RequestInterceptor`) | Spring Cloud OpenFeign |
| `ssrf-guard-okhttp-demo` | URL parse 시점 (`Interceptor`) + DNS 시점 (`Dns` SPI) | OkHttp |
| `ssrf-guard-jdkhttp-demo` | URL parse 시점 (래퍼) | `java.net.http.HttpClient` |
| **`ssrf-guard-httpclient5-demo` (이것)** | **DNS 시점만** (`DnsResolver` + `RedirectStrategy`) | Apache HttpClient 5 |
| `ssrf-guard-springai-demo` / `-langchain4j-demo` | LLM 툴 인자 JSON 검증 | Spring AI / LangChain4j |

DNS 시점만으로 충분한 이유:
- IP 리터럴 (`http://169.254.169.254/`, decimal/hex/octal 인코딩 loopback) 모두 같은 `InetAddress[]`로 디코딩됨 — private-IP 필터가 잡음.
- 화이트리스트 외 호스트는 resolver의 `getAllByName` 호출 자체에 도달 안 함.
- DNS rebinding과 late-binding A-record도 같은 게이트에서 잡힘 — 공격자가 race할 두 번째 lookup이 없음.

URL-parse 시점 게이트와 비교해 *못 잡는* 것: scheme 제한과 `https://user:pass@host/` userinfo 거부. 다른 모양이 필요해서 현재 `-httpclient5` 모듈에는 없습니다.

## 빌드 검증

```bash
./gradlew build
```

`SsrfGuardHttpClient5DemoApplicationTests` 스모크 테스트가 AWS 메타데이터, 10진수 loopback, 비화이트리스트 호스트 모두 DNS 게이트에서 차단됨을 검증 — 어떤 소켓도 열리기 전에.
114 changes: 114 additions & 0 deletions ssrf-guard-httpclient5-demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# ssrf-guard-httpclient5-demo

**English** · [한국어](README.ko.md)

Runnable example for [`ssrf-guard-httpclient5`](https://github.com/devslab-kr/ssrf-guard) — SSRF protection for Apache HttpClient 5.

**Different shape from the other ssrf-guard demos.** Apache HttpClient 5 hooks the SSRF policy at **DNS-resolution time**, not URL-parse time. That's the right gate for HttpClient because it closes the TOCTOU window between "you checked the URL" and "the socket opens".

## What the module does

Two extension points on `HttpClients.custom()`:

| Plug-in point | What it does |
| --- | --- |
| [`DnsResolver`](https://hc.apache.org/httpcomponents-client-5.4.x/current/httpclient5/apidocs/org/apache/hc/client5/http/DnsResolver.html) | `SafeDnsResolver` — refuses to resolve hosts outside the whitelist; filters private / loopback / link-local / cloud-metadata IPs out of the resolved set; if nothing is left, throws `UnknownHostException` so `Socket.connect()` never happens. |
| [`RedirectStrategy`](https://hc.apache.org/httpcomponents-client-5.4.x/current/httpclient5/apidocs/org/apache/hc/client5/http/protocol/RedirectStrategy.html) | `SafeRedirectStrategy` — runs scheme check + the same DNS gate on every redirect hop. Closes the "whitelist `example.com`, then it 302s to `http://169.254.169.254/`" attack. |

The `InetAddress[]` `SafeDnsResolver` returns is the exact same array HttpClient passes to `Socket.connect()` — so there's no second DNS lookup between validation and connection. That's the TOCTOU window closed.

## Run

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

## Try it

```bash
# Allowed — host in whitelist
curl 'http://localhost:8080/fetch?url=https://httpbin.org/get' | jq

# Blocked — AWS metadata (link-local IP filtered out at DNS gate)
curl 'http://localhost:8080/fetch?url=http://169.254.169.254/' | jq

# Blocked — decimal-encoded 127.0.0.1
curl 'http://localhost:8080/fetch?url=http://2130706433/' | jq

# Blocked — host not in whitelist (SafeDnsResolver refuses upfront)
curl 'http://localhost:8080/fetch?url=https://evil.com/' | jq
```

Blocked responses look like:

```json
{
"client": "Apache HttpClient 5",
"url": "http://169.254.169.254/",
"status": "blocked",
"reason": "blocked_dns",
"message": "No allowed IP after filtering: 169.254.169.254"
}
```

`reason: "blocked_dns"` covers both:
- *"Host not in whitelist: <host>"* — `SafeDnsResolver` refused before even calling `InetAddress.getAllByName`.
- *"No allowed IP after filtering: <host>"* — DNS returned IPs but every one was in a blocked range.

## What to read

| File | Why |
| --- | --- |
| `build.gradle.kts` | Two deps: `kr.devslab:ssrf-guard-httpclient5:3.1.0` + `org.apache.httpcomponents.client5:httpclient5:5.4.1` |
| `SsrfGuardHttpClient5DemoApplication.java` | Just `@SpringBootApplication`. The module's autoconfig wires the guarded `CloseableHttpClient` bean — **zero lines of wiring code**. |
| `HttpClient5DemoController.java` | Standard `client.execute(get, handler)` call — no reference to the guard at all |
| `application.yml` | `ssrf.guard.*` keys: whitelist, `block-private-networks`, `follow-redirects`, `allowed-schemes` |

## Without Spring

The module's autoconfig is a thin wrapper around five lines:

```java
HostPolicy hostPolicy = new HostPolicy(
List.of("api.partner.com"),
List.of());
SafeDnsResolver dns = new SafeDnsResolver(hostPolicy, /* blockPrivate */ true,
NoOpSsrfGuardMetrics.INSTANCE);

CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
.setDnsResolver(dns)
.build())
.setRedirectStrategy(new SafeRedirectStrategy(
dns, List.of("http", "https"), NoOpSsrfGuardMetrics.INSTANCE))
.build();
```

Drop into any non-Spring JVM app (Quarkus, Helidon, Lambda, plain `main`).

## How this compares to other ssrf-guard demos

| Demo | Where the URL is checked | Library |
| --- | --- | --- |
| `ssrf-guard-demo` (RestClient / RestTemplate / WebClient) | URL-parse time (`ClientHttpRequestInterceptor`) + DNS-time for WebClient (reactor-netty AddressResolverGroup) | Spring HTTP stack |
| `ssrf-guard-feign-demo` | URL-parse time (`RequestInterceptor`) | Spring Cloud OpenFeign |
| `ssrf-guard-okhttp-demo` | URL-parse time (`Interceptor`) + DNS-time (`Dns` SPI) | OkHttp |
| `ssrf-guard-jdkhttp-demo` | URL-parse time (wrapper) | `java.net.http.HttpClient` |
| **`ssrf-guard-httpclient5-demo` (this)** | **DNS-time only** (`DnsResolver` + `RedirectStrategy`) | Apache HttpClient 5 |
| `ssrf-guard-springai-demo` / `-langchain4j-demo` | LLM tool argument JSON validation | Spring AI / LangChain4j |

The DNS-time-only approach is enough because:
- IP literals (`http://169.254.169.254/`, decimal/hex/octal-encoded loopbacks) all decode to the same `InetAddress[]` the DNS resolver returns — the private-IP filter catches them.
- Hosts not in the whitelist never reach the resolver's `getAllByName` call.
- DNS rebinding and late-binding A-records are caught at the same gate — there's no second lookup the attacker can race against.

What it *doesn't* catch (compared to URL-parse-time gates): scheme restrictions and `https://user:pass@host/` userinfo rejection. Those need a different shape and aren't in the `-httpclient5` module today.

## Verify the build

```bash
./gradlew build
```

Runs the smoke tests in `SsrfGuardHttpClient5DemoApplicationTests` — checks that AWS-metadata, decimal-encoded loopback, and non-whitelisted hosts all block at the DNS gate before any socket opens.
42 changes: 42 additions & 0 deletions ssrf-guard-httpclient5-demo/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
plugins {
java
id("org.springframework.boot") version "3.5.3"
id("io.spring.dependency-management") version "1.1.7"
}

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

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

repositories {
mavenCentral()
}

dependencies {
// Spring Boot is here only for the REST endpoint that wraps the demo.
// The ssrf-guard-httpclient5 module ships its own Spring autoconfig —
// that's what wires the SafeDnsResolver + SafeRedirectStrategy onto a
// CloseableHttpClient bean. With this dependency on the classpath plus
// Spring Boot's autoconfig scanner, no wiring code is required in the
// demo's main(). Drop autoconfig (e.g. add @ImportAutoConfiguration
// exclusions) and the wiring still works through SafeDnsResolver
// constructed by hand — see the README's "Without Spring" section.
implementation("org.springframework.boot:spring-boot-starter-web")

implementation("kr.devslab:ssrf-guard-httpclient5:3.1.0")
// The Apache HttpClient 5 runtime. Versions 5.3+ ship the
// DnsResolver / RedirectStrategy interfaces the module plugs into.
implementation("org.apache.httpcomponents.client5:httpclient5:5.4.1")

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

tasks.named<Test>("test") {
useJUnitPlatform()
}
2 changes: 2 additions & 0 deletions ssrf-guard-httpclient5-demo/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
org.gradle.parallel=true
Binary file not shown.
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