diff --git a/README.ko.md b/README.ko.md index 08c6a77..8ac28fa 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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) | ## 컨벤션 diff --git a/README.md b/README.md index c6077fc..7a218eb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ssrf-guard-httpclient5-demo/README.ko.md b/ssrf-guard-httpclient5-demo/README.ko.md new file mode 100644 index 0000000..625b7a5 --- /dev/null +++ b/ssrf-guard-httpclient5-demo/README.ko.md @@ -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: "* — `SafeDnsResolver`가 `InetAddress.getAllByName` 호출하기도 전에 거부. +- *"No allowed IP after filtering: "* — 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 게이트에서 차단됨을 검증 — 어떤 소켓도 열리기 전에. diff --git a/ssrf-guard-httpclient5-demo/README.md b/ssrf-guard-httpclient5-demo/README.md new file mode 100644 index 0000000..1104237 --- /dev/null +++ b/ssrf-guard-httpclient5-demo/README.md @@ -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: "* — `SafeDnsResolver` refused before even calling `InetAddress.getAllByName`. +- *"No allowed IP after filtering: "* — 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. diff --git a/ssrf-guard-httpclient5-demo/build.gradle.kts b/ssrf-guard-httpclient5-demo/build.gradle.kts new file mode 100644 index 0000000..3515281 --- /dev/null +++ b/ssrf-guard-httpclient5-demo/build.gradle.kts @@ -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") { + useJUnitPlatform() +} diff --git a/ssrf-guard-httpclient5-demo/gradle.properties b/ssrf-guard-httpclient5-demo/gradle.properties new file mode 100644 index 0000000..5ec0077 --- /dev/null +++ b/ssrf-guard-httpclient5-demo/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true diff --git a/ssrf-guard-httpclient5-demo/gradle/wrapper/gradle-wrapper.jar b/ssrf-guard-httpclient5-demo/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/ssrf-guard-httpclient5-demo/gradle/wrapper/gradle-wrapper.jar differ diff --git a/ssrf-guard-httpclient5-demo/gradle/wrapper/gradle-wrapper.properties b/ssrf-guard-httpclient5-demo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/ssrf-guard-httpclient5-demo/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/ssrf-guard-httpclient5-demo/gradlew b/ssrf-guard-httpclient5-demo/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/ssrf-guard-httpclient5-demo/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/ssrf-guard-httpclient5-demo/gradlew.bat b/ssrf-guard-httpclient5-demo/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/ssrf-guard-httpclient5-demo/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ssrf-guard-httpclient5-demo/settings.gradle.kts b/ssrf-guard-httpclient5-demo/settings.gradle.kts new file mode 100644 index 0000000..b48d37a --- /dev/null +++ b/ssrf-guard-httpclient5-demo/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "ssrf-guard-httpclient5-demo" diff --git a/ssrf-guard-httpclient5-demo/src/main/java/kr/devslab/examples/ssrfguardhttpclient5/HttpClient5DemoController.java b/ssrf-guard-httpclient5-demo/src/main/java/kr/devslab/examples/ssrfguardhttpclient5/HttpClient5DemoController.java new file mode 100644 index 0000000..6f90c1a --- /dev/null +++ b/ssrf-guard-httpclient5-demo/src/main/java/kr/devslab/examples/ssrfguardhttpclient5/HttpClient5DemoController.java @@ -0,0 +1,96 @@ +package kr.devslab.examples.ssrfguardhttpclient5; + +import kr.devslab.ssrfguard.core.SsrfGuardException; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.net.UnknownHostException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + *
+ *   curl 'http://localhost:8080/fetch?url=https://httpbin.org/get'
+ *   curl 'http://localhost:8080/fetch?url=http://169.254.169.254/'
+ *   curl 'http://localhost:8080/fetch?url=http://2130706433/'
+ *   curl 'http://localhost:8080/fetch?url=https://evil.com/'
+ * 
+ * + *

