From d7f19faca3223e3187833c5c989d4f5aebcee69e Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sat, 23 May 2026 19:53:26 +0900 Subject: [PATCH] v3.1: GraalVM native-image hints (typed payload record + RuntimeHintsRegistrar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that together make the library play nicely with Spring Boot 3's AOT processor and `./gradlew nativeBuild`: 1) Replace Map.of(...) error payload with the typed SsrfBlockPayload record The previous form serialised an immutable Map.of(...) — internally one of ImmutableCollections.MapN, whose private layout AOT couldn't introspect. Reflection on those classes would either fail at native- image build time or strip the fields silently, producing empty {} bodies in production where the LLM expected its structured error. The record fixes both: AOT sees the components at build time and registers reflection automatically, AND the JSON wire shape gains a stable declaration order (Map iteration order was officially undefined). The wire keys, values, and substring assertions remain unchanged — every existing JsonToolInputGuard test passes verbatim. 2) Add SsrfGuardLlmRuntimeHints + META-INF/spring/aot.factories - SsrfBlockPayload record reflection (INVOKE_PUBLIC_METHODS, constructors, DECLARED_FIELDS — covers everything Jackson touches) - BlockReason enum reflection (PUBLIC_FIELDS + methods — Jackson reads constants and calls label() via reflection) Spring Boot's AotServices loader discovers the registrar via `META-INF/spring/aot.factories`, so it activates whenever `ssrf-guard-llm` is on the classpath — no consumer code change. What does NOT need its own hints - All -springai / -langchain4j autoconfigs — Spring Boot 3 AOT handles @AutoConfiguration / @Bean / @Conditional* / @ConfigurationProperties out of the box. - SsrfGuardReactorAddressResolverGroup — extends Netty's AbstractAddressResolver, which is a regular subclass relationship; no reflection. - All -httpclient5 / -jdkhttp / -okhttp types — direct Java code, no runtime reflection. Tests - Existing 9 JsonToolInputGuard tests pass after the record refactor (proves wire-compat). - 2 new SsrfGuardLlmRuntimeHintsTest tests assert the right hints are registered via Spring's RuntimeHintsPredicates. - Full build: 212 tests across 11 modules. Note: actual GraalVM native-image build verification isn't in CI yet — adding it requires the GraalVM toolchain on the runner and ~10 min build per smoke. The declarations here follow Spring Boot's documented patterns; if a consumer hits a native build issue, an additional registerType(...) call here is the fix. --- docs/changelog.ko.md | 8 +++ docs/changelog.md | 8 +++ .../ssrfguard/llm/JsonToolInputGuard.java | 12 ++-- .../ssrfguard/llm/SsrfBlockPayload.java | 63 +++++++++++++++++++ .../llm/SsrfGuardLlmRuntimeHints.java | 62 ++++++++++++++++++ .../resources/META-INF/spring/aot.factories | 2 + .../llm/SsrfGuardLlmRuntimeHintsTest.java | 44 +++++++++++++ 7 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/SsrfBlockPayload.java create mode 100644 ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/SsrfGuardLlmRuntimeHints.java create mode 100644 ssrf-guard-llm/src/main/resources/META-INF/spring/aot.factories create mode 100644 ssrf-guard-llm/src/test/java/kr/devslab/ssrfguard/llm/SsrfGuardLlmRuntimeHintsTest.java diff --git a/docs/changelog.ko.md b/docs/changelog.ko.md index 37dc9ea..aec0f8f 100644 --- a/docs/changelog.ko.md +++ b/docs/changelog.ko.md @@ -17,6 +17,14 @@ v3.1.0 작업 추적. 릴리즈된 항목은 태그 시 별도 섹션으로 이 - **`ssrf-guard-springai`가 thin adapter (~30줄)로 리팩토링.** 새 `-llm` 모듈의 `JsonToolInputGuard`에 위임. Public API 변경 없음 — `SsrfGuardedToolCallback`의 모든 생성자/메서드 시그니처 유지. v3.0.x 소비자는 API 변경 못 느낌 — 단지 `ssrf-guard-llm`을 transitive로 받게 됨. +### Added (이어서) + +- **GraalVM native-image 친화성.** `ssrf-guard-llm`이 `RuntimeHintsRegistrar`를 등록 (`META-INF/spring/aot.factories` 통해) — Spring Boot AOT 프로세서가 런타임 reflection surface (매 차단마다 Jackson이 직렬화하는 `SsrfBlockPayload` record, 그리고 `label`이 Jackson에 의해 읽히는 `BlockReason` enum)를 인식. 이전 `Map.of(...)` payload 형태가 JVM-내부 `ImmutableCollections.MapN` 타입을 써서 AOT가 introspect 못 했던 문제 해결. 어댑터 모듈 (`-springai`, `-langchain4j`, 모든 Spring 자동설정)은 Spring Boot 3 AOT가 무료로 처리 — `@Bean` 팩토리 메서드와 `BeanPostProcessor`만 호스팅하므로. + +### Changed (이어서) + +- **에러 payload 형태 안정화.** SSRF 차단 시 LLM이 보는 JSON 객체가 이제 typed `SsrfBlockPayload` record (`{error, reason, url, message, guidance}`) 기반. v3.0.x와 wire 호환 — 동일 필드명, 동일 값. 기존 테스트가 substring 매치로 검증하고 그대로 통과; wire shape에 의존한 스크립트도 변경 불필요. + ### Fixed - **WebClient DNS-time 방어 공백 메움.** v3.0.x의 `ssrf-guard-webclient`는 URL-time 필터만 실행 — 화이트리스트를 통과한 호스트가 DNS 시점에 사설 IP로 resolve될 수 있었음 (전형적인 DNS 리바인딩 → 메타데이터 공격). 새 `SsrfGuardReactorAddressResolverGroup`이 reactor-netty의 `AddressResolverGroup`에 후킹해서 RestClient 모듈이 Apache HttpClient `DnsResolver` 단계에서 적용하는 것과 동일한 사설 IP 필터를 적용. WebFlux 앱도 이제 차단형 RestClient 앱이 갖던 2단계 방어 (URL + DNS) 동등하게 받음. reactor-netty 클래스패스 의존 — 비-Netty WebFlux 백엔드 (Jetty Reactive, Helidon)는 URL-time 필터만 작동하고 connector 교체는 스킵. diff --git a/docs/changelog.md b/docs/changelog.md index 0e722cc..ceb35e6 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -17,6 +17,14 @@ Tracking v3.1.0 work. Released items will move into their own section below when - **`ssrf-guard-springai` refactored to a thin adapter (~30 lines).** Delegates to `JsonToolInputGuard` from the new `-llm` module. Public API unchanged — every constructor and method on `SsrfGuardedToolCallback` keeps the same shape. v3.0.x consumers see no API change; they just pick up `ssrf-guard-llm` transitively. +### Added (continued) + +- **GraalVM native-image friendliness.** `ssrf-guard-llm` registers a `RuntimeHintsRegistrar` (via `META-INF/spring/aot.factories`) so Spring Boot's AOT processor learns about the reflective surface this library uses at runtime: the new `SsrfBlockPayload` record (Jackson-serialised on every block) and the `BlockReason` enum (also Jackson-touched for its `label`). Replaces the previous `Map.of(...)` payload form, which used JVM-private `ImmutableCollections.MapN` types that AOT couldn't introspect. Adapter modules (`-springai`, `-langchain4j`, all Spring autoconfigs) get free AOT coverage from Spring Boot 3 — they only host `@Bean` factory methods and a `BeanPostProcessor`, both of which the AOT processor already handles. + +### Changed (continued) + +- **Error-payload shape stabilised.** The JSON object an LLM sees on an SSRF block is now backed by the typed `SsrfBlockPayload` record (`{error, reason, url, message, guidance}`). Wire-compatible with v3.0.x — same field names, same values. Existing tests assert on substring matches against those names and keep passing; if you scripted around the wire shape you don't need to change anything. + ### Fixed - **WebClient DNS-time defense gap closed.** v3.0.x's `ssrf-guard-webclient` only ran the URL-time filter — a host that passed the whitelist could still resolve to a private IP at DNS time (the classic DNS-rebinding-to-metadata attack). The new `SsrfGuardReactorAddressResolverGroup` plugs into reactor-netty's `AddressResolverGroup` and filters resolved IPs against the same private-IP ranges the RestClient module checks at the Apache HttpClient `DnsResolver` step. WebFlux apps now get the same two-layer defense (URL + DNS) the blocking RestClient apps already had. Gated on reactor-netty being on the classpath — non-Netty WebFlux backends (Jetty Reactive, Helidon) still get the URL-time filter and just skip the connector swap. diff --git a/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/JsonToolInputGuard.java b/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/JsonToolInputGuard.java index 72f2fd5..ec912e0 100644 --- a/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/JsonToolInputGuard.java +++ b/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/JsonToolInputGuard.java @@ -152,14 +152,10 @@ private static boolean looksLikeUrl(String s) { private String formatErrorPayload(SsrfGuardException e, String url) { try { - return MAPPER.writeValueAsString(Map.of( - "error", "ssrf_blocked", - "reason", e.reason().label(), - "url", url, - "message", e.getMessage(), - "guidance", "Refuse the request or ask the user for a different URL. " + - "The blocked URL targets a private/internal network or violates the application's SSRF policy." - )); + // Typed record instead of Map.of(...) — see SsrfBlockPayload + // javadoc for the GraalVM / wire-stability rationale. + return MAPPER.writeValueAsString( + SsrfBlockPayload.of(e.reason().label(), url, e.getMessage())); } catch (Exception jsonErr) { // Fallback — minimal hand-rolled JSON. We control all the field // values here so this stays safe to concatenate. diff --git a/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/SsrfBlockPayload.java b/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/SsrfBlockPayload.java new file mode 100644 index 0000000..799c3c1 --- /dev/null +++ b/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/SsrfBlockPayload.java @@ -0,0 +1,63 @@ +package kr.devslab.ssrfguard.llm; + +/** + * The JSON shape returned to an LLM when a tool input fails the SSRF policy. + * Used as the structured-error payload by {@link JsonToolInputGuard} and any + * other adapter that wants to surface a uniform "I can't fetch that URL" + * message to the model. + * + *

