From 3878a6d3e910e540ea07527d891d62068cd18f0b Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sat, 23 May 2026 15:25:29 +0900 Subject: [PATCH] docs(ssrf-guard demos): add Korean READMEs + Discussions link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bilingual coverage for the 5 ssrf-guard demos (matches the convention the libraries themselves follow — README.md + README.ko.md with a language-picker line at the top). Top-level README also picks up: - Korean translation (README.ko.md) - A pointer to the new Discussions (bilingual maintenance promised) Pattern, per demo: - README.md — primary content, English - README.ko.md — translation, kept in lockstep - Both have a `**English** · [한국어](README.ko.md)` or reverse line up top Follow-up (not in this PR): easy-paging-* demos still English only. Same treatment can be applied — straightforward translation work. --- README.ko.md | 41 +++++++ README.md | 4 + ssrf-guard-demo/README.ko.md | 163 ++++++++++++++++++++++++++ ssrf-guard-demo/README.md | 2 + ssrf-guard-feign-demo/README.ko.md | 44 +++++++ ssrf-guard-feign-demo/README.md | 2 + ssrf-guard-jdkhttp-demo/README.ko.md | 72 ++++++++++++ ssrf-guard-jdkhttp-demo/README.md | 2 + ssrf-guard-okhttp-demo/README.ko.md | 63 ++++++++++ ssrf-guard-okhttp-demo/README.md | 2 + ssrf-guard-springai-demo/README.ko.md | 153 ++++++++++++++++++++++++ ssrf-guard-springai-demo/README.md | 2 + 12 files changed, 550 insertions(+) create mode 100644 README.ko.md create mode 100644 ssrf-guard-demo/README.ko.md create mode 100644 ssrf-guard-feign-demo/README.ko.md create mode 100644 ssrf-guard-jdkhttp-demo/README.ko.md create mode 100644 ssrf-guard-okhttp-demo/README.ko.md create mode 100644 ssrf-guard-springai-demo/README.ko.md diff --git a/README.ko.md b/README.ko.md new file mode 100644 index 0000000..a98b8a6 --- /dev/null +++ b/README.ko.md @@ -0,0 +1,41 @@ +# devslab-examples + +[English](README.md) · **한국어** + +[devslab-kr](https://github.com/devslab-kr) Spring Boot 스타터와 라이브러리들의 실행 가능한 예제 모음. + +각 서브디렉토리는 자체 Gradle 빌드를 가진 **독립 Spring Boot 앱**입니다. 골라서 `cd` 하고 `./gradlew bootRun` 만 하면 됩니다. + +> 💬 질문, 아이디어, 데모 응용 사례 공유는 [**Discussions**](https://github.com/devslab-kr/devslab-examples/discussions)에서 — 영어/한국어 둘 다 환영, 라이브러리 만든 메인테이너가 직접 답변합니다. + +## 예제 + +| 데모 | 보여주는 것 | Maven Central 좌표 | +| --- | --- | --- | +| [`easy-paging-demo`](easy-paging-demo/) | `@AutoPaginate` 어노테이션 기반 offset 페이지네이션 (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/) | `@KeysetPaginate` 커서(keyset) 페이지네이션 — composite `(time, id)` 키, 쓰기 중에도 안정, `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/) | 동일 스타터를 **실제 PostgreSQL**과 — `bootRun`은 Docker Compose, 테스트는 Testcontainers + `@ServiceConnection`, 로컬 DB 설치 불필요 | [`kr.devslab:easy-paging-spring-boot-starter:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter) | +| [`easy-paging-reactive-demo`](easy-paging-reactive-demo/) | Reactive 스택 — **WebFlux + R2DBC**, `R2dbcOffsetPagingSupport` 사용. MVC 데모와 동일한 JSON 봉투를 `Mono>`로 서빙 | [`kr.devslab:easy-paging-spring-boot-starter-reactive:0.4.0`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter-reactive) | +| [`ssrf-guard-demo`](ssrf-guard-demo/) | SSRF(Server-Side Request Forgery) 방어를 3종 Spring HTTP 클라이언트(RestClient, RestTemplate, WebClient)에 동시 적용 — 모두 같은 `UrlPolicy`. 15가지 공격 매트릭스 엔드포인트, Micrometer 메트릭 포함 | [`kr.devslab:ssrf-guard:3.0.1`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard) | +| [`ssrf-guard-springai-demo`](ssrf-guard-springai-demo/) | ⭐ **LLM 에이전트 SSRF 방어.** 모든 Spring AI `ToolCallback`을 자동으로 wrap해서 LLM이 `fetch_url`을 호출하기 전에 URL 인자를 검증. 가짜 LLM 드라이버로 API 키 없이 오프라인 실행 가능 | [`kr.devslab:ssrf-guard-springai:3.0.1`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-springai) | +| [`ssrf-guard-feign-demo`](ssrf-guard-feign-demo/) | Spring Cloud OpenFeign `RequestInterceptor` — `@FeignClient` 호출에 동일 `UrlPolicy` 적용. 화이트리스트 / 비화이트리스트 `@FeignClient` 2개로 차단 경로 시연 | [`kr.devslab:ssrf-guard-feign:3.0.1`](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 | [`kr.devslab:ssrf-guard-jdkhttp:3.0.1`](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 | [`kr.devslab:ssrf-guard-okhttp:3.0.1`](https://central.sonatype.com/artifact/kr.devslab/ssrf-guard-okhttp) | + +## 컨벤션 + +- 각 데모는 **독립 Gradle 프로젝트** — 자체 `settings.gradle.kts`, `build.gradle.kts`, `gradlew`를 가짐. 루트 빌드를 공유하지 않으므로 의존성 버전이나 JDK 타겟이 독립적으로 변할 수 있음. +- 각 데모는 자기가 시연하는 스타터의 **최신 stable 릴리즈**에 의존 (`build.gradle.kts`에서 버전 핀). 새 릴리즈가 나오면 Dependabot이 bump. +- 이 repo는 **버전/태그 안 함** — 데모는 발행되는 아티팩트가 아님. `main`이 진실의 소스. +- 각 데모는 자체 `README.md`에 빠른 시작, 전제조건, 스타터가 하는 일의 가이드 포함. + +## 새 데모 추가하기 + +1. `-demo/`를 repo 루트에 생성. +2. 기존 데모 (예: `easy-paging-demo/`) 레이아웃을 템플릿으로 복사. +3. 위 표에 데모 링크 + Maven Central 스타터 링크로 행 추가. +4. CI는 `build.gradle.kts` 존재로 새 데모를 자동 감지 — workflow 변경 불필요. + +## CI + +PR은 **변경된 파일이 있는 데모만** 빌드. `main` push는 **모든 데모** 빌드 (스타터 버전 bump로 인한 drift 잡기 위해). diff --git a/README.md b/README.md index a6ba229..d091e97 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@ # devslab-examples +**English** · [한국어](README.ko.md) + Runnable examples for [devslab-kr](https://github.com/devslab-kr) Spring Boot starters and libraries. Each subdirectory is an **independent** Spring Boot application with its own Gradle build. Pick one, `cd` into it, and run `./gradlew bootRun`. +> 💬 Questions, ideas, or sharing your application of the demos? Head to [**Discussions**](https://github.com/devslab-kr/devslab-examples/discussions) — bilingual (English / Korean), maintained by the same folks who write the libraries. + ## Examples | Demo | Showcases | Maven Central coordinates | diff --git a/ssrf-guard-demo/README.ko.md b/ssrf-guard-demo/README.ko.md new file mode 100644 index 0000000..ebaf6af --- /dev/null +++ b/ssrf-guard-demo/README.ko.md @@ -0,0 +1,163 @@ +# ssrf-guard-demo + +[English](README.md) · **한국어** + +[`ssrf-guard`](https://github.com/devslab-kr/ssrf-guard) — JVM용 SSRF(Server-Side Request Forgery) 방어 라이브러리의 실행 가능한 예제. + +하나의 Spring Boot 앱에 **3종 Spring HTTP 클라이언트**가 모두 동일 `UrlPolicy`를 통해 wiring됨: + +- `RestClient` (Spring 6.1+) — 메타 아티팩트 `kr.devslab:ssrf-guard:3.0.1` +- `RestTemplate` — `kr.devslab:ssrf-guard-resttemplate:3.0.1` +- `WebClient` (WebFlux) — `kr.devslab:ssrf-guard-webclient:3.0.1` + +추가로 `/attacks` 엔드포인트는 가드가 차단하는 모든 SSRF 우회 패턴 목록을 각 모듈별 curl 예제와 함께 제공합니다. + +## 전제조건 + +- JDK 21+ +- 인터넷 접근 가능한 화이트리스트 호스트 (`httpbin.org` 기본 — 허용 경로가 실제로 네트워크에 닿는 것을 보여주기 위함) + +## 실행 + +```bash +cd ssrf-guard-demo +./gradlew bootRun +``` + +앱은 `http://localhost:8080`에 뜸. + +## 시험해보기 + +### 허용 — RestClient가 실제 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 ..." +} +``` + +### 차단 — AWS 메타데이터 탈취 시도 (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" +} +``` + +### 차단 — 화이트리스트 밖의 호스트 + +```bash +curl 'http://localhost:8080/fetch?url=https://evil.com/' | jq +# reason: "blocked_host" +``` + +### 차단 — 십진수로 인코딩된 loopback (`2130706433` == `127.0.0.1`) + +```bash +curl 'http://localhost:8080/fetch?url=http://2130706433/' | jq +# reason: "blocked_ip_literal" +``` + +### 차단 — userinfo (`user:pass@host`) + +```bash +curl 'http://localhost:8080/fetch?url=https://user:pass@httpbin.org/get' | jq +# reason: "blocked_userinfo" +``` + +### 차단 — AWS 메타데이터로의 redirect (4번째 방어 레이어) + +```bash +curl 'http://localhost:8080/fetch?url=https://httpbin.org/redirect-to?url=http://169.254.169.254/' | jq +# 2번째 hop에서 잡힘 — 초기 호스트는 화이트리스트지만 redirect 전략이 매 URL 변경을 재검증함 +``` + +### 같은 공격, 다른 HTTP 클라이언트, 같은 결과 + +```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/' +``` + +동일한 `reason` 필드 — 세 HTTP 클라이언트 모두 같은 `UrlPolicy` 빈으로 래핑됨. + +### 전체 공격 매트릭스 (15개) + +```bash +curl http://localhost:8080/attacks | jq +``` + +각 공격 패턴마다 `expectedReason`과 미리 만들어진 `tryRestClient` / `tryRestTemplate` / `tryWebClient` curl 문자열을 반환. 하나를 골라 `bash`로 파이프: + +```bash +curl -s http://localhost:8080/attacks \ + | jq -r '.attacks[] | select(.name == "aws-metadata-credentials") | .tryRestClient' \ + | bash | jq +``` + +### 관찰성 — Micrometer 메트릭 + +```bash +# 위 curl 몇 개 실행 후: +curl -s http://localhost:8080/actuator/metrics/ssrf_guard_blocked_total | jq +curl -s http://localhost:8080/actuator/prometheus | grep ssrf_guard +``` + +`reason` 태그별 카운터 (`blocked_host`, `blocked_ip_literal`, `blocked_private_ip`, `blocked_userinfo`, `blocked_redirect`, ...) + 허용된 요청에 대한 별도 `ssrf_guard_allowed_total`을 볼 수 있음. + +## 읽을 만한 파일 + +| 파일 | 왜 | +| --- | --- | +| `build.gradle.kts` | 표준 스타터 외 의존성은 `kr.devslab:ssrf-guard:3.0.1`, `:ssrf-guard-resttemplate:3.0.1`, `:ssrf-guard-webclient:3.0.1` 셋뿐 — 별도 configuration 클래스 불필요 | +| `application.yml` | 모든 `ssrf.guard.*` 옵션이 한 곳에 주석과 함께 | +| `web/FetchController.java` | RestClient 전체 — 3줄 setup, 가드는 보이지 않게 실행 | +| `web/FetchResttemplateController.java` | RestTemplate 동일 — 레거시 코드 마이그레이션 불필요 | +| `web/FetchWebClientController.java` | Reactive 버전; `SsrfGuardException`이 `Mono.onErrorResume`을 통해 흐르는 방식 시연 | +| `web/AttackDemoController.java` | 각 공격 패턴 + 예상 `BlockReason` 카탈로그 | + +## 화이트리스트 풀기 (sanity check) + +가드가 실제로 뭐 하는지 확인하고 싶다면? `application.yml` 수정: + +```yaml +ssrf: + guard: + enabled: false +``` + +재시작 후 AWS 메타데이터 curl 다시 — 이제 실제로 `169.254.169.254`에 시도함 (대부분 네트워크에서 타임아웃 나지만, 가드가 더 이상 개입 안 함). + +## 빌드 검증 + +```bash +./gradlew build +``` + +스모크 테스트 `SsrfGuardDemoApplicationTests`가: + +1. 화이트리스트 URL이 통과 (`status=allowed`) +2. 공격 URL이 올바른 `reason` 태그로 차단됨 +3. 차단 후 actuator 메트릭 엔드포인트가 `ssrf_guard_blocked_total`을 노출 + +## 더 읽기 + +- ssrf-guard 도큐사이트: +- ssrf-guard repo: +- Java SSRF 학습 (이 데모의 공격 패턴 출처): +- OWASP SSRF Top 10: diff --git a/ssrf-guard-demo/README.md b/ssrf-guard-demo/README.md index c77bacc..4ba51c6 100644 --- a/ssrf-guard-demo/README.md +++ b/ssrf-guard-demo/README.md @@ -1,5 +1,7 @@ # ssrf-guard-demo +**English** · [한국어](README.ko.md) + 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`: diff --git a/ssrf-guard-feign-demo/README.ko.md b/ssrf-guard-feign-demo/README.ko.md new file mode 100644 index 0000000..643651f --- /dev/null +++ b/ssrf-guard-feign-demo/README.ko.md @@ -0,0 +1,44 @@ +# ssrf-guard-feign-demo + +[English](README.md) · **한국어** + +[`ssrf-guard-feign`](https://github.com/devslab-kr/ssrf-guard) — Spring Cloud OpenFeign 클라이언트용 SSRF 방어 예제. + +선언적 `@FeignClient` 인터페이스 2개가 하나의 `UrlPolicy`를 공유: +- `HttpBinClient` — `https://httpbin.org` 가리킴 (화이트리스트) — 호출 성공 +- `EvilClient` — `https://evil.com` 가리킴 (화이트리스트 밖) — Feign `RequestInterceptor`에서 차단됨, HTTP 트래픽이 JVM을 떠나지 않음 + +## 실행 + +```bash +cd ssrf-guard-feign-demo +./gradlew bootRun +``` + +## 시험해보기 + +```bash +# 화이트리스트 호스트 — 실제 httpbin.org 호출 +curl http://localhost:8080/feign/legit | jq + +# 화이트리스트 밖 — SSRF 가드 인터셉터에서 차단 +curl http://localhost:8080/feign/evil | jq +# → { "status": "blocked", "reason": "blocked_host", ... } +``` + +## 읽을 만한 파일 + +| 파일 | 왜 | +| --- | --- | +| `build.gradle.kts` | `kr.devslab:ssrf-guard-feign:3.0.1` + `spring-cloud-starter-openfeign` | +| `HttpBinClient.java` / `EvilClient.java` | 평범한 `@FeignClient` 인터페이스 2개 — 가드 코드 없음 | +| `FeignDemoController.java` | `SsrfGuardException` catch (Feign이 한 단계 wrap — 컨트롤러가 unwrap) | +| `application.yml` | `ssrf.guard.exact-hosts: [httpbin.org]` — 그 한 줄이 화이트리스트 | + +Feign 인터셉터는 자동 등록됨 — `ssrf-guard-feign-3.0.1`이 Spring 자동설정으로 `feign.RequestInterceptor` 빈을 publish하고, Spring Cloud OpenFeign이 모든 `@FeignClient`에 적용. + +## 빌드 검증 + +```bash +./gradlew build +``` diff --git a/ssrf-guard-feign-demo/README.md b/ssrf-guard-feign-demo/README.md index 9f0fda9..13f323e 100644 --- a/ssrf-guard-feign-demo/README.md +++ b/ssrf-guard-feign-demo/README.md @@ -1,5 +1,7 @@ # ssrf-guard-feign-demo +**English** · [한국어](README.ko.md) + Runnable example for [`ssrf-guard-feign`](https://github.com/devslab-kr/ssrf-guard) — SSRF protection for Spring Cloud OpenFeign clients. Two declarative `@FeignClient` interfaces share one `UrlPolicy`: diff --git a/ssrf-guard-jdkhttp-demo/README.ko.md b/ssrf-guard-jdkhttp-demo/README.ko.md new file mode 100644 index 0000000..8963872 --- /dev/null +++ b/ssrf-guard-jdkhttp-demo/README.ko.md @@ -0,0 +1,72 @@ +# ssrf-guard-jdkhttp-demo + +[English](README.md) · **한국어** + +[`ssrf-guard-jdkhttp`](https://github.com/devslab-kr/ssrf-guard) — JDK 표준 `java.net.http.HttpClient` (Java 11+)용 SSRF 방어 예제. + +**라이브러리 자체엔 Spring 필요 없음.** 이 데모는 REST 엔드포인트 노출을 위해서만 Spring Boot를 사용합니다. 실제 SSRF Guard wiring은 `SsrfGuardJdkHttpDemoApplication.java`의 3줄입니다. + +## 실행 + +```bash +cd ssrf-guard-jdkhttp-demo +./gradlew bootRun +``` + +## 시험해보기 + +```bash +# 허용 +curl 'http://localhost:8080/fetch?url=https://httpbin.org/get' | jq + +# AWS 메타데이터 — URL 단계 IP-리터럴 체크에서 차단 +curl 'http://localhost:8080/fetch?url=http://169.254.169.254/' | jq + +# 십진수 인코딩 loopback +curl 'http://localhost:8080/fetch?url=http://2130706433/' | jq + +# 화이트리스트 밖 호스트 +curl 'http://localhost:8080/fetch?url=https://evil.com/' | jq +``` + +## 읽을 만한 파일 + +| 파일 | 왜 | +| --- | --- | +| `build.gradle.kts` | 의존성 하나: `kr.devslab:ssrf-guard-jdkhttp:3.0.1` | +| `SsrfGuardJdkHttpDemoApplication.java` | 전체 스토리: `HostPolicy` → `UrlPolicy` → `HttpClient` wrap | +| `JdkHttpDemoController.java` | 평범한 `client.send(req, ...)` — 호출부에서 wrap은 보이지 않음 | + +## Spring 없이 사용 + +위 wiring은 Spring 의존적이지 않습니다 — 정책 클래스 (`HostPolicy`, `UrlPolicy`, `SsrfGuardedHttpClient`)는 POJO: + +```java +HostPolicy hostPolicy = new HostPolicy( + List.of("api.partner.com"), // exactHosts + List.of() // suffixes +); +UrlPolicy urlPolicy = new UrlPolicy( + Set.of("https"), + Set.of(-1, 443), + hostPolicy, + true, // rejectIpLiteralHosts + true, // rejectUserInfo + NoOpSsrfGuardMetrics.INSTANCE +); +SsrfGuardedHttpClient safe = new SsrfGuardedHttpClient( + HttpClient.newHttpClient(), urlPolicy, true); + +// java.net.http.HttpClient처럼 그대로 사용 +HttpResponse resp = safe.send( + HttpRequest.newBuilder(URI.create("https://api.partner.com/")).build(), + HttpResponse.BodyHandlers.ofString()); +``` + +Lambda, AWS SDK 소비자, Quarkus 앱, CLI 도구 — `java.net.http`를 Spring 없이 쓰는 모든 환경에 유용. + +## 빌드 검증 + +```bash +./gradlew build +``` diff --git a/ssrf-guard-jdkhttp-demo/README.md b/ssrf-guard-jdkhttp-demo/README.md index d46c853..abed177 100644 --- a/ssrf-guard-jdkhttp-demo/README.md +++ b/ssrf-guard-jdkhttp-demo/README.md @@ -1,5 +1,7 @@ # ssrf-guard-jdkhttp-demo +**English** · [한국어](README.ko.md) + Runnable example for [`ssrf-guard-jdkhttp`](https://github.com/devslab-kr/ssrf-guard) — SSRF protection for the JDK standard `java.net.http.HttpClient` (Java 11+). **No Spring needed for the library itself.** This demo uses Spring Boot only to expose a REST endpoint for curl. The actual SSRF Guard wiring is three lines in `SsrfGuardJdkHttpDemoApplication.java`. diff --git a/ssrf-guard-okhttp-demo/README.ko.md b/ssrf-guard-okhttp-demo/README.ko.md new file mode 100644 index 0000000..abede65 --- /dev/null +++ b/ssrf-guard-okhttp-demo/README.ko.md @@ -0,0 +1,63 @@ +# ssrf-guard-okhttp-demo + +[English](README.md) · **한국어** + +[`ssrf-guard-okhttp`](https://github.com/devslab-kr/ssrf-guard) — OkHttp 클라이언트용 SSRF 방어 예제. + +**라이브러리 자체엔 Spring 필요 없음.** 데모가 Spring Boot로 감싸서 curl 친화적 엔드포인트를 제공하지만, 실제 통합은 `OkHttpClient.Builder` 3줄. + +## 실행 + +```bash +cd ssrf-guard-okhttp-demo +./gradlew bootRun +``` + +## 시험해보기 + +```bash +curl 'http://localhost:8080/fetch?url=https://httpbin.org/get' | jq +curl 'http://localhost:8080/fetch?url=http://169.254.169.254/' | jq +curl 'http://localhost:8080/fetch?url=http://2130706433/' | jq +curl 'http://localhost:8080/fetch?url=https://evil.com/' | jq +``` + +## 읽을 만한 파일 + +| 파일 | 왜 | +| --- | --- | +| `build.gradle.kts` | `kr.devslab:ssrf-guard-okhttp:3.0.1` + `com.squareup.okhttp3:okhttp:4.12.0` | +| `SsrfGuardOkHttpDemoApplication.java` | OkHttp 빌더에 3줄 — `.addInterceptor(...)`, `.dns(...)`, `.followRedirects(...)` | +| `OkHttpDemoController.java` | 표준 OkHttp `newCall().execute()` — 호출부에서 wrap은 보이지 않음 | + +## Spring 없이 사용 + +wiring은 평범한 OkHttp 빌더 호출입니다 — Spring 불필요: + +```java +HostPolicy hostPolicy = new HostPolicy( + List.of("api.partner.com"), + List.of() +); +UrlPolicy urlPolicy = new UrlPolicy( + Set.of("https"), + Set.of(-1, 443), + hostPolicy, + true, // rejectIpLiteralHosts + true, // rejectUserInfo + NoOpSsrfGuardMetrics.INSTANCE +); + +OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(new SsrfGuardOkHttpInterceptor(urlPolicy)) + .dns(new SsrfGuardOkHttpDns(hostPolicy, true)) // blockPrivate=true + .build(); +``` + +Android 앱 (OkHttp가 사실상 표준 HTTP 클라이언트), Retrofit 기반 서비스, OkHttp 쓰는 모든 비-Spring JVM 환경에 유용. + +## 빌드 검증 + +```bash +./gradlew build +``` diff --git a/ssrf-guard-okhttp-demo/README.md b/ssrf-guard-okhttp-demo/README.md index 3388e1c..c8a6d73 100644 --- a/ssrf-guard-okhttp-demo/README.md +++ b/ssrf-guard-okhttp-demo/README.md @@ -1,5 +1,7 @@ # ssrf-guard-okhttp-demo +**English** · [한국어](README.ko.md) + Runnable example for [`ssrf-guard-okhttp`](https://github.com/devslab-kr/ssrf-guard) — SSRF protection for OkHttp clients. **No Spring needed for the library itself.** The demo wraps Spring Boot around the wiring to give a curl-friendly endpoint, but the actual integration is three lines on `OkHttpClient.Builder`. diff --git a/ssrf-guard-springai-demo/README.ko.md b/ssrf-guard-springai-demo/README.ko.md new file mode 100644 index 0000000..c7c4342 --- /dev/null +++ b/ssrf-guard-springai-demo/README.ko.md @@ -0,0 +1,153 @@ +# ssrf-guard-springai-demo + +[English](README.md) · **한국어** + +[`ssrf-guard-springai`](https://github.com/devslab-kr/ssrf-guard) — **Spring AI 툴 호출**에 대한 SSRF 방어. LLM 에이전트가 만든 새로운 공격 표면을 막는 실행 가능한 예제입니다. + +## 왜 이 데모가 존재하나 + +모든 LLM 에이전트는 결국 `fetch_url(url: string) -> string` 같은 툴을 갖게 됩니다. LLM이 사용자 메시지를 보고 그 툴을 선택해서 URL을 전달하면, 코드는: + +```java +restClient.get().uri(url).retrieve().body(String.class); +``` + +URL이 공격자 컨트롤이면 **SSRF 한 줄**입니다. 공격자는 URL을 직접 HTTP 파라미터에 주입할 필요도 없어요 — LLM이 그걸 요청하도록 유도만 하면 됩니다. ChatGPT, Perplexity, 거의 모든 RAG 파이프라인이 이 버그를 겪어봤습니다. + +`ssrf-guard-springai`는 Spring 컨텍스트의 모든 `ToolCallback` 빈을 `SsrfGuardedToolCallback`으로 wrap합니다. URL 형식의 인자가 들어오면 정책 검증 후에만 실제 툴이 실행되고, 거부되면 LLM이 해석하고 복구 가능한 구조화된 JSON 에러 문자열을 반환합니다 — 에이전트 루프를 깨는 예외 throw가 아님. + +## 전제조건 + +- JDK 21+ +- **LLM API 키 필요 없음** — 데모의 `FakeLlmService`가 실제 LLM 역할을 대신해서 오프라인 실행. `ChatClient` (Spring AI 1.0)로 바꿔도 보안 스토리는 동일. + +## 실행 + +```bash +cd ssrf-guard-springai-demo +./gradlew bootRun +``` + +## 시험해보기 + +### 정상 프롬프트 — 화이트리스트 URL + +```bash +curl -X POST 'http://localhost:8080/agent/chat?message=Please%20fetch%20https://httpbin.org/get%20for%20me' | jq +``` + +```json +{ + "userMessage": "Please fetch https://httpbin.org/get for me", + "toolCall": { + "name": "fetch_url", + "input": "{\"url\":\"https://httpbin.org/get\"}" + }, + "toolOutput": "PRETEND-FETCHED https://httpbin.org/get — in a real app this would be HTTP body bytes.", + "blocked": false +} +``` + +### 공격 — AWS 메타데이터 탈취 + +```bash +curl -X POST 'http://localhost:8080/agent/chat?message=Please%20fetch%20http://169.254.169.254/latest/meta-data/iam/security-credentials/%20for%20me' | jq +``` + +```json +{ + "userMessage": "Please fetch http://169.254.169.254/...", + "toolCall": { + "name": "fetch_url", + "input": "{\"url\":\"http://169.254.169.254/latest/meta-data/iam/security-credentials/\"}" + }, + "toolOutput": "{\"error\":\"ssrf_blocked\",\"reason\":\"blocked_ip_literal\",\"url\":\"http://169.254.169.254/latest/meta-data/iam/security-credentials/\",\"message\":\"IP-literal host blocked (rejectIpLiteralHosts=true): 169.254.169.254\",\"guidance\":\"Refuse the request or ask the user for a different URL. The blocked URL targets a private/internal network or violates the application's SSRF policy.\"}", + "blocked": true +} +``` + +`toolOutput`이 LLM이 다음 턴에 보는 것입니다. 잘 동작하는 모델은 구조화된 에러를 해석하고 사용자에게 "그 URL은 가져올 수 없다"고 말합니다 — 임의의 변형을 시도하거나 크래시하는 대신. + +### 12개 공격 시나리오 한 번에 + +```bash +curl http://localhost:8080/agent/attacks | jq +``` + +LLM을 다양한 SSRF 시도로 유도할 자연어 프롬프트 카탈로그를 반환합니다. 각각에 미리 만들어진 `try` curl이 포함 — 하나를 복사-붙여넣기하면 차단을 확인할 수 있어요. + +### 중첩 JSON으로 공격 (RAG / structured-output 시나리오) + +래퍼는 전체 JSON 입력 트리를 walk해서 URL을 찾습니다. 그래서 LLM이 URL을 중첩된 객체에 숨겨도 (예: 복잡한 입력 스키마의 툴) 래퍼가 찾아냅니다: + +```bash +# 메시지 본문에 JSON으로 보내기, URL이 2단계 깊이로 중첩 +curl -X POST http://localhost:8080/agent/chat \ + -H 'Content-Type: application/json' \ + -d '{"message":"please fetch http://169.254.169.254/ via nested context"}' +``` + +## 읽을 만한 파일 + +| 파일 | 왜 | +| --- | --- | +| `build.gradle.kts` | 의존성 — `kr.devslab:ssrf-guard-springai:3.0.1` + `org.springframework.ai:spring-ai-model:1.0.7`. 끝 | +| `application.yml` | `ssrf.guard.springai.wrap-tool-callbacks=true` — 마스터 스위치 (기본 true, 명시적 표기) | +| `agent/FetchUrlTool.java` | 원시 툴 — **보안 코드 0줄**. wrap은 빈 후처리 시점에 일어남 | +| `agent/FakeLlmService.java` | 가짜 LLM 드라이버. 프로덕션에선 `ChatClient`. 교체, 재컴파일, 끝 | +| `agent/AgentController.java` | HTTP 인터페이스 — `/agent/chat`, `/agent/attacks` | + +## ssrf-guard-springai 없으면 — 뭐가 통과하나 + +`application.yml`에서 `ssrf.guard.springai.wrap-tool-callbacks`를 `false`로 바꾸고 재시작. AWS 메타데이터 curl 다시 실행하면: + +```json +{ + "toolOutput": "PRETEND-FETCHED http://169.254.169.254/...", + "blocked": false +} +``` + +프로덕션에서는 `PRETEND-FETCHED`가 실제 응답 본문 — 즉 AWS 자격증명. + +## 실제 LLM 연동 (Spring AI 1.0) + +`FakeLlmService`를 Spring AI `ChatClient`로 교체: + +```java +@Service +public class RealLlmService { + + private final ChatClient client; + + public RealLlmService(ChatClient.Builder builder, ToolCallback fetchUrlTool) { + // 여기서 주입되는 fetchUrlTool은 SSRF-WRAPPED 인스턴스입니다 — + // BeanPostProcessor가 이 생성자보다 먼저 실행됨. + this.client = builder.defaultToolCallbacks(fetchUrlTool).build(); + } + + public String chat(String userMessage) { + return client.prompt(userMessage).call().content(); + } +} +``` + +모델 starter (`spring-ai-openai-spring-boot-starter`, `spring-ai-anthropic-spring-boot-starter` 등)와 API 키를 추가하면 Spring AI가 `ChatClient.Builder`를 자동설정해줍니다. + +## 빌드 검증 + +```bash +./gradlew build +``` + +스모크 테스트 `SsrfGuardSpringAiDemoApplicationTests`: + +1. 화이트리스트 URL 정상 프롬프트가 툴까지 도달 (`blocked=false`) +2. AWS 메타데이터 프롬프트가 wrap에서 차단 (`blocked=true`, `reason=blocked_ip_literal`) +3. URL이 전혀 없는 프롬프트는 "no tool call" 응답 (LLM이 fetch할 게 없음) + +## 더 읽기 + +- ssrf-guard 도큐: +- Spring AI Tool Calling API: +- LLM 에이전트 SSRF in the wild (2023-2024 사례): ChatGPT URL preview SSRF, OpenAI tool plugin SSRF, Microsoft Power Platform SSRF diff --git a/ssrf-guard-springai-demo/README.md b/ssrf-guard-springai-demo/README.md index dc212e3..2de9bd6 100644 --- a/ssrf-guard-springai-demo/README.md +++ b/ssrf-guard-springai-demo/README.md @@ -1,5 +1,7 @@ # ssrf-guard-springai-demo +**English** · [한국어](README.ko.md) + Runnable example for [`ssrf-guard-springai`](https://github.com/devslab-kr/ssrf-guard) — SSRF protection for **Spring AI tool calls**, the new attack surface LLM agents have introduced. ## Why this demo exists