The injected {@link CloseableHttpClient} is the SSRF-guarded one created + * by {@code SsrfGuardHttpClient5AutoConfiguration}. The controller code below + * doesn't reference the guard at all — call sites stay clean. + */ +@RestController +public class HttpClient5DemoController { + + private final CloseableHttpClient client; + + public HttpClient5DemoController(CloseableHttpClient client) { + this.client = client; + } + + @GetMapping("/fetch") + public Map fetch(@RequestParam String url) { + Map response = new LinkedHashMap<>(); + response.put("client", "Apache HttpClient 5"); + response.put("url", url); + + HttpGet get = new HttpGet(url); + try { + return client.execute(get, http -> { + response.put("status", "allowed"); + response.put("httpStatus", http.getCode()); + HttpEntity entity = http.getEntity(); + String body = entity == null ? null : EntityUtils.toString(entity); + if (body != null) { + response.put("bodyPreview", + body.length() <= 200 ? body : body.substring(0, 200) + "..."); + } + return response; + }); + } catch (SsrfGuardException e) { + // Rare — SafeRedirectStrategy throws RedirectException, which + // HttpClient wraps. The interceptor in other demos throws + // SsrfGuardException directly; here SafeDnsResolver throws + // UnknownHostException (see below). Kept for symmetry. + response.put("status", "blocked"); + response.put("reason", e.reason().label()); + response.put("message", e.getMessage()); + return response; + } catch (UnknownHostException e) { + // The DNS-time gate's failure shape: when SafeDnsResolver + // refuses to resolve (host not in whitelist, or all IPs + // private after filtering), HttpClient surfaces it as + // UnknownHostException. The message tells you which gate + // fired ("Host not in whitelist" / "No allowed IP after + // filtering"). + response.put("status", "blocked"); + response.put("reason", "blocked_dns"); + response.put("message", e.getMessage()); + return response; + } catch (Exception e) { + // Walk the cause chain — HttpClient sometimes wraps the + // RedirectException (from SafeRedirectStrategy) further up. + Throwable root = e; + while (root != null) { + if (root instanceof SsrfGuardException sg) { + response.put("status", "blocked"); + response.put("reason", sg.reason().label()); + response.put("message", sg.getMessage()); + return response; + } + if (root.getCause() == root) break; + root = root.getCause(); + } + response.put("status", "error"); + response.put("error", e.getClass().getSimpleName()); + response.put("message", String.valueOf(e.getMessage())); + return response; + } + } +} diff --git a/ssrf-guard-httpclient5-demo/src/main/java/kr/devslab/examples/ssrfguardhttpclient5/SsrfGuardHttpClient5DemoApplication.java b/ssrf-guard-httpclient5-demo/src/main/java/kr/devslab/examples/ssrfguardhttpclient5/SsrfGuardHttpClient5DemoApplication.java new file mode 100644 index 0000000..441c8c0 --- /dev/null +++ b/ssrf-guard-httpclient5-demo/src/main/java/kr/devslab/examples/ssrfguardhttpclient5/SsrfGuardHttpClient5DemoApplication.java @@ -0,0 +1,37 @@ +package kr.devslab.examples.ssrfguardhttpclient5; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Demo of {@code ssrf-guard-httpclient5}. Apache HttpClient 5 plugs into the + * SSRF defenses at DNS-resolution time (not URL-parse time), which is a + * different shape from the OkHttp / RestClient demos: + * + *

    + *
  • {@link kr.devslab.ssrfguard.httpclient5.SafeDnsResolver} replaces the + * default {@code DnsResolver} on the connection manager. Before any + * socket opens, it (a) rejects hosts not in the whitelist, and (b) + * filters out private / loopback / link-local / cloud-metadata IPs. + * The {@code InetAddress[]} it returns is the same array HttpClient + * hands to {@code Socket.connect}, closing the TOCTOU window.
  • + *
  • {@link kr.devslab.ssrfguard.httpclient5.SafeRedirectStrategy} runs the + * same policy on every redirect hop — scheme check + DNS gate.
  • + *
+ * + *

Everything below is auto-wired by {@code SsrfGuardHttpClient5AutoConfiguration} + * from the {@code ssrf-guard-httpclient5} module — when this Spring Boot app + * starts, a guarded {@link org.apache.hc.client5.http.impl.classic.CloseableHttpClient} + * appears in the context. The controller just injects it. No wiring code in + * this demo's main(). + * + *

