Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/changelog.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 교체는 스킵.
Expand Down
8 changes: 8 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>Why a record, not a {@code Map.of(...)}.
* <ul>
* <li><b>GraalVM native-image:</b> a record's component metadata is part
* of the bytecode — Spring Boot's AOT processor (3.x+) registers
* reflection hints automatically. The previous {@code Map.of(...)}
* form used JDK {@code ImmutableCollections.MapN}, whose internals
* are private and inconsistent across JVMs, so AOT couldn't help
* and a downstream native-image build would either fail or strip
* the serialised fields.</li>
* <li><b>Stable wire shape:</b> field declaration order is the JSON
* output order (Jackson honours record component order). With
* {@code Map.of} the iteration order was officially undefined,
* so the JSON shape could drift between JVMs.</li>
* <li><b>Type safety:</b> the five payload fields are now visible to
* the compiler. Renaming one in a hurry no longer breaks the
* contract LLMs read on their next turn.</li>
* </ul>
*
* <p>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."
);
}
}
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>What Spring Boot 3 AOT already handles automatically, and we therefore
* do <b>not</b> repeat here:
*
* <ul>
* <li>{@code @ConfigurationProperties} (our {@code SsrfGuardProperties}).</li>
* <li>{@code @AutoConfiguration} classes (each adapter's autoconfig).</li>
* <li>{@code @Bean} factory methods and {@code @Conditional*} annotations.</li>
* <li>{@link SsrfBlockPayload} — Spring AOT detects record components from
* bytecode and registers reflection hints for serialization without
* us asking. Belt-and-braces registered below anyway in case a
* consumer's chain leaves AOT out of the loop.</li>
* </ul>
*
* <p>What we <b>do</b> register:
*
* <ul>
* <li>{@link BlockReason} enum — Jackson serialises the {@code reason}
* label through reflection on the enum constants. AOT doesn't
* always trace through to a transitively-referenced enum.</li>
* <li>{@link SsrfBlockPayload} record — explicit declaration so consumers
* who use the payload outside Spring (custom dispatcher, MCP server)
* still get correct reflection metadata at native-image build time.</li>
* </ul>
*
* <p>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.
*
* <p>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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
org.springframework.aot.hint.RuntimeHintsRegistrar=\
kr.devslab.ssrfguard.llm.SsrfGuardLlmRuntimeHints
Original file line number Diff line number Diff line change
@@ -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);
}
}