Skip to content

Changelog

All notable changes to ssrf-guard are recorded here.

The format follows Keep a Changelog and the project adheres to Semantic Versioning.

[Unreleased]

[3.3.0] — Redirect-hop parity and scanner scheme coverage

Security

  • Redirect hops now get the same checks as the first request. Each adapter previously improvised what to re-validate on a hop, and they disagreed: httpclient5 re-checked the scheme and re-ran DNS but not port, userinfo or IP-literal rules; jdkhttp re-checked nothing, because the JDK client follows redirects internally and gives no hook. A redirect off an allowlisted host is the shape SSRF actually takes, so a hop with a weaker check than the first request is a hole with extra steps.

    New RedirectGuard in core is the single definition of what a hop must pass — the full UrlPolicy, re-thrown as blocked_redirect. The loop itself cannot move to core the way it does in the JS sibling, because on the JVM each client owns its own redirect loop; the decision does.

    SsrfGuardedHttpClient (jdkhttp) now follows and re-validates redirects itself, with fetch-specification semantics matching the JS sibling: 303 (and 301/302 on POST) downgrade to GET and drop the body, credential headers are stripped when a hop crosses an origin, and maxRedirects (default 5) bounds the chain.

    SafeRedirectStrategy (httpclient5) takes the UrlPolicy and calls the same seam. The three-argument constructor is deprecated; it keeps the pre-3.3.0 scheme-only behaviour so existing code compiles.

    Found by the first JVM ↔ JS parity audit. OkHttp is not covered by this change — its Dns layer still re-checks the host allowlist and private IPs per hop, but scheme, port, userinfo and IP-literal rules are not re-applied. A network interceptor looked like the seam and is not one: OkHttp invokes it after the connection is established, so the request has already reached the internal host. Closing it properly needs the same loop treatment as jdkhttp and is tracked separately.

  • The LLM tool-input scanner no longer drops non-http(s) schemes and protocol-relative URLs at the collection stage. file://, ftp://, gopher:// and any other scheme:// URL in tool input were filtered out before the policy ever saw them, so they passed the guard in silence rather than being rejected by allowedSchemes. Protocol-relative references (//evil.example/x), which inherit the caller's scheme at fetch time and are therefore real fetch targets, were not collected at all.

    Collection is now generous and the policy decides — the same correction made on the JS side in @devslab/ssrf-guard-js 0.2.0. file:// and friends now yield blocked_scheme; //host is validated against the host allowlist as if it resolved to https. Schemes with no authority (mailto:, urn:, data:) are still ignored: they have no host to check and are not fetch surfaces.

    This is the same shape as the uppercase-scheme bypass fixed in 3.1.1 — a filter at the collection stage letting a URL skip validation entirely. Affects ssrf-guard-springai and ssrf-guard-langchain4j.

Migration

One breaking change, in ssrf-guard-jdkhttp. SsrfGuardedHttpClient now requires a delegate built with HttpClient.Redirect.NEVER and throws IllegalArgumentException otherwise, because it follows and validates redirects itself. A delegate that follows them internally would bypass the policy on every hop — so this refuses loudly rather than acting as a guard that quietly does nothing.

HttpClient.newHttpClient() and HttpClient.newBuilder().build() are already NEVER, so most code is unaffected. If you passed Redirect.NORMAL or ALWAYS, drop it: you were getting no redirect validation at all, and you now get it from the wrapper.

Everything else drops in — SafeRedirectStrategy's old constructor still compiles (deprecated), and the scanner change only rejects URLs the policy would always have rejected had it seen them.

[3.2.0] — scanEmbedded: mid-sentence URL scanning for the LLM tool-input guard

Added

  • scanEmbedded — mid-sentence URL scanning for the LLM tool-input guard. JsonToolInputGuard gains an opt-in third constructor argument (and the adapters a matching ssrf.guard.llm.scan-embedded property, default false) that extracts and validates http(s):// URLs buried mid-sentence inside tool-input strings ("summarize http://169.254.169.254/ please") — the shape a prompt-injected instruction typically takes. Strictly additive over the whole-string scanner; trailing prose punctuation is trimmed, balanced parentheses in paths (/wiki/Foo_(bar)) survive, and a URL glued to preceding text (seehttp://evil.com) is still caught. Default behavior unchanged. Ported from the JS sibling @devslab/ssrf-guard-js 0.5.0, where the option originated from AskLinq integration feedback.