See the README for the equivalent non-Spring wiring (5 lines on + * {@code HttpClients.custom()}). + */ +@SpringBootApplication +public class SsrfGuardHttpClient5DemoApplication { + + public static void main(String[] args) { + SpringApplication.run(SsrfGuardHttpClient5DemoApplication.class, args); + } +} diff --git a/ssrf-guard-httpclient5-demo/src/main/resources/application.yml b/ssrf-guard-httpclient5-demo/src/main/resources/application.yml new file mode 100644 index 0000000..d2495fb --- /dev/null +++ b/ssrf-guard-httpclient5-demo/src/main/resources/application.yml @@ -0,0 +1,34 @@ +# SSRF Guard configuration — read by ssrf-guard-httpclient5's autoconfig. +# The same `ssrf.guard.*` keys work across every ssrf-guard module. + +spring: + application: + name: ssrf-guard-httpclient5-demo + +ssrf: + guard: + enabled: true + + # Whitelist — only hosts in this list will resolve through SafeDnsResolver. + exact-hosts: + - httpbin.org + + # When true, any IP that lands in a private / loopback / link-local / + # cloud-metadata range after DNS resolution is filtered out. The + # SafeDnsResolver does the filtering before SocketChannel.connect() — + # this is the gate that catches obfuscated IP literals like + # http://2130706433/ (= 127.0.0.1 decimal) and http://169.254.169.254/ + # (AWS metadata). + block-private-networks: true + + # When true, redirects are followed but each hop goes through the + # SafeRedirectStrategy: scheme check + DNS gate re-validation. Set to + # false to refuse all redirects. + follow-redirects: true + + # The schemes a redirect hop may use. http and https only by default + # — file:// / gopher:// / ftp:// stay blocked even if a redirect + # target tries to use them. + allowed-schemes: + - http + - https diff --git a/ssrf-guard-httpclient5-demo/src/test/java/kr/devslab/examples/ssrfguardhttpclient5/SsrfGuardHttpClient5DemoApplicationTests.java b/ssrf-guard-httpclient5-demo/src/test/java/kr/devslab/examples/ssrfguardhttpclient5/SsrfGuardHttpClient5DemoApplicationTests.java new file mode 100644 index 0000000..2d07267 --- /dev/null +++ b/ssrf-guard-httpclient5-demo/src/test/java/kr/devslab/examples/ssrfguardhttpclient5/SsrfGuardHttpClient5DemoApplicationTests.java @@ -0,0 +1,60 @@ +package kr.devslab.examples.ssrfguardhttpclient5; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Smoke tests for the Apache HttpClient 5 demo. The point isn't to verify + * Apache HttpClient — it's to verify that ssrf-guard-httpclient5's autoconfig + * correctly fires {@code SafeDnsResolver} on URLs that ought to be blocked. + * + *

