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:
httpclient5re-checked the scheme and re-ran DNS but not port, userinfo or IP-literal rules;jdkhttpre-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
RedirectGuardin core is the single definition of what a hop must pass — the fullUrlPolicy, re-thrown asblocked_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(and301/302onPOST) downgrade toGETand drop the body, credential headers are stripped when a hop crosses an origin, andmaxRedirects(default 5) bounds the chain.SafeRedirectStrategy(httpclient5) takes theUrlPolicyand 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
Dnslayer 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 otherscheme://URL in tool input were filtered out before the policy ever saw them, so they passed the guard in silence rather than being rejected byallowedSchemes. 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-js0.2.0.file://and friends now yieldblocked_scheme;//hostis 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-springaiandssrf-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.JsonToolInputGuardgains an opt-in third constructor argument (and the adapters a matchingssrf.guard.llm.scan-embeddedproperty, defaultfalse) that extracts and validateshttp(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-js0.5.0, where the option originated from AskLinq integration feedback.
Security¶
- Tool-input URLs that
java.net.URIcannot parse no longer skip validation. Two collection-time gaps fixed inJsonToolInputGuard: (1) a whole-string URL with surrounding whitespace (" http://10.0.0.5/ ") passed the prefix test but failedURIparsing and was silently skipped — candidates are now trimmed before parsing; (2) a URL whose path contains characters browsers send unencoded butjava.net.URIrejects (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 judgesscheme://authority. Affectsssrf-guard-springaiandssrf-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.
JsonToolInputGuardcollected candidate URLs with a case-sensitivehttp:///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 usedequalsIgnoreCase, so this only affected the collection step. Detection is now case-insensitive. Affectsssrf-guard-springaiandssrf-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. ExposesToolInputGuard(interface) andJsonToolInputGuard(default impl). Holds the ~200 lines of logic that previously lived insideSsrfGuardedToolCallback.ssrf-guard-langchain4j— new adapter module wrapping LangChain4j'sToolExecutor. Closes the same LLM-agent SSRF surface asssrf-guard-springaibut for the LangChain4j ecosystem (the other major Java LLM framework). Auto-wrap viaBeanPostProcessorfor Spring Boot users;SsrfGuardedToolExecutors.wrap(...)helpers for non-Spring / programmatic users.- GraalVM native-image friendliness.
ssrf-guard-llmregisters aRuntimeHintsRegistrar(viaMETA-INF/spring/aot.factories) so Spring Boot's AOT processor learns about the reflective surface this library uses at runtime: the newSsrfBlockPayloadrecord (Jackson-serialised on every block) and theBlockReasonenum (Jackson-touched for itslabel). Adapter modules (-springai,-langchain4j, all Spring autoconfigs) get free AOT coverage from Spring Boot 3 — they only host@Beanfactory methods and aBeanPostProcessor, both of which the AOT processor already handles.
Changed¶
ssrf-guard-springairefactored to a thin adapter (~30 lines). Delegates toJsonToolInputGuardfrom the new-llmmodule. Public API unchanged — every constructor and method onSsrfGuardedToolCallbackkeeps the same shape. v3.0.x consumers see no API change; they just pick upssrf-guard-llmtransitively.- Error-payload shape stabilised. The JSON object an LLM sees on an SSRF block is now backed by the typed
SsrfBlockPayloadrecord ({error, reason, url, message, guidance}). Wire-compatible with v3.0.x — same field names, same values. Replaces the previousMap.of(...)form whose JDK-privateImmutableCollections.MapNbacking 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-webclientonly 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 newSsrfGuardReactorAddressResolverGroupplugs into reactor-netty'sAddressResolverGroupand filters resolved IPs against the same private-IP ranges the RestClient module checks at the Apache HttpClientDnsResolverstep. 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.MeterRegistrywhen consumers don't havemicrometer-coreon the classpath. Affected every Spring autoconfig module —-restclient,-resttemplate(via-restclient),-webclient,-feign,-springai— because the metrics bean factory method declaredObjectProvider<MeterRegistry>as a parameter, and the JVM resolves parameter types at class load time even whenObjectProviderwould otherwise handle the missing bean gracefully.- The fix moves the Micrometer-backed metrics bean into a static inner
@Configurationclass 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@BeanreturningNoOpSsrfGuardMetricsunder@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=truedefault). 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=truedefault). URLs of the formhttps://user:pass@host/...are rejected — known SSRF bypass vector and credential-leak risk. - IPv4-mapped IPv6 + 6to4 unmapping.
::ffff:10.0.0.5and2002: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'sisLoopbackAddress()misses these.
Added — observability¶
- Micrometer metrics, auto-wired when a
MeterRegistrybean is on the classpath: - 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.securitypackage into their respective modules. Thessrf-guardmeta artifact re-exports them, soimport 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-startertokr.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 —
SsrfGuardApplicationremoved. The empty@SpringBootApplicationwas 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 jacocoTestReporton every push tomainand 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 classicbadexample.combypass).SsrfGuardAutoConfigurationTest— every public bean of the auto-config is registered when enabled, and none are whenssrf.guard.enabled=false.SsrfGuardIntegrationTest— real HTTP throughMockWebServer, 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