Why a record, not a {@code Map.of(...)}. + *

+ * + *

The JSON keys are part of the library's public contract — well-behaved + * LLMs are prompted to read these field names (especially {@code error} + * and {@code reason}) to decide how to recover. Don't rename. + * + * @param error constant string {@code "ssrf_blocked"} — lets the LLM + * distinguish an SSRF rejection from other tool errors + * @param reason stable {@link kr.devslab.ssrfguard.core.BlockReason#label()} + * enum label — safe to use as a metric tag or log filter + * @param url the URL the policy rejected, copied verbatim from the tool input + * @param message the {@link kr.devslab.ssrfguard.core.SsrfGuardException} + * message text — human-readable explanation of the gate that fired + * @param guidance short prompt fragment guiding the LLM on what to do next + */ +public record SsrfBlockPayload( + String error, + String reason, + String url, + String message, + String guidance +) { + + /** + * Convenience factory that supplies the constant {@code error} value + * and the standard guidance text. Adapters call this instead of the + * full constructor. + */ + public static SsrfBlockPayload of(String reason, String url, String message) { + return new SsrfBlockPayload( + "ssrf_blocked", + reason, + url, + message, + "Refuse the request or ask the user for a different URL. " + + "The blocked URL targets a private/internal network or violates the application's SSRF policy." + ); + } +} diff --git a/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/SsrfGuardLlmRuntimeHints.java b/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/SsrfGuardLlmRuntimeHints.java new file mode 100644 index 0000000..cd6197d --- /dev/null +++ b/ssrf-guard-llm/src/main/java/kr/devslab/ssrfguard/llm/SsrfGuardLlmRuntimeHints.java @@ -0,0 +1,62 @@ +package kr.devslab.ssrfguard.llm; + +import kr.devslab.ssrfguard.core.BlockReason; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; + +/** + * GraalVM native-image hints for {@code ssrf-guard-llm}. + * + *

What Spring Boot 3 AOT already handles automatically, and we therefore + * do not repeat here: + * + *

+ * + *

What we do register: + * + *

+ * + *

Wired through {@code META-INF/spring/aot.factories} so Spring Boot's + * AOT processor picks it up whenever {@code ssrf-guard-llm} is on the + * classpath — no consumer code change needed. + * + *

If you hit native-image build problems with this library, file an + * issue with the {@code native-image} error output — adding hints is + * usually a one-line change here. + */ +public final class SsrfGuardLlmRuntimeHints implements RuntimeHintsRegistrar { + + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + // Record serialised by Jackson. INVOKE_PUBLIC_METHODS covers the + // canonical-record accessors Jackson uses to read each component. + hints.reflection().registerType(SsrfBlockPayload.class, + MemberCategory.INVOKE_PUBLIC_METHODS, + MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, + MemberCategory.DECLARED_FIELDS); + + // Enum reflection — Jackson reads enum constants via reflection. + // PUBLIC_FIELDS + INVOKE_PUBLIC_METHODS covers the standard enum + // surface (name(), values(), the constants themselves). + hints.reflection().registerType(BlockReason.class, + MemberCategory.PUBLIC_FIELDS, + MemberCategory.INVOKE_PUBLIC_METHODS, + MemberCategory.INVOKE_DECLARED_METHODS); + } +} diff --git a/ssrf-guard-llm/src/main/resources/META-INF/spring/aot.factories b/ssrf-guard-llm/src/main/resources/META-INF/spring/aot.factories new file mode 100644 index 0000000..74d72db --- /dev/null +++ b/ssrf-guard-llm/src/main/resources/META-INF/spring/aot.factories @@ -0,0 +1,2 @@ +org.springframework.aot.hint.RuntimeHintsRegistrar=\ + kr.devslab.ssrfguard.llm.SsrfGuardLlmRuntimeHints diff --git a/ssrf-guard-llm/src/test/java/kr/devslab/ssrfguard/llm/SsrfGuardLlmRuntimeHintsTest.java b/ssrf-guard-llm/src/test/java/kr/devslab/ssrfguard/llm/SsrfGuardLlmRuntimeHintsTest.java new file mode 100644 index 0000000..7ad5447 --- /dev/null +++ b/ssrf-guard-llm/src/test/java/kr/devslab/ssrfguard/llm/SsrfGuardLlmRuntimeHintsTest.java @@ -0,0 +1,44 @@ +package kr.devslab.ssrfguard.llm; + +import kr.devslab.ssrfguard.core.BlockReason; +import org.junit.jupiter.api.Test; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies the hints {@link SsrfGuardLlmRuntimeHints} registers — running + * this test without a GraalVM native image still confirms the + * {@code RuntimeHintsRegistrar} adds the right metadata to the hints + * snapshot, so a downstream {@code ./gradlew nativeBuild} won't miss our + * reflective surface. + */ +class SsrfGuardLlmRuntimeHintsTest { + + @Test + void registers_payload_record_for_reflective_serialization() { + RuntimeHints hints = new RuntimeHints(); + new SsrfGuardLlmRuntimeHints().registerHints(hints, getClass().getClassLoader()); + + // Jackson serialises records via INVOKE_PUBLIC_METHODS on the + // accessor methods; AOT must allow that. + assertThat(RuntimeHintsPredicates.reflection() + .onType(SsrfBlockPayload.class) + .withMemberCategory(MemberCategory.INVOKE_PUBLIC_METHODS)) + .accepts(hints); + } + + @Test + void registers_block_reason_enum() { + RuntimeHints hints = new RuntimeHints(); + new SsrfGuardLlmRuntimeHints().registerHints(hints, getClass().getClassLoader()); + + // BlockReason.label() is invoked at serialize time; reflection on + // enum constants must be permitted. + assertThat(RuntimeHintsPredicates.reflection() + .onType(BlockReason.class)) + .accepts(hints); + } +}