None of these tests reach the network: the blocked tests bail out at + * the DNS gate (`SafeDnsResolver` throws `UnknownHostException` before any + * socket would open), and we don't exercise the allowed path against a real + * external host. + */ +@SpringBootTest +@AutoConfigureMockMvc +class SsrfGuardHttpClient5DemoApplicationTests { + + @Autowired + private MockMvc mockMvc; + + @Test + void awsMetadataAddressIsBlockedAtDnsGate() throws Exception { + // 169.254.169.254 is a literal IP — SafeDnsResolver resolves it to + // itself, then the private-network filter rejects it (link-local). + mockMvc.perform(get("/fetch").param("url", "http://169.254.169.254/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("blocked")) + .andExpect(jsonPath("$.reason").value("blocked_dns")); + } + + @Test + void decimalEncodedLoopbackIsBlockedAtDnsGate() throws Exception { + // 2130706433 decodes to 127.0.0.1 — the private-IP filter catches it. + mockMvc.perform(get("/fetch").param("url", "http://2130706433/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("blocked")) + .andExpect(jsonPath("$.reason").value("blocked_dns")); + } + + @Test + void hostNotInWhitelistIsBlockedAtDnsGate() throws Exception { + // SafeDnsResolver rejects before even consulting DNS — the message + // says "Host not in whitelist". + mockMvc.perform(get("/fetch").param("url", "https://evil.com/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("blocked")) + .andExpect(jsonPath("$.reason").value("blocked_dns")) + .andExpect(jsonPath("$.message").value( + org.hamcrest.Matchers.containsString("Host not in whitelist"))); + } +} diff --git a/ssrf-guard-native-image-demo/README.ko.md b/ssrf-guard-native-image-demo/README.ko.md new file mode 100644 index 0000000..0b2023d --- /dev/null +++ b/ssrf-guard-native-image-demo/README.ko.md @@ -0,0 +1,126 @@ +# ssrf-guard-native-image-demo + +[English](README.md) · **한국어** + +[`ssrf-guard 3.1.0`](https://github.com/devslab-kr/ssrf-guard)의 **GraalVM 네이티브 이미지 힌트**가 동작함을 end-to-end로 증명 — Spring Boot 앱에 라이브러리 drop, `./gradlew nativeCompile`, 나온 네이티브 바이너리가 JVM 빌드와 동일한 게이트에서 SSRF 시도를 차단. + +## 이 데모가 증명하는 것 + +ssrf-guard 3.1.0은 각 모듈에서 `META-INF/spring/aot.factories`로 `RuntimeHintsRegistrar` 엔트리를 발행합니다. 커버 범위: + +| 타입 | 힌트가 필요한 이유 | +| --- | --- | +| `UrlPolicy`, `HostPolicy` | Spring Boot AOT가 `@ConfigurationProperties` 바인딩을 리플렉션으로 인스턴스화 | +| `SsrfBlockPayload` (record) | LLM-tool wrap이 URL 거부할 때 Jackson 직렬화 wire에 등장 | +| `BlockReason` (enum) | 동일 — JSON 에러 페이로드에 포함 | +| `JsonToolInputGuard` | Tool-input JSON 트리를 Jackson 리플렉션으로 walk | +| `MicrometerSsrfGuardMetrics` | 조건부 빈 — `MeterRegistry` 리플렉션 lookup | + +힌트 없으면 네이티브 바이너리가 `ssrf.guard.*` 바인딩 / `SsrfBlockPayload` 직렬화 첫 시도에서 `MissingReflectionRegistrationError`로 죽음. 힌트 있으면 그냥 동작. + +## 사전 요구사항 + +| 도구 | 버전 | 이유 | +| --- | --- | --- | +| **GraalVM** | 21+, `native-image` 설치된 것 | `./gradlew nativeCompile` 실행에 필수 | +| **Docker** | (옵션) any | OCI 이미지 만들고 싶으면 `./gradlew bootBuildImage` | +| **메모리** | 약 8 GB free | 네이티브 이미지 빌드 메모리 많이 씀 | +| **디스크** | 약 3 GB | 빌드 캐시 + 출력 바이너리 (~80 MB) | + +`GRAALVM_HOME` (또는 JDK가 GraalVM 디스트리이면 `JAVA_HOME`) 설정. `native-image --version`로 확인. + +## 일단 JVM에서 실행 + +```bash +cd ssrf-guard-native-image-demo +./gradlew bootRun +``` + +다른 터미널에서: + +```bash +# 허용 (화이트리스트 호스트): +curl 'http://localhost:8080/fetch?url=https://httpbin.org/get' | jq + +# 차단 (AWS 메타데이터 IP 리터럴): +curl 'http://localhost:8080/fetch?url=http://169.254.169.254/' | jq + +# 12개 공격 패턴 카탈로그: +curl http://localhost:8080/attacks | jq +``` + +응답에 `"runtime": "jvm"`이 보여서 어느 빌드를 hit 했는지 알 수 있음. + +## 네이티브 이미지 빌드 + +```bash +./gradlew nativeCompile +``` + +하드웨어에 따라 3–8분. 출력: `build/native/nativeCompile/ssrf-guard-native-image-demo` (Linux/macOS) 또는 `…\ssrf-guard-native-image-demo.exe` (Windows). + +OCI 이미지를 대신 만들고 싶으면 `./gradlew bootBuildImage`. + +## 네이티브 바이너리 실행 + +```bash +./build/native/nativeCompile/ssrf-guard-native-image-demo +``` + +동일한 엔드포인트, 동일한 동작 — 단 `"runtime": "graalvm-native"`이고 startup이 ~1.5초 대신 ~50ms. + +```bash +curl 'http://localhost:8080/fetch?url=http://169.254.169.254/' | jq +``` + +```json +{ + "runtime": "graalvm-native", + "url": "http://169.254.169.254/", + "status": "blocked", + "reason": "blocked_ip_literal", + "message": "IP-literal host blocked..." +} +``` + +대신 `MissingReflectionRegistrationError`나 `status: error`가 나오면 ssrf-guard 측 힌트 누락 — 스택트레이스와 함께 이슈 부탁드립니다. + +## 더 빠른 검증 (전체 네이티브 빌드 없이) + +`./gradlew nativeCompile`은 몇 분 걸림. 전체 링크 없이 힌트만 검증하려면: + +```bash +./gradlew processAot +``` + +10초 정도. AOT 생성 소스가 `build/generated/aotSources`에 떨어짐. `META-INF/native-image/...`에서 ssrf-guard가 기여한 reflection / proxy / resource 힌트 확인 가능: + +```bash +find build/generated -name 'reflect-config.json' -exec head -30 {} \; +``` + +`UrlPolicy`, `HostPolicy`, `SsrfBlockPayload`, `BlockReason` 등 엔트리가 보일 것. + +## 읽을 만한 파일 + +| 파일 | 왜 | +| --- | --- | +| `build.gradle.kts` | 2줄 셋업: `kr.devslab:ssrf-guard:3.1.0` + `org.graalvm.buildtools.native` plugin | +| `SsrfGuardNativeImageDemoApplication.java` | `@SpringBootApplication` + `RestClient` bean만 — 나머지는 자동 설정 처리 | +| `NativeImageDemoController.java` | `/fetch` + `/attacks` — `runtime` 필드 (jvm vs graalvm-native)로 시각적 확인 | +| `application.yml` | `ssrf.guard.*` 키 — `@ConfigurationProperties` 바인딩 경로가 AOT 힌트가 필요한 그 경로 | + +## CI 전략 + +이 데모의 CI는 **JVM 모드만** 실행 — `./gradlew build`. 네이티브 이미지 빌드는 매 PR마다 돌리기엔 너무 느리고 리소스 많이 씀. JVM 테스트 스위트가 `processAot` 코드젠 경로를 간접 검증. + +네이티브 이미지 검증은 로컬에서 수동 — 위 단계대로 하면 10분 미만. + +## 왜 중요한가 + +GraalVM 네이티브 이미지는 JVM cold-start 비용 문제의 답: +- AWS Lambda / Cloud Run / Azure Container Apps +- Kubernetes pod scale-to-zero +- 100 ms 미만 시작 필요한 CLI 도구 + +라이브러리 측 힌트 없으면 사용자가 끌어오는 모든 의존성이 네이티브 이미지 지뢰. ssrf-guard 3.1.0은 힌트를 ship해서 사용자가 우리 타입에 대한 `reflect-config.json` 안 쓰고도 `nativeCompile` 가능. diff --git a/ssrf-guard-native-image-demo/README.md b/ssrf-guard-native-image-demo/README.md new file mode 100644 index 0000000..9430ff7 --- /dev/null +++ b/ssrf-guard-native-image-demo/README.md @@ -0,0 +1,126 @@ +# ssrf-guard-native-image-demo + +**English** · [한국어](README.ko.md) + +End-to-end proof that [`ssrf-guard 3.1.0`](https://github.com/devslab-kr/ssrf-guard)'s **GraalVM native-image hints** work — drop the library into a Spring Boot app, run `./gradlew nativeCompile`, and the resulting native binary blocks SSRF attempts at the same gates the JVM build does. + +## What this demo proves + +ssrf-guard 3.1.0 ships `RuntimeHintsRegistrar` entries through `META-INF/spring/aot.factories` in each module. The hints cover: + +| Type | Why it needs a hint | +| --- | --- | +| `UrlPolicy`, `HostPolicy` | Spring Boot AOT reflectively instantiates `@ConfigurationProperties` binding | +| `SsrfBlockPayload` (record) | Jackson serializes it on the wire when the LLM-tool wrap rejects a URL | +| `BlockReason` (enum) | Same — appears in the JSON error payload | +| `JsonToolInputGuard` | Tree-walks tool-input JSON via Jackson reflection | +| `MicrometerSsrfGuardMetrics` | Conditional bean — `MeterRegistry` reflective lookup | + +Without those hints, a native binary would fail at runtime with `MissingReflectionRegistrationError` the first time it tried to bind `ssrf.guard.*` or serialize an `SsrfBlockPayload`. With the hints, it Just Works. + +## Prerequisites + +| Tool | Version | Why | +| --- | --- | --- | +| **GraalVM** | 21+ with `native-image` installed | `./gradlew nativeCompile` won't run without it | +| **Docker** | (optional) any | If you'd rather use `./gradlew bootBuildImage` to produce an OCI image | +| **Memory** | ~8 GB free | Native image builds are memory-hungry | +| **Disk** | ~3 GB | Build cache + the output binary (~80 MB) | + +Set `GRAALVM_HOME` (or just `JAVA_HOME` if your JDK is a GraalVM distribution). Verify with `native-image --version`. + +## Run it on the JVM first + +```bash +cd ssrf-guard-native-image-demo +./gradlew bootRun +``` + +Then in another terminal: + +```bash +# Allowed (host in whitelist): +curl 'http://localhost:8080/fetch?url=https://httpbin.org/get' | jq + +# Blocked (AWS metadata IP literal): +curl 'http://localhost:8080/fetch?url=http://169.254.169.254/' | jq + +# See the 12-pattern attack catalog: +curl http://localhost:8080/attacks | jq +``` + +Response shows `"runtime": "jvm"` so you know which build you're hitting. + +## Build the native image + +```bash +./gradlew nativeCompile +``` + +Takes 3–8 minutes depending on hardware. Output lands at `build/native/nativeCompile/ssrf-guard-native-image-demo` (Linux/macOS) or `…\ssrf-guard-native-image-demo.exe` (Windows). + +You can also do `./gradlew bootBuildImage` to produce a container image instead. + +## Run the native binary + +```bash +./build/native/nativeCompile/ssrf-guard-native-image-demo +``` + +Same endpoints, same behaviour — but now `"runtime": "graalvm-native"` and startup is ~50 ms instead of ~1.5 seconds. + +```bash +curl 'http://localhost:8080/fetch?url=http://169.254.169.254/' | jq +``` + +```json +{ + "runtime": "graalvm-native", + "url": "http://169.254.169.254/", + "status": "blocked", + "reason": "blocked_ip_literal", + "message": "IP-literal host blocked..." +} +``` + +If you instead see `MissingReflectionRegistrationError` or `status: error`, that's a hint gap in ssrf-guard — please file an issue with the stack trace. + +## Faster verification (no full native build) + +`./gradlew nativeCompile` takes minutes. To verify the hints register correctly without doing the full link: + +```bash +./gradlew processAot +``` + +Runs in ~10 seconds. Produces the AOT-generated source under `build/generated/aotSources`. Inspect `META-INF/native-image/...` to see the reflection / proxy / resource hints ssrf-guard contributed: + +```bash +find build/generated -name 'reflect-config.json' -exec head -30 {} \; +``` + +You should see entries for `UrlPolicy`, `HostPolicy`, `SsrfBlockPayload`, `BlockReason`, etc. + +## What to read + +| File | Why | +| --- | --- | +| `build.gradle.kts` | The two-line setup: `kr.devslab:ssrf-guard:3.1.0` + `org.graalvm.buildtools.native` plugin | +| `SsrfGuardNativeImageDemoApplication.java` | Just `@SpringBootApplication` + a `RestClient` bean — autoconfig handles the rest | +| `NativeImageDemoController.java` | `/fetch` + `/attacks` — surfaces `runtime` (jvm vs graalvm-native) for visual confirmation | +| `application.yml` | `ssrf.guard.*` keys — bound via `@ConfigurationProperties`, which is the path that needs the AOT hints | + +## CI strategy + +This demo's CI runs **JVM mode only** — `./gradlew build`. The native-image build is too slow / resource-heavy for every PR. We verify the hints register correctly via the JVM test suite (which uses the same `processAot` codegen path indirectly). + +Native-image verification is left to whoever wants to confirm locally — the steps above take under 10 minutes start to finish. + +## Why this matters + +GraalVM native images are the answer to JVM cold-start cost in: +- AWS Lambda / Cloud Run / Azure Container Apps +- Kubernetes pods that scale to zero +- CLI tools that need to start in <100 ms + +Without library-side hints, every dependency the consumer pulls in is a potential native-image landmine. ssrf-guard 3.1.0 ships the hints so consumers can `nativeCompile` without writing `reflect-config.json` for our types. diff --git a/ssrf-guard-native-image-demo/build.gradle.kts b/ssrf-guard-native-image-demo/build.gradle.kts new file mode 100644 index 0000000..d5507ce --- /dev/null +++ b/ssrf-guard-native-image-demo/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + java + id("org.springframework.boot") version "3.5.3" + id("io.spring.dependency-management") version "1.1.7" + // GraalVM Native Image — provides `nativeCompile` + `nativeRun` tasks, + // and the `processAot` task that exercises every RuntimeHintsRegistrar + // contributed by libraries on the classpath. ssrf-guard 3.1.0 ships + // hints for UrlPolicy / HostPolicy / SsrfBlockPayload / BlockReason + // + the LLM JSON-walking path + Micrometer reflective bean; this demo + // is the end-to-end proof those hints actually let a native image + // build cleanly. + id("org.graalvm.buildtools.native") version "0.10.5" +} + +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") + + // The meta artifact — pulls -core + -httpclient5 + -restclient + AOT + // hints transitively. Everything ssrf-guard contributes to the native + // image (reflection / proxies / resources) flows through this single + // dep. + implementation("kr.devslab:ssrf-guard:3.1.0") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.named("test") { + useJUnitPlatform() +} + +graalvmNative { + binaries { + named("main") { + // Verbose during the build itself — the build log is the part + // that catches missing reflection hints (they show up as + // "missing-class-error" warnings the AOT processor emits). + verbose.set(true) + buildArgs.add("--no-fallback") + // Enables Spring's "init at run time" defaults for classes the + // Boot AOT scanner already knows are unsafe to init at build. + } + } +} diff --git a/ssrf-guard-native-image-demo/gradle.properties b/ssrf-guard-native-image-demo/gradle.properties new file mode 100644 index 0000000..a31b0e0 --- /dev/null +++ b/ssrf-guard-native-image-demo/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true diff --git a/ssrf-guard-native-image-demo/gradle/wrapper/gradle-wrapper.jar b/ssrf-guard-native-image-demo/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/ssrf-guard-native-image-demo/gradle/wrapper/gradle-wrapper.jar differ diff --git a/ssrf-guard-native-image-demo/gradle/wrapper/gradle-wrapper.properties b/ssrf-guard-native-image-demo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/ssrf-guard-native-image-demo/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/ssrf-guard-native-image-demo/gradlew b/ssrf-guard-native-image-demo/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/ssrf-guard-native-image-demo/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/ssrf-guard-native-image-demo/gradlew.bat b/ssrf-guard-native-image-demo/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/ssrf-guard-native-image-demo/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ssrf-guard-native-image-demo/settings.gradle.kts b/ssrf-guard-native-image-demo/settings.gradle.kts new file mode 100644 index 0000000..0497838 --- /dev/null +++ b/ssrf-guard-native-image-demo/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "ssrf-guard-native-image-demo" diff --git a/ssrf-guard-native-image-demo/src/main/java/kr/devslab/examples/ssrfguardnativeimage/NativeImageDemoController.java b/ssrf-guard-native-image-demo/src/main/java/kr/devslab/examples/ssrfguardnativeimage/NativeImageDemoController.java new file mode 100644 index 0000000..8c1a67e --- /dev/null +++ b/ssrf-guard-native-image-demo/src/main/java/kr/devslab/examples/ssrfguardnativeimage/NativeImageDemoController.java @@ -0,0 +1,84 @@ +package kr.devslab.examples.ssrfguardnativeimage; + +import kr.devslab.ssrfguard.core.SsrfGuardException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Endpoints: + *

    + *
  • {@code /fetch?url=...} — guarded fetch through {@link RestClient}.
  • + *
  • {@code /attacks} — the same 12-pattern attack catalog the main + * ssrf-guard-demo exposes, scoped to what's interesting here.
  • + *
