diff --git a/README.ko.md b/README.ko.md index abf8079..9ba324c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -29,7 +29,7 @@ 전체 인덱스: [github.com/devslab-kr/devslab-examples](https://github.com/devslab-kr/devslab-examples). -## 모듈 매트릭스 (v3.0.0) +## 모듈 매트릭스 쓰는 HTTP 클라이언트에 맞는 모듈만 고르세요. `ssrf-guard-core`는 transitive로 따라옴. @@ -38,9 +38,11 @@ | **`ssrf-guard`** | 메타 — RestClient + HttpClient5 (v2.0.0 호환) | ✅ | | `ssrf-guard-restclient` | Spring 6.1+ `RestClient` | ✅ | | `ssrf-guard-resttemplate` | Spring `RestTemplate` | ✅ | -| `ssrf-guard-webclient` | Spring WebFlux `WebClient` | ✅ | +| `ssrf-guard-webclient` | Spring WebFlux `WebClient` — URL 단계 필터 + reactor-netty DNS 단계 IP 필터 (v3.1+) | ✅ | | `ssrf-guard-feign` | Spring Cloud OpenFeign | ✅ | -| **`ssrf-guard-springai`** ⭐ | Spring AI `ToolCallback` URL 검증 — LLM 에이전트 SSRF 차단 | ✅ | +| `ssrf-guard-llm` 🧩 | 프레임워크-중립 JSON 툴 입력 검증 (v3.1+) — LLM 어댑터들이 재사용 | — | +| **`ssrf-guard-springai`** ⭐ | Spring AI `ToolCallback` URL 검증 — `-llm` 위의 thin adapter | ✅ | +| **`ssrf-guard-langchain4j`** ⭐ | LangChain4j `ToolExecutor` URL 검증 — Java LLM 양대 프레임워크 다른 한쪽 (v3.1+) | ✅ | | `ssrf-guard-httpclient5` | Apache HttpClient 5 직접 | — | | `ssrf-guard-jdkhttp` | `java.net.http.HttpClient` | — | | `ssrf-guard-okhttp` | OkHttp | — | diff --git a/README.md b/README.md index d706149..0c99e90 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Standalone Spring Boot projects that exercise every module documented below — Full index at [github.com/devslab-kr/devslab-examples](https://github.com/devslab-kr/devslab-examples). -## Module matrix (v3.0.0) +## Module matrix Pick the module matching your HTTP client. The core (`ssrf-guard-core`) follows transitively. @@ -38,9 +38,11 @@ Pick the module matching your HTTP client. The core (`ssrf-guard-core`) follows | **`ssrf-guard`** | Meta artifact — RestClient + HttpClient5 (v2.0.0 back-compat) | ✅ | | `ssrf-guard-restclient` | Spring 6.1+ `RestClient` | ✅ | | `ssrf-guard-resttemplate` | Spring `RestTemplate` | ✅ | -| `ssrf-guard-webclient` | Spring WebFlux `WebClient` | ✅ | +| `ssrf-guard-webclient` | Spring WebFlux `WebClient` — URL-time filter + reactor-netty DNS-time IP filter (v3.1+) | ✅ | | `ssrf-guard-feign` | Spring Cloud OpenFeign | ✅ | -| **`ssrf-guard-springai`** ⭐ | Spring AI `ToolCallback` URL validation — closes the LLM-agent SSRF surface | ✅ | +| `ssrf-guard-llm` 🧩 | Framework-agnostic JSON tool-input validator (v3.1+) — reused by the LLM adapters | — | +| **`ssrf-guard-springai`** ⭐ | Spring AI `ToolCallback` URL validation — thin adapter over `-llm` | ✅ | +| **`ssrf-guard-langchain4j`** ⭐ | LangChain4j `ToolExecutor` URL validation — same defense for the other Java LLM framework (v3.1+) | ✅ | | `ssrf-guard-httpclient5` | Apache HttpClient 5 directly | — | | `ssrf-guard-jdkhttp` | `java.net.http.HttpClient` | — | | `ssrf-guard-okhttp` | OkHttp | — | diff --git a/docs/getting-started/installation.ko.md b/docs/getting-started/installation.ko.md index 2c7d473..7c98050 100644 --- a/docs/getting-started/installation.ko.md +++ b/docs/getting-started/installation.ko.md @@ -69,7 +69,38 @@ ssrf-guard v3.0.0은 HTTP 클라이언트 경계로 분리되어 있습니다. ``` - 모든 `ToolCallback` 빈을 URL 인자 검증으로 감쌈. **LLM 에이전트가 URL을 받아 fetch하는 시나리오의 결정적인 새 SSRF 표면.** + 모든 `ToolCallback` 빈을 URL 인자 검증으로 감쌈. **LLM 에이전트가 URL을 받아 fetch하는 시나리오의 결정적인 새 SSRF 표면.** v3.1+는 내부적으로 `ssrf-guard-llm` (프레임워크-중립 코어)에 위임 — public API 동일. + +=== "LangChain4j 툴 실행" + + ```xml + + kr.devslab + ssrf-guard-langchain4j + 3.1.0 + + ``` + + Spring AI 탭과 동일한 위협 모델, 다른 프레임워크. 모든 `ToolExecutor` 빈을 wrap. LangChain4j 툴이 Spring 빈일 때 `BeanPostProcessor`가 자동 처리; 프로그래매틱/비-Spring 사용에는 `SsrfGuardedToolExecutors.wrap(...)` 헬퍼가 `AiServices.builder(...).tools(Map)` 형태 지원. + +=== "커스텀 툴 dispatcher (프레임워크 없음)" + + ```xml + + kr.devslab + ssrf-guard-llm + 3.1.0 + + ``` + + 프레임워크-중립 코어. 자체 툴 라우터 (MCP 서버, 내부 RPC dispatcher, 커스텀 에이전트 루프) 만든 경우 직접 사용: + + ```java + JsonToolInputGuard guard = new JsonToolInputGuard(urlPolicy); + String violation = guard.checkOrFormatError(rawJsonInput); + if (violation != null) return violation; // LLM에게 줄 구조화된 JSON + // ... 진짜 툴 실행 + ``` === "JDK HttpClient (Spring 없음)" diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index de63dfc..2df4953 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -69,7 +69,38 @@ ssrf-guard v3.0.0 is split along HTTP-client boundaries — pick the module(s) m ``` - Wraps every `ToolCallback` bean with URL-argument validation. The defining new SSRF surface — LLM agents that take a URL as a parameter and fetch it. + Wraps every `ToolCallback` bean with URL-argument validation. The defining new SSRF surface — LLM agents that take a URL as a parameter and fetch it. v3.1+ delegates to `ssrf-guard-llm` (the framework-agnostic core); same public API. + +=== "LangChain4j tool execution" + + ```xml + + kr.devslab + ssrf-guard-langchain4j + 3.1.0 + + ``` + + Same threat model as the Spring AI tab — different framework. Wraps every `ToolExecutor` bean. Useful when your LangChain4j tools are Spring beans (auto-wrapped by `BeanPostProcessor`); for programmatic / non-Spring use, the `SsrfGuardedToolExecutors.wrap(...)` helpers cover the `AiServices.builder(...).tools(Map)` shape. + +=== "Custom tool dispatcher (no framework)" + + ```xml + + kr.devslab + ssrf-guard-llm + 3.1.0 + + ``` + + The framework-agnostic core. Use directly when you've built your own tool router (MCP server, internal RPC dispatcher, custom agent loop): + + ```java + JsonToolInputGuard guard = new JsonToolInputGuard(urlPolicy); + String violation = guard.checkOrFormatError(rawJsonInput); + if (violation != null) return violation; // structured JSON for the LLM + // ... run the real tool + ``` === "Plain JDK HttpClient (no Spring)" diff --git a/docs/guides/configuration.ko.md b/docs/guides/configuration.ko.md index 7db4fe5..45e7d5c 100644 --- a/docs/guides/configuration.ko.md +++ b/docs/guides/configuration.ko.md @@ -92,3 +92,24 @@ ssrf: ``` 이 정도 화이트리스트면 partner-integration이 많은 거의 모든 서비스를 SSRF에서 보호. + +## LLM 어댑터 properties (v3.1+) + +LLM 툴 빈 자동 wrap을 제어하는 두 토글. 둘 다 기본 `true` — 자체 코드에서 (`SsrfGuardedToolCallbacks.wrap(...)` / `SsrfGuardedToolExecutors.wrap(...)`) 선별적으로 wrap하고 싶으면 `false`로 끔. + +| 키 | 기본값 | 효과 | +|---|---|---| +| `ssrf.guard.springai.wrap-tool-callbacks` | `true` | Spring AI 모든 `ToolCallback` 빈 자동 wrap. `ssrf-guard-springai` classpath 필요. | +| `ssrf.guard.langchain4j.wrap-tool-executors` | `true` | LangChain4j 모든 `ToolExecutor` 빈 자동 wrap. `ssrf-guard-langchain4j` classpath 필요. | + +```yaml +ssrf: + guard: + enabled: true + exact-hosts: + - api.partner.com + springai: + wrap-tool-callbacks: true # 기본 + langchain4j: + wrap-tool-executors: true # 기본 +``` diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index c218027..5dda7f2 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -92,3 +92,24 @@ ssrf: ``` A whitelist this size will keep almost any partner-integration-heavy service safe from SSRF. + +## LLM-adapter properties (v3.1+) + +Two additional toggles control automatic wrapping of LLM tool beans. Both default to `true` — set to `false` to wrap selectively from your own code (via `SsrfGuardedToolCallbacks.wrap(...)` or `SsrfGuardedToolExecutors.wrap(...)`) without the `BeanPostProcessor` running. + +| Key | Default | Effect | +|---|---|---| +| `ssrf.guard.springai.wrap-tool-callbacks` | `true` | Wraps every Spring AI `ToolCallback` bean. Requires `ssrf-guard-springai` on the classpath. | +| `ssrf.guard.langchain4j.wrap-tool-executors` | `true` | Wraps every LangChain4j `ToolExecutor` bean. Requires `ssrf-guard-langchain4j` on the classpath. | + +```yaml +ssrf: + guard: + enabled: true + exact-hosts: + - api.partner.com + springai: + wrap-tool-callbacks: true # default + langchain4j: + wrap-tool-executors: true # default +``` diff --git a/docs/guides/security-model.ko.md b/docs/guides/security-model.ko.md index febd4bc..1d3207c 100644 --- a/docs/guides/security-model.ko.md +++ b/docs/guides/security-model.ko.md @@ -51,7 +51,8 @@ Java 내장 `InetAddress.isSiteLocalAddress()`는 CGNAT, benchmark range, IPv6 솔직한 한계 목록. 경계 인식이 threat model의 일부. - **HTTP 클라이언트마다 다른 URL 파싱을 검증하지 않음.** JDK `URI` 생성자, Spring `UriComponentsBuilder`, Apache HttpClient의 request line — `://user:pass@a.com\@b.com/` 같은 문자열이 어떤 호스트인지 항상 동의하지 않음. 신뢰할 수 없는 입력에서 URL을 받으면 `RestClient`에 넘기기 전 정규화해야. OWASP cheat sheet의 [URL parser confusion section](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html#network-layer) 권장. -- **`RestClient` 외 HTTP 클라이언트는 보호 안 함.** `HttpURLConnection`, `OkHttpClient`, 직접 Apache `HttpClient` (래핑 안 된), `WebClient` — 이 중 어떤 것도 SSRF 정책을 거치지 않음. 자동 구성은 Spring Boot 자동 `RestClient.Builder`만 customize. +- **wrap되지 않은 HTTP 클라이언트는 보호 안 함.** 자동 구성된 `RestClient.Builder` / `RestTemplateBuilder` / `WebClient.Builder` / `OkHttpClient.Builder` 등의 경로를 거치지 않고 직접 만든 `HttpURLConnection` 같은 코드는 SSRF 정책 우회. v3.1+는 주요 Java HTTP 스택 (RestClient · RestTemplate · WebClient · Feign · OkHttp · JDK `HttpClient` · Apache HttpClient 5)과 LLM 툴 dispatch (Spring AI · LangChain4j)를 모두 커버 — 사용 중인 각각에 맞는 모듈을 골라 추가. +- **WebClient는 v3.1부터 URL 단계 *+* DNS 단계 방어 모두.** v3.0.x WebClient 모듈은 URL 단계 필터만 실행 — 화이트리스트를 통과한 호스트가 사설 IP로 resolve되어도 reactor-netty가 그대로 연결. v3.1의 `SsrfGuardReactorAddressResolverGroup`이 reactor-netty의 address resolver에 후킹해서 다른 모듈과 동일한 사설/loopback 범위로 필터링. 비-Netty WebFlux 백엔드 (Jetty Reactive, Helidon)도 URL 단계 방어는 받음; connector 교체는 reactor-netty classpath 의존. - **JVM 캐시가 작용할 때 DNS rebinding을 막지 않음.** Java가 DNS 해석을 캐시; JVM이 영구 캐싱 (Java 8u192 이전 보안 정책 default)이면 hostname의 record 변경 후에도 캐시된 IP를 계속 hit. 모던 JVM은 default가 30초이긴 함 — `networkaddress.cache.ttl` 합리적 값 유지. - **`exact-hosts`에 사설 IP literal을 직접 넣는 걸 막지 않음.** `10.0.0.5`를 화이트리스트하면 인터셉터가 호스트를 통과시키고, DNS resolver가 그 IP로 단락. (단, `block-private-networks=false`로 안 두면) 사설 IP 필터가 여전히 적용되어 요청은 거부 — 하지만 레이어링이 "인터셉터 통과, resolver 거부"이지 "인터셉터 즉시 거부" 아님. - **응답 본문 검증 안 함.** 화이트리스트 호스트라도 downstream 이슈를 트리거하는 콘텐츠 반환 가능. SSRF 방어는 신뢰하는 호스트로 소켓이 연결될 때 끝남; 그 호스트가 반환하는 건 애플리케이션 로직 책임. diff --git a/docs/guides/security-model.md b/docs/guides/security-model.md index 87b73b7..8ce6521 100644 --- a/docs/guides/security-model.md +++ b/docs/guides/security-model.md @@ -51,7 +51,8 @@ Java's built-in `InetAddress.isSiteLocalAddress()` misses CGNAT, the benchmark r Honest list. Knowing the boundary is part of the threat model. - **It does not validate URL parsing the way every HTTP client interprets it.** The JDK `URI` constructor, Spring's `UriComponentsBuilder`, Apache HttpClient's request line — they don't always agree on what host a `://user:pass@a.com\@b.com/` string represents. If you accept URLs from untrusted input, normalize them before handing them to `RestClient`. The OWASP cheat sheet has a [URL parser confusion section](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html#network-layer) worth reading. -- **It does not protect non-`RestClient` HTTP clients.** Code using `HttpURLConnection`, `OkHttpClient`, raw Apache `HttpClient` (not the wrapped one), `WebClient` — none of those go through the SSRF policy. The auto-configuration only customizes Spring Boot's auto-configured `RestClient.Builder`. +- **It does not protect non-wrapped HTTP clients.** Code using raw `HttpURLConnection`, or any client built outside the auto-configured `RestClient.Builder` / `RestTemplateBuilder` / `WebClient.Builder` / `OkHttpClient.Builder` etc. — that custom code bypasses the SSRF policy. v3.1+ covers every major Java HTTP stack (RestClient · RestTemplate · WebClient · Feign · OkHttp · JDK `HttpClient` · Apache HttpClient 5) plus LLM tool dispatch (Spring AI · LangChain4j) — pick the matching module for each one you use. +- **WebClient gets URL-time *and* DNS-time defense from v3.1.** v3.0.x's WebClient module only ran the URL-time filter — a host that passed the whitelist could still resolve to a private IP, and reactor-netty would connect to it. v3.1's `SsrfGuardReactorAddressResolverGroup` hooks into reactor-netty's address resolver and filters resolved IPs against the same private/loopback ranges. Non-Netty WebFlux backends (Jetty Reactive, Helidon) still get URL-time defense; the connector swap is gated on reactor-netty being on the classpath. - **It does not protect against DNS rebinding when the JVM cache is in play.** Java caches DNS resolutions; if your JVM caches forever (the default for security policies that pre-date Java 8u192) and a hostname's records change after the cache was populated, the cached IP is what you keep hitting. Set `networkaddress.cache.ttl` to something sane (default in modern JVMs is already 30 seconds). - **It does not stop you from putting a private-IP literal directly in `exact-hosts`.** If you whitelist `10.0.0.5`, the interceptor accepts that host, and the DNS resolver short-circuits to that IP. The private-IP filter still applies (unless you set `block-private-networks=false`), so the request is rejected — but the layering is "interceptor accepts, resolver rejects," not "interceptor rejects up front." - **It does not validate response bodies.** A whitelisted host can still return content that triggers downstream issues. SSRF defense ends when the socket connects to a host you trust; what that host returns is application logic. diff --git a/docs/index.ko.md b/docs/index.ko.md index 09bfde4..dda186b 100644 --- a/docs/index.ko.md +++ b/docs/index.ko.md @@ -17,9 +17,11 @@ ssrf-guard는 작은 core(정책 + IP 분류) + HTTP 클라이언트별 모듈 | **`ssrf-guard`** | 메타 아티팩트 — RestClient + HttpClient5 (v2.0.0 호환) | Yes | | `ssrf-guard-restclient` | Spring 6.1+ `RestClient` | Yes | | `ssrf-guard-resttemplate` | Spring `RestTemplate` (엔터프라이즈/레거시) | Yes | -| `ssrf-guard-webclient` | Spring WebFlux `WebClient` | Yes (WebFlux) | +| `ssrf-guard-webclient` | Spring WebFlux `WebClient` — URL 단계 필터 **+** reactor-netty DNS 단계 IP 필터 (v3.1+) | Yes (WebFlux) | | `ssrf-guard-feign` | Spring Cloud OpenFeign | Yes (Cloud) | -| **`ssrf-guard-springai`** ⭐ | Spring AI `ToolCallback` — LLM 에이전트 SSRF 차단 | Yes (AI) | +| `ssrf-guard-llm` 🧩 | 프레임워크-중립 JSON 툴 입력 검증 (v3.1+). springai / langchain4j 어댑터가 사용; 커스텀 dispatcher에서도 직접 사용 가능. | No | +| **`ssrf-guard-springai`** ⭐ | Spring AI `ToolCallback` — LLM 에이전트 SSRF 차단 (`-llm` 위의 thin adapter) | Yes (AI) | +| **`ssrf-guard-langchain4j`** ⭐ | LangChain4j `ToolExecutor` — Java LLM 양대 프레임워크 다른 한쪽 (v3.1+, `-llm` 위의 thin adapter) | Yes | | `ssrf-guard-httpclient5` | Apache HttpClient 5 직접 사용 | No | | `ssrf-guard-jdkhttp` | `java.net.http.HttpClient` (JDK 11+) | No | | `ssrf-guard-okhttp` | OkHttp | No | diff --git a/docs/index.md b/docs/index.md index c1cc565..0898914 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,9 +17,11 @@ Pick the module(s) matching your HTTP client. The core is pulled in transitively | **`ssrf-guard`** | Meta artifact — RestClient + HttpClient5 (v2.0.0 back-compat) | Yes | | `ssrf-guard-restclient` | Spring 6.1+ `RestClient` | Yes | | `ssrf-guard-resttemplate` | Spring `RestTemplate` (enterprise / legacy) | Yes | -| `ssrf-guard-webclient` | Spring WebFlux `WebClient` | Yes (WebFlux) | +| `ssrf-guard-webclient` | Spring WebFlux `WebClient` — URL-time filter **and** reactor-netty DNS-time IP filter (v3.1+) | Yes (WebFlux) | | `ssrf-guard-feign` | Spring Cloud OpenFeign | Yes (Cloud) | -| **`ssrf-guard-springai`** ⭐ | Spring AI `ToolCallback` — closes the LLM-agent SSRF surface | Yes (AI) | +| `ssrf-guard-llm` 🧩 | Framework-agnostic JSON tool-input validator (v3.1+). Used by the springai / langchain4j adapters; usable directly from a custom dispatcher. | No | +| **`ssrf-guard-springai`** ⭐ | Spring AI `ToolCallback` — closes the LLM-agent SSRF surface (thin adapter over `-llm`) | Yes (AI) | +| **`ssrf-guard-langchain4j`** ⭐ | LangChain4j `ToolExecutor` — same defense for the other Java LLM framework (v3.1+, thin adapter over `-llm`) | Yes | | `ssrf-guard-httpclient5` | Apache HttpClient 5 directly | No | | `ssrf-guard-jdkhttp` | `java.net.http.HttpClient` (JDK 11+) | No | | `ssrf-guard-okhttp` | OkHttp | No | diff --git a/ssrf-guard-langchain4j/src/test/java/kr/devslab/ssrfguard/langchain4j/SsrfGuardLangchain4jAutoConfigurationTest.java b/ssrf-guard-langchain4j/src/test/java/kr/devslab/ssrfguard/langchain4j/SsrfGuardLangchain4jAutoConfigurationTest.java new file mode 100644 index 0000000..2fa43e3 --- /dev/null +++ b/ssrf-guard-langchain4j/src/test/java/kr/devslab/ssrfguard/langchain4j/SsrfGuardLangchain4jAutoConfigurationTest.java @@ -0,0 +1,114 @@ +package kr.devslab.ssrfguard.langchain4j; + +import dev.langchain4j.agent.tool.ToolExecutionRequest; +import dev.langchain4j.service.tool.ToolExecutor; +import kr.devslab.ssrfguard.core.UrlPolicy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Nested; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Boots a Spring context with the langchain4j autoconfig active and asserts: + * + *
    + *
  1. The autoconfig publishes the expected beans ({@code UrlPolicy}, + * {@code SsrfGuardMetrics}, and the BeanPostProcessor).
  2. + *
  3. The BeanPostProcessor actually wraps consumer {@code @Bean ToolExecutor} + * declarations — every executor in the context becomes a + * {@code SsrfGuardedToolExecutor} without consumer code.
  4. + *
  5. A request through the wrapped executor blocks attacker URLs + * end-to-end. This is the "secure by default" claim we make in + * the README — the test holds us to it.
  6. + *
  7. {@code wrap-tool-executors=false} disables the BeanPostProcessor + * (consumers who want to wrap manually keep the off switch).
  8. + *
+ */ +class SsrfGuardLangchain4jAutoConfigurationTest { + + @SpringBootApplication + static class TestApp { + + /** A consumer-side tool that records every invocation. */ + @Bean + ToolExecutor fakeFetchUrlTool() { + return (request, memoryId) -> "PRETEND-FETCHED " + request.arguments(); + } + } + + @Nested + @SpringBootTest(classes = TestApp.class) + @TestPropertySource(properties = { + "ssrf.guard.enabled=true", + "ssrf.guard.exact-hosts=api.example.com" + // wrap-tool-executors defaults to true + }) + class WhenAutoWrapEnabled { + + @Autowired ApplicationContext ctx; + @Autowired UrlPolicy policy; + @Autowired ToolExecutor injectedExecutor; + + @Test + void context_publishes_url_policy_and_postprocessor() { + assertThat(policy).isNotNull(); + // The consumer's ToolExecutor came back wrapped. + assertThat(injectedExecutor) + .as("autoconfig BeanPostProcessor should wrap @Bean ToolExecutor") + .isInstanceOf(SsrfGuardedToolExecutor.class); + } + + @Test + void wrapped_executor_blocks_aws_metadata_url() { + String result = injectedExecutor.execute( + ToolExecutionRequest.builder() + .name("fetch_url") + .arguments("{\"url\":\"http://169.254.169.254/latest/meta-data/\"}") + .build(), + null); + assertThat(result) + .as("LLM-facing output on a blocked URL") + .contains("\"error\":\"ssrf_blocked\"") + .contains("\"reason\":\"blocked_ip_literal\""); + } + + @Test + void wrapped_executor_allows_whitelisted_url() { + String result = injectedExecutor.execute( + ToolExecutionRequest.builder() + .name("fetch_url") + .arguments("{\"url\":\"https://api.example.com/v1\"}") + .build(), + null); + // The fake delegate echoes "PRETEND-FETCHED ..." on success — + // if we see that string the wrap let the call through. + assertThat(result).startsWith("PRETEND-FETCHED"); + } + } + + @Nested + @SpringBootTest(classes = TestApp.class) + @TestPropertySource(properties = { + "ssrf.guard.enabled=true", + "ssrf.guard.exact-hosts=api.example.com", + "ssrf.guard.langchain4j.wrap-tool-executors=false" + }) + class WhenAutoWrapDisabled { + + @Autowired ToolExecutor injectedExecutor; + + @Test + void executor_is_NOT_wrapped() { + // The off switch should leave the consumer's bean untouched — + // they presumably plan to wrap manually via + // SsrfGuardedToolExecutors.wrap(...). + assertThat(injectedExecutor).isNotInstanceOf(SsrfGuardedToolExecutor.class); + } + } +} diff --git a/ssrf-guard-webclient/src/test/java/kr/devslab/ssrfguard/webclient/SsrfGuardWebClientAutoConfigurationTest.java b/ssrf-guard-webclient/src/test/java/kr/devslab/ssrfguard/webclient/SsrfGuardWebClientAutoConfigurationTest.java index 304e149..3687549 100644 --- a/ssrf-guard-webclient/src/test/java/kr/devslab/ssrfguard/webclient/SsrfGuardWebClientAutoConfigurationTest.java +++ b/ssrf-guard-webclient/src/test/java/kr/devslab/ssrfguard/webclient/SsrfGuardWebClientAutoConfigurationTest.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.ApplicationContext; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.test.context.TestPropertySource; import org.springframework.web.reactive.function.client.WebClient; @@ -19,6 +20,11 @@ class SsrfGuardWebClientAutoConfigurationTest { @Autowired ApplicationContext ctx; @Autowired WebClient.Builder webClientBuilder; @Autowired SsrfGuardExchangeFilterFunction filter; + // v3.1 — new connector + resolver beans wired by + // ReactorNettyConnectorConfiguration. Should always resolve when + // reactor-netty is on the classpath (which it is via spring-webflux). + @Autowired ReactorClientHttpConnector connector; + @Autowired SsrfGuardReactorAddressResolverGroup resolverGroup; @Test void filter_is_registered() { @@ -34,4 +40,21 @@ void web_client_customizer_is_registered() { void builder_is_available() { assertThat(webClientBuilder.build()).isNotNull(); } + + @Test + void reactor_client_http_connector_is_registered() { + // v3.1 closes the DNS-time gap. The autoconfig should publish a + // ReactorClientHttpConnector backed by our filtering resolver. + assertThat(connector).isNotNull(); + assertThat(ctx.containsBean("ssrfReactorClientHttpConnector")).isTrue(); + } + + @Test + void address_resolver_group_is_registered() { + // The resolver group bean itself is also exposed so consumers with + // their own ClientHttpConnector (custom pool / proxy / mTLS) can + // still attach our resolver by calling httpClient.resolver(group). + assertThat(resolverGroup).isNotNull(); + assertThat(ctx.containsBean("ssrfReactorAddressResolverGroup")).isTrue(); + } }