Security

  • Tool-input URLs that java.net.URI cannot parse no longer skip validation. Two collection-time gaps fixed in JsonToolInputGuard: (1) a whole-string URL with surrounding whitespace (" http://10.0.0.5/ ") passed the prefix test but failed URI parsing and was silently skipped — candidates are now trimmed before parsing; (2) a URL whose path contains characters browsers send unencoded but java.net.URI rejects (http://10.0.0.5/a[0]) also parse-failed and skipped validation — the guard now retries with everything after the authority dropped, since the policy only judges scheme://authority. Affects ssrf-guard-springai and ssrf-guard-langchain4j.

Migration

Drop in v3.2.0 — no consumer code changes; scanEmbedded is off by default, so existing behavior is unchanged until you opt in via ssrf.guard.llm.scan-embedded=true (auto-wrap) or the new three-argument JsonToolInputGuard constructor (manual wiring). The two parse-time validation-skip fixes apply unconditionally — if a tool input previously passed the guard only because its URL was unparseable, it is now validated.

[3.1.1] — Fix uppercase-scheme bypass in the LLM tool-input guard

Security

  • Uppercase-scheme bypass in the LLM tool-input guard fixed. JsonToolInputGuard collected candidate URLs with a case-sensitive http:// / https:// prefix test, so a tool-input URL written with an uppercase scheme (HTTP://169.254.169.254/…, HtTpS://evil.com/) was never collected and skipped policy validation entirely — bypassing the host allowlist and IP-literal checks. URI schemes are case-insensitive (RFC 3986 §3.1) and the downstream scheme check already used equalsIgnoreCase, so this only affected the collection step. Detection is now case-insensitive. Affects ssrf-guard-springai and ssrf-guard-langchain4j, which both route tool input through this guard.

Migration

Drop in v3.1.1 — no consumer code changes. If your app exposes an LLM tool that takes a URL and you rely on ssrf-guard-springai / ssrf-guard-langchain4j to screen tool input, upgrade to pick up the fix.

[3.1.0] — LLM core extraction, LangChain4j, WebClient DNS gap, GraalVM hints

Added

  • ssrf-guard-llm — new framework-agnostic core module holding the JSON walking + URL extraction + policy validation that every LLM tool adapter shares. Exposes ToolInputGuard (interface) and JsonToolInputGuard (default impl). Holds the ~200 lines of logic that previously lived inside SsrfGuardedToolCallback.
  • ssrf-guard-langchain4j — new adapter module wrapping LangChain4j's ToolExecutor. Closes the same LLM-agent SSRF surface as ssrf-guard-springai but for the LangChain4j ecosystem (the other major Java LLM framework). Auto-wrap via BeanPostProcessor for Spring Boot users; SsrfGuardedToolExecutors.wrap(...) helpers for non-Spring / programmatic users.
  • 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 (Jackson-touched for its label). 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

  • 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.
  • 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. Replaces the previous Map.of(...) form whose JDK-private ImmutableCollections.MapN backing types AOT couldn't introspect. If you scripted around the wire shape you don't need to change anything; substring assertions in existing tests keep passing.

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.

Migration

Drop in v3.1.0 — no consumer code changes. The meta kr.devslab:ssrf-guard:3.1.0 still transitively pulls in -core, -httpclient5, -restclient like v3.0.x. New modules (-llm, -langchain4j) are opt-in; consumers who don't add their coordinate don't pull the new jars.

If you previously caught SecurityException you still match SsrfGuardException (it's still a SecurityException subclass). Catch SsrfGuardException to read the structured e.reason().

For native-image consumers: add kr.devslab:ssrf-guard-llm:3.1.0 (or any module that depends on it transitively) and the AOT processor will pick up the registered hints automatically.

[3.0.1] — Fix metrics bean classpath gate

Fixed

  • ClassNotFoundException: io.micrometer.core.instrument.MeterRegistry when consumers don't have micrometer-core on the classpath. Affected every Spring autoconfig module — -restclient, -resttemplate (via -restclient), -webclient, -feign, -springai — because the metrics bean factory method declared ObjectProvider<MeterRegistry> as a parameter, and the JVM resolves parameter types at class load time even when ObjectProvider would otherwise handle the missing bean gracefully.
  • The fix moves the Micrometer-backed metrics bean into a static inner @Configuration class gated by @ConditionalOnClass(name = "io.micrometer.core.instrument.MeterRegistry") (string form, so Spring's ASM-based condition evaluator inspects the annotation without JVM-loading Micrometer). The outer autoconfig declares a fallback @Bean returning NoOpSsrfGuardMetrics under @ConditionalOnMissingBean(SsrfGuardMetrics.class).

Migration

Drop in v3.0.1 — no consumer code changes. If you previously worked around the v3.0.0 bug by adding io.micrometer:micrometer-core to your build only because of this error (not because you actually wanted metrics), the dep can come out now.

[3.0.0] — Multi-module + LLM agent SSRF defense

The v2.0.0 starter was a single jar that only worked with Spring's RestClient. v3.0.0 splits the codebase along client boundaries, adds support for every common JVM HTTP stack, and ships a Spring AI Tool wrapper that closes the SSRF surface LLM agents have been introducing for the last two years.

Added — new modules (opt-in)

Module Use case
ssrf-guard-core Policy / NetUtil / Micrometer metrics interface — no Spring dependency
ssrf-guard-httpclient5 Apache HttpClient 5 DnsResolver + RedirectStrategy (TOCTOU closure)
ssrf-guard-restclient Spring 6.1+ RestClient autoconfig (the v2.0.0 surface, now its own module)
ssrf-guard-resttemplate NEW — Spring RestTemplate autoconfig for the enterprise/legacy crowd
ssrf-guard-webclient NEW — Spring WebFlux WebClient ExchangeFilterFunction + autoconfig
ssrf-guard-feign NEW — Spring Cloud OpenFeign RequestInterceptor + autoconfig
ssrf-guard-springai NEW — Spring AI ToolCallback wrapper that validates URL-shaped tool arguments before LLM-driven execution. The hot SSRF surface in 2025+
ssrf-guard-jdkhttp NEW — java.net.http.HttpClient wrapper (no Spring, JDK 11+)
ssrf-guard-okhttp NEW — OkHttp Interceptor + Dns (no Spring)
ssrf-guard Meta artifact — bundles -core + -httpclient5 + -restclient for v2.0.0 back-compat

Added — defense-in-depth hardening

  • IP-literal host rejection (ssrf.guard.reject-ip-literal-hosts=true default). Any URL whose host parses as an IP literal in any form — dotted decimal (127.0.0.1), bare decimal (2130706433), hex (0x7f000001), octal (0177.0.0.1), partial (127.1), IPv6 ([::1]) — is rejected at the URL-time check, before DNS. Closes the obfuscated-IP bypass class.
  • Userinfo rejection (ssrf.guard.reject-user-info=true default). URLs of the form https://user:pass@host/... are rejected — known SSRF bypass vector and credential-leak risk.
  • IPv4-mapped IPv6 + 6to4 unmapping. ::ffff:10.0.0.5 and 2002:0a00:: (the 6to4 form wrapping 10.0.0.0/8) are now correctly classified as private, not "public IPv6 that happens to embed an internal v4". Java's isLoopbackAddress() misses these.

Added — observability

  • Micrometer metrics, auto-wired when a MeterRegistry bean is on the classpath:
    ssrf_guard_blocked_total{reason="blocked_private_ip", scheme="http"} 42
    ssrf_guard_allowed_total{scheme="https"} 13042
    
  • Structured WARN logs on every block: ssrf-guard: <message> (reason=blocked_private_ip, scheme=http, host=169.254.169.254).

Changed — BREAKING

  • Package renames. Types moved out of the catch-all kr.devslab.ssrfguard.security package into their respective modules. The ssrf-guard meta artifact re-exports them, so import kr.devslab.ssrfguard.* consumers may need to update imports:
v2.0.0 v3.0.0
kr.devslab.ssrfguard.autoconfigure.SsrfGuardAutoConfiguration kr.devslab.ssrfguard.restclient.SsrfGuardRestClientAutoConfiguration
kr.devslab.ssrfguard.autoconfigure.SsrfGuardProperties kr.devslab.ssrfguard.core.SsrfGuardProperties
kr.devslab.ssrfguard.security.SsrfGuardInterceptor kr.devslab.ssrfguard.restclient.SsrfGuardClientHttpRequestInterceptor
kr.devslab.ssrfguard.security.SafeDnsResolver kr.devslab.ssrfguard.httpclient5.SafeDnsResolver
kr.devslab.ssrfguard.security.SafeRedirectStrategy kr.devslab.ssrfguard.httpclient5.SafeRedirectStrategy
kr.devslab.ssrfguard.security.NetUtil kr.devslab.ssrfguard.core.NetUtil
- SecurityException → SsrfGuardException. All rejection paths now throw SsrfGuardException (still a subclass of SecurityException, so v2.0.0 catch blocks keep working). The exception carries a BlockReason enum tag for metrics / logging.
- New properties. ssrf.guard.reject-ip-literal-hosts and ssrf.guard.reject-user-info default to true — turning them off restores v2.0.0 behaviour on those two checks.

Migration

For most consumers, update the version and rebuild — that's it:

<dependency>
    <groupId>kr.devslab</groupId>
    <artifactId>ssrf-guard</artifactId>
    <version>3.0.0</version>
</dependency>

The ssrf-guard meta artifact transitively pulls in -core, -httpclient5, and -restclient, which together provide the entire v2.0.0 surface.

If you use a different HTTP client, pick the matching module:

<!-- RestTemplate -->
<dependency>
    <groupId>kr.devslab</groupId>
    <artifactId>ssrf-guard-resttemplate</artifactId>
    <version>3.0.0</version>
</dependency>

<!-- WebClient -->
<dependency>
    <groupId>kr.devslab</groupId>
    <artifactId>ssrf-guard-webclient</artifactId>
    <version>3.0.0</version>
</dependency>

<!-- Spring AI tool calls -->
<dependency>
    <groupId>kr.devslab</groupId>
    <artifactId>ssrf-guard-springai</artifactId>
    <version>3.0.0</version>
</dependency>

If your code catches SecurityException from outbound calls, it still works — SsrfGuardException extends SecurityException. If you want the structured tag, catch SsrfGuardException and inspect e.reason().

[2.0.0] — Rebrand to kr.devslab:ssrf-guard

Changed

  • BREAKING — coordinate changed. From com.devs.lab:ssrf-guard-spring-boot-starter to kr.devslab:ssrf-guard. The legacy artifact was never published to Maven Central, so v2.0.0 is the first proper Central release.
  • BREAKING — package renamed. devs.lab.ssrf.* → kr.devslab.ssrfguard.*:
Old New
devs.lab.ssrf.config.SsrfGuardAutoConfiguration kr.devslab.ssrfguard.autoconfigure.SsrfGuardAutoConfiguration
devs.lab.ssrf.security.SsrfGuardProperties kr.devslab.ssrfguard.autoconfigure.SsrfGuardProperties
devs.lab.ssrf.security.SsrfGuardInterceptor kr.devslab.ssrfguard.security.SsrfGuardInterceptor
devs.lab.ssrf.security.SafeDnsResolver kr.devslab.ssrfguard.security.SafeDnsResolver
devs.lab.ssrf.security.SafeRedirectStrategy kr.devslab.ssrfguard.security.SafeRedirectStrategy
devs.lab.ssrf.security.NetUtil kr.devslab.ssrfguard.security.NetUtil
  • BREAKING — SsrfGuardApplication removed. The empty @SpringBootApplication was vestigial scaffolding from the original Spring Initializr template; a starter library has no business carrying a main class.
  • Build system: Maven → Gradle 8.10 with Vanniktech maven-publish 0.30.0. Same convention as easy-paging-spring-boot-starter and api-log.
  • Release flow: semantic-release → tag-triggered Gradle publish. A git tag matching v[0-9]+.[0-9]+.[0-9]+ runs the release workflow, which builds + signs + uploads to Sonatype Central Portal and creates a GitHub Release in one step.

Added

  • CI workflow (.github/workflows/ci.yml) — runs ./gradlew build jacocoTestReport on every push to main and on every PR, uploads coverage to Codecov.
  • Docs site at https://ssrf-guard.devslab.kr/ — installation, quickstart, security model, configuration reference. Built with mkdocs-material + i18n (English + Korean).
  • Bilingual README (README.md / README.ko.md).
  • Full test coverage of every documented defense:
  • NetUtilTest — whitelist matching (exact + suffix), IDN normalisation, private-IP classification across IPv4 (loopback, RFC-1918, link-local incl. AWS metadata, CGNAT, benchmark, broadcast) and IPv6 (ULA, link-local).
  • SafeDnsResolverTest — whitelist gate + private-IP filter, including the "filtered everything" path.
  • SsrfGuardInterceptorTest — scheme/host/port accept/reject matrix, suffix label-boundary lookalike (the classic badexample.com bypass).
  • SsrfGuardAutoConfigurationTest — every public bean of the auto-config is registered when enabled, and none are when ssrf.guard.enabled=false.
  • SsrfGuardIntegrationTest — real HTTP through MockWebServer, end-to-end through the four-layer defense.

Migration

Update your dependency coordinate and any direct imports:

<!-- v1.x (never on Maven Central) -->
<dependency>
    <groupId>com.devs.lab</groupId>
    <artifactId>ssrf-guard-spring-boot-starter</artifactId>
    <version>1.1.0</version>
</dependency>

<!-- v2.0.0 -->
<dependency>
    <groupId>kr.devslab</groupId>
    <artifactId>ssrf-guard</artifactId>
    <version>2.0.0</version>
</dependency>

application.yml keys are unchanged — ssrf.guard.* works identically.

Direct imports of the security types? Replace devs.lab.ssrf with kr.devslab.ssrfguard and split between kr.devslab.ssrfguard.autoconfigure (properties + auto-config) and kr.devslab.ssrfguard.security (interceptor + resolver + redirect + NetUtil).

[1.1.0] — 2025-09-23

semantic-release rollup of pre-v2 work. Tagged but never published to Maven Central.

  • README + releaser templates touched up

[1.0.0] — 2025-09-23

Initial public release. Tagged but never published to Maven Central.

  • First cut of the SSRF starter under com.devs.lab:ssrf-guard-spring-boot-starter

Open source by 데브스랩(DevsLab)