+ */ +@RestController +public class NativeImageDemoController { + + private final RestClient restClient; + + public NativeImageDemoController(RestClient restClient) { + this.restClient = restClient; + } + + @GetMapping("/fetch") + public Map fetch(@RequestParam String url) { + Map response = new LinkedHashMap<>(); + response.put("runtime", System.getProperty("org.graalvm.nativeimage.imagecode") != null + ? "graalvm-native" + : "jvm"); + response.put("url", url); + try { + String body = restClient.get().uri(url).retrieve().body(String.class); + response.put("status", "allowed"); + response.put("bodyPreview", + body == null || body.length() <= 200 ? body : body.substring(0, 200) + "..."); + return response; + } catch (SsrfGuardException e) { + // The block reason serializes through SsrfBlockPayload, which + // has explicit GraalVM reflection hints (it's a record + Jackson + // serialization in the wire path). + response.put("status", "blocked"); + response.put("reason", e.reason().label()); + response.put("message", e.getMessage()); + return response; + } + } + + /** + * Same 12 patterns as the main demo. Each one round-trips through the + * SSRF interceptor, which uses the {@code UrlPolicy}, {@code HostPolicy}, + * and {@code SsrfBlockPayload} types — every reflective surface + * registered by ssrf-guard's RuntimeHintsRegistrar. + */ + @GetMapping("/attacks") + public Map attacks() { + Map root = new LinkedHashMap<>(); + root.put("description", + "12 attack URLs that should all block. Run each via /fetch?url=... " + + "and confirm the response says status: blocked. If anything " + + "comes back status: error with a MissingReflectionRegistrationError " + + "or NullPointerException in the native binary, that's a hint gap."); + root.put("scenarios", List.of( + Map.of("name", "AWS metadata (IP literal)", "url", "http://169.254.169.254/latest/meta-data/iam/security-credentials/"), + Map.of("name", "GCP metadata (internal host)", "url", "http://metadata.google.internal/computeMetadata/v1/instance/"), + Map.of("name", "Decimal-encoded loopback", "url", "http://2130706433/"), + Map.of("name", "Hex-encoded loopback", "url", "http://0x7f000001/"), + Map.of("name", "Octal-style loopback", "url", "http://0177.0.0.1/"), + Map.of("name", "Partial-form loopback", "url", "http://127.1/"), + Map.of("name", "IPv4-mapped IPv6 loopback", "url", "http://[::ffff:127.0.0.1]/"), + Map.of("name", "IPv4-mapped IPv6 private", "url", "http://[::ffff:10.0.0.5]/admin"), + Map.of("name", "Private RFC1918 host", "url", "http://10.0.0.5/internal-api/users"), + Map.of("name", "Userinfo with non-whitelisted host", "url", "https://user:pass@evil.com/leak"), + Map.of("name", "Non-whitelisted external host", "url", "https://evil.com/exfiltrate"), + Map.of("name", "httpbin redirect → AWS metadata", "url", "https://httpbin.org/redirect-to?url=http://169.254.169.254/") + )); + return root; + } +} diff --git a/ssrf-guard-native-image-demo/src/main/java/kr/devslab/examples/ssrfguardnativeimage/SsrfGuardNativeImageDemoApplication.java b/ssrf-guard-native-image-demo/src/main/java/kr/devslab/examples/ssrfguardnativeimage/SsrfGuardNativeImageDemoApplication.java new file mode 100644 index 0000000..3ffdbae --- /dev/null +++ b/ssrf-guard-native-image-demo/src/main/java/kr/devslab/examples/ssrfguardnativeimage/SsrfGuardNativeImageDemoApplication.java @@ -0,0 +1,52 @@ +package kr.devslab.examples.ssrfguardnativeimage; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestClient; + +/** + * Proves that ssrf-guard 3.1.0's GraalVM hints work end-to-end. The app: + * + *
    + *
  1. Pulls {@code kr.devslab:ssrf-guard:3.1.0} (which transitively brings + * in {@code -restclient}, {@code -httpclient5}, {@code -core}, and the + * {@code RuntimeHintsRegistrar} entries each module contributes via + * {@code META-INF/spring/aot.factories}).
  2. + *
  3. Wires a {@link RestClient} that has the SSRF interceptor applied + * (via the {@code RestClientCustomizer} the {@code -restclient} + * autoconfig provides).
  4. + *
  5. Exposes a {@code /fetch} endpoint and an {@code /attacks} catalog — + * a stripped-down version of the main {@code ssrf-guard-demo}.
  6. + *
+ * + *

The point isn't the runtime behaviour — that's covered by the other + * demos. The point is the build: when you run {@code ./gradlew + * nativeCompile}, GraalVM walks the closed-world model that + * {@code processAot} produces from the registered hints. If anything is + * missing, the native image fails to start at runtime with + * {@code MissingReflectionRegistrationError}. Running this demo's native + * binary and getting a working {@code /fetch} response is the proof. + * + *

See the README for the verification flow ({@code processAot} → + * {@code nativeCompile} → {@code nativeRun}). + */ +@SpringBootApplication +public class SsrfGuardNativeImageDemoApplication { + + public static void main(String[] args) { + SpringApplication.run(SsrfGuardNativeImageDemoApplication.class, args); + } + + /** + * A {@link RestClient.Builder} that ssrf-guard-restclient's + * {@link RestClientCustomizer} will have already applied its SSRF + * interceptor to. This bean is just sugar so the controller can inject + * a {@link RestClient} directly. + */ + @Bean + public RestClient restClient(RestClient.Builder builder) { + return builder.build(); + } +} diff --git a/ssrf-guard-native-image-demo/src/main/resources/application.yml b/ssrf-guard-native-image-demo/src/main/resources/application.yml new file mode 100644 index 0000000..8c2ba45 --- /dev/null +++ b/ssrf-guard-native-image-demo/src/main/resources/application.yml @@ -0,0 +1,23 @@ +spring: + application: + name: ssrf-guard-native-image-demo + +ssrf: + guard: + enabled: true + exact-hosts: + - httpbin.org + - api.partner.com + block-private-networks: true + reject-ip-literal-hosts: true + reject-user-info: true + follow-redirects: true + allowed-schemes: + - http + - https + +logging: + level: + root: WARN + kr.devslab.examples: INFO + kr.devslab.ssrfguard: INFO diff --git a/ssrf-guard-native-image-demo/src/test/java/kr/devslab/examples/ssrfguardnativeimage/SsrfGuardNativeImageDemoApplicationTests.java b/ssrf-guard-native-image-demo/src/test/java/kr/devslab/examples/ssrfguardnativeimage/SsrfGuardNativeImageDemoApplicationTests.java new file mode 100644 index 0000000..a7376f5 --- /dev/null +++ b/ssrf-guard-native-image-demo/src/test/java/kr/devslab/examples/ssrfguardnativeimage/SsrfGuardNativeImageDemoApplicationTests.java @@ -0,0 +1,55 @@ +package kr.devslab.examples.ssrfguardnativeimage; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * JVM-mode smoke test. CI runs this — building a native image is too slow + * and resource-heavy to do on every PR. The native-image build is verified + * manually by running {@code ./gradlew nativeCompile && ./gradlew nativeRun} + * locally (see README). + * + *

What this test proves: with ssrf-guard 3.1.0 on the classpath, the + * autoconfig wires correctly, the {@code RestClientCustomizer} applies the + * SSRF interceptor, and the same block path the other demos exercise works + * here. The native-image-specific verification is in the README workflow. + */ +@SpringBootTest +@AutoConfigureMockMvc +class SsrfGuardNativeImageDemoApplicationTests { + + @Autowired + private MockMvc mockMvc; + + @Test + void awsMetadataIsBlocked() throws Exception { + mockMvc.perform(get("/fetch").param("url", "http://169.254.169.254/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("blocked")) + .andExpect(jsonPath("$.reason").value("blocked_ip_literal")) + .andExpect(jsonPath("$.runtime").value("jvm")); + } + + @Test + void disallowedHostIsBlocked() throws Exception { + mockMvc.perform(get("/fetch").param("url", "https://evil.com/")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("blocked")) + .andExpect(jsonPath("$.reason").value("blocked_host")); + } + + @Test + void attackCatalogIsServed() throws Exception { + mockMvc.perform(get("/attacks")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.scenarios").isArray()) + .andExpect(jsonPath("$.scenarios.length()").value(12)); + } +}