From 07d41f1511ecfa30c50175e82b844f8a9b7b4366 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sat, 23 May 2026 19:10:58 +0900 Subject: [PATCH] v3.1: close WebClient DNS-time defense gap (reactor-netty resolver) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3.0.x's ssrf-guard-webclient only ran URL-time validation via the ExchangeFilterFunction. A host that passed the whitelist could still resolve to a private IP at DNS time — the classic DNS-rebinding-to-metadata attack the RestClient module already blocked through Apache HttpClient's DnsResolver step. This closes the gap. SsrfGuardReactorAddressResolverGroup extends Netty's AddressResolverGroup, delegates DNS lookup to the JDK default, then filters resolved InetSocketAddresses against the same NetUtil.isPrivateOrLocal ranges used elsewhere in the project. Wiring SsrfGuardWebClientAutoConfiguration now publishes a ReactorClientHttpConnector bean built on HttpClient.create().resolver(resolverGroup). The existing ssrfWebClientCustomizer picks it up via ObjectProvider and calls .clientConnector(connector) on the builder — additive to the filter(). Both gated: - The connector wiring lives in ReactorNettyConnectorConfiguration (static inner @Configuration) gated by @ConditionalOnClass(HttpClient.class). Non-Netty WebFlux backends (Jetty Reactive, Helidon) keep the URL-time filter, skip the connector swap. - @ConditionalOnMissingBean(ReactorClientHttpConnector.class) so consumers with their own connector (custom pool, proxy, mTLS) win. Those consumers can still attach the resolver themselves via the SsrfGuardReactorAddressResolverGroup bean. Behaviour matrix | Resolved IPs | block-private-networks | Outcome | |---|---|---| | All public | n/a | Pass through | | All private | true (default) | UnknownHostException | | All private | false | Pass through | | Mixed | true | Public ones kept, privates filtered | Tests 6 new tests in SsrfGuardReactorAddressResolverGroupTest covering every behaviour-matrix cell against a stub upstream AddressResolverGroup (no real DNS) — public IP, loopback, AWS metadata link-local, multi-A mixed, all-private, filter-disabled. All 13 webclient tests pass (7 existing + 6 new). --- docs/changelog.ko.md | 4 + docs/changelog.md | 4 + .../SsrfGuardReactorAddressResolverGroup.java | 180 ++++++++++++++++++ .../SsrfGuardWebClientAutoConfiguration.java | 65 ++++++- ...fGuardReactorAddressResolverGroupTest.java | 169 ++++++++++++++++ 5 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 ssrf-guard-webclient/src/main/java/kr/devslab/ssrfguard/webclient/SsrfGuardReactorAddressResolverGroup.java create mode 100644 ssrf-guard-webclient/src/test/java/kr/devslab/ssrfguard/webclient/SsrfGuardReactorAddressResolverGroupTest.java diff --git a/docs/changelog.ko.md b/docs/changelog.ko.md index e07f381..37dc9ea 100644 --- a/docs/changelog.ko.md +++ b/docs/changelog.ko.md @@ -17,6 +17,10 @@ v3.1.0 작업 추적. 릴리즈된 항목은 태그 시 별도 섹션으로 이 - **`ssrf-guard-springai`가 thin adapter (~30줄)로 리팩토링.** 새 `-llm` 모듈의 `JsonToolInputGuard`에 위임. Public API 변경 없음 — `SsrfGuardedToolCallback`의 모든 생성자/메서드 시그니처 유지. v3.0.x 소비자는 API 변경 못 느낌 — 단지 `ssrf-guard-llm`을 transitive로 받게 됨. +### 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 교체는 스킵. + ## [3.0.1] — 메트릭 빈 classpath 게이트 수정 ### Fixed diff --git a/docs/changelog.md b/docs/changelog.md index f042098..0e722cc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -17,6 +17,10 @@ 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. +### 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. + ## [3.0.1] — Fix metrics bean classpath gate ### Fixed diff --git a/ssrf-guard-webclient/src/main/java/kr/devslab/ssrfguard/webclient/SsrfGuardReactorAddressResolverGroup.java b/ssrf-guard-webclient/src/main/java/kr/devslab/ssrfguard/webclient/SsrfGuardReactorAddressResolverGroup.java new file mode 100644 index 0000000..01b5654 --- /dev/null +++ b/ssrf-guard-webclient/src/main/java/kr/devslab/ssrfguard/webclient/SsrfGuardReactorAddressResolverGroup.java @@ -0,0 +1,180 @@ +package kr.devslab.ssrfguard.webclient; + +import io.netty.resolver.AbstractAddressResolver; +import io.netty.resolver.AddressResolver; +import io.netty.resolver.AddressResolverGroup; +import io.netty.resolver.DefaultAddressResolverGroup; +import io.netty.util.concurrent.EventExecutor; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.Promise; +import kr.devslab.ssrfguard.core.BlockReason; +import kr.devslab.ssrfguard.core.NetUtil; +import kr.devslab.ssrfguard.core.NoOpSsrfGuardMetrics; +import kr.devslab.ssrfguard.core.SsrfGuardMetrics; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; + +/** + * DNS-time gate for the reactor-netty {@code HttpClient} that backs Spring's + * {@link org.springframework.web.reactive.function.client.WebClient}. Wraps + * the JDK's default {@link AddressResolverGroup} and filters out any + * resolved {@link InetSocketAddress} whose IP lands in the private / + * loopback / link-local / metadata / CGNAT ranges. + * + *

This closes the v3.0.0 follow-up gap: the existing + * {@link SsrfGuardExchangeFilterFunction} only validates the URL string + * (scheme / host / port / IP-literal / userinfo) before the request leaves. + * The DNS step happens afterwards inside reactor-netty, and at that point a + * host that passed the whitelist could still resolve to {@code 169.254.169.254} + * (DNS rebinding) or to a private RFC-1918 address. Hooking the address + * resolver lets us re-check at the IP level — the same defense-in-depth + * the RestClient module gets through Apache HttpClient's {@code DnsResolver}. + * + *

Design

+ * + * + *

What this does NOT cover

+ * The URL-time check (scheme / whitelist / port / IP literal / userinfo) + * stays in {@link SsrfGuardExchangeFilterFunction} — that runs before + * DNS, so attacks like {@code http://2130706433/} get rejected at the + * filter and never even reach the resolver. The resolver is the + * second-defense layer for whitelisted hosts that resolve to a private IP. + */ +public final class SsrfGuardReactorAddressResolverGroup extends AddressResolverGroup { + + private static final Logger log = LoggerFactory.getLogger(SsrfGuardReactorAddressResolverGroup.class); + + private final AddressResolverGroup delegate; + private final boolean blockPrivate; + private final SsrfGuardMetrics metrics; + + public SsrfGuardReactorAddressResolverGroup(boolean blockPrivate, SsrfGuardMetrics metrics) { + this(DefaultAddressResolverGroup.INSTANCE, blockPrivate, metrics); + } + + /** Test seam — allow injecting a non-default delegate group. */ + public SsrfGuardReactorAddressResolverGroup( + AddressResolverGroup delegate, + boolean blockPrivate, + SsrfGuardMetrics metrics) { + this.delegate = delegate; + this.blockPrivate = blockPrivate; + this.metrics = (metrics == null) ? NoOpSsrfGuardMetrics.INSTANCE : metrics; + } + + @Override + protected AddressResolver newResolver(EventExecutor executor) throws Exception { + AddressResolver raw = delegate.getResolver(executor); + return new FilteringAddressResolver(executor, raw, blockPrivate, metrics); + } + + /** + * Netty {@link AbstractAddressResolver} that delegates real DNS work to + * the JDK resolver and applies the private-IP filter on results. + * + *

Both {@link #doResolve} (single) and {@link #doResolveAll} (all + * answers) are hooked — reactor-netty calls one or the other depending + * on whether multi-A-record fail-over is enabled. + */ + private static final class FilteringAddressResolver extends AbstractAddressResolver { + + private final AddressResolver delegate; + private final boolean blockPrivate; + private final SsrfGuardMetrics metrics; + + FilteringAddressResolver(EventExecutor executor, + AddressResolver delegate, + boolean blockPrivate, + SsrfGuardMetrics metrics) { + super(executor); + this.delegate = delegate; + this.blockPrivate = blockPrivate; + this.metrics = metrics; + } + + @Override + protected boolean doIsResolved(InetSocketAddress address) { + return delegate.isResolved(address); + } + + @Override + protected void doResolve(InetSocketAddress unresolved, Promise promise) { + // ASYNC. doResolve returns; the delegate's future invokes our + // listener on completion, and we set the promise from there. + delegate.resolve(unresolved).addListener((Future future) -> { + if (!future.isSuccess()) { + promise.setFailure(future.cause()); + return; + } + InetSocketAddress resolved = future.getNow(); + if (blockPrivate && NetUtil.isPrivateOrLocal(resolved.getAddress())) { + recordBlock(unresolved.getHostString(), resolved); + promise.setFailure(new UnknownHostException( + "ssrf-guard: blocked private/loopback IP at DNS resolution — " + + resolved.getAddress().getHostAddress())); + } else { + promise.setSuccess(resolved); + } + }); + } + + @Override + protected void doResolveAll(InetSocketAddress unresolved, Promise> promise) { + delegate.resolveAll(unresolved).addListener((Future> future) -> { + if (!future.isSuccess()) { + promise.setFailure(future.cause()); + return; + } + List raw = future.getNow(); + if (!blockPrivate) { + promise.setSuccess(raw); + return; + } + List filtered = new ArrayList<>(raw.size()); + for (InetSocketAddress addr : raw) { + if (!NetUtil.isPrivateOrLocal(addr.getAddress())) { + filtered.add(addr); + } + } + if (filtered.isEmpty()) { + recordBlock(unresolved.getHostString(), raw.isEmpty() ? null : raw.get(0)); + promise.setFailure(new UnknownHostException( + "ssrf-guard: blocked — all resolved IPs are private/loopback for host " + + unresolved.getHostString())); + } else { + promise.setSuccess(filtered); + } + }); + } + + private void recordBlock(String host, InetSocketAddress firstSeen) { + // Best-effort. NoOp metrics in pure non-Spring use; Micrometer + // counter when the autoconfig wires the real bean. + try { + metrics.recordBlocked(BlockReason.BLOCKED_PRIVATE_IP, null, host); + } catch (RuntimeException ignored) { + // metric backends shouldn't break the resolver path + } + log.warn("ssrf-guard: blocked WebClient DNS — host={} resolved to private/loopback (first={})", + host, firstSeen == null ? null : firstSeen.getAddress().getHostAddress()); + } + } +} diff --git a/ssrf-guard-webclient/src/main/java/kr/devslab/ssrfguard/webclient/SsrfGuardWebClientAutoConfiguration.java b/ssrf-guard-webclient/src/main/java/kr/devslab/ssrfguard/webclient/SsrfGuardWebClientAutoConfiguration.java index f534fc7..a513111 100644 --- a/ssrf-guard-webclient/src/main/java/kr/devslab/ssrfguard/webclient/SsrfGuardWebClientAutoConfiguration.java +++ b/ssrf-guard-webclient/src/main/java/kr/devslab/ssrfguard/webclient/SsrfGuardWebClientAutoConfiguration.java @@ -14,7 +14,9 @@ import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; /** * Auto-configuration that wires the SSRF defenses into Spring Boot's @@ -26,6 +28,21 @@ *

  • {@code ssrf.guard.enabled=true} (default).
  • * * + *

    Two defense layers wired here: + *

      + *
    1. {@link SsrfGuardExchangeFilterFunction} — URL-time check on every + * outbound request before reactor-netty even resolves the host.
    2. + *
    3. {@link SsrfGuardReactorAddressResolverGroup} — DNS-time check that + * filters resolved IPs against the private/loopback/metadata ranges. + * Closes the v3.0.0 gap where a whitelisted host could resolve to a + * private IP (DNS rebinding / metadata-server tricks) and the filter + * wouldn't catch it because by then the URL string had passed. v3.1+. + * The {@link ReactorNettyConnectorConfiguration} inner class is + * gated on reactor-netty's presence so non-Netty WebFlux backends + * (Jetty Reactive, Helidon) skip the connector wiring without + * breaking the URL-time defense.
    4. + *
    + * *

    Metrics: Micrometer-backed when {@code micrometer-core} is on the * classpath ({@link MetricsConfiguration} below); no-op otherwise. The * outer class never references Micrometer types, so a consumer without @@ -68,8 +85,52 @@ SsrfGuardExchangeFilterFunction ssrfGuardExchangeFilterFunction(UrlPolicy policy @Bean @ConditionalOnMissingBean(name = "ssrfWebClientCustomizer") - WebClientCustomizer ssrfWebClientCustomizer(SsrfGuardExchangeFilterFunction filter) { - return builder -> builder.filter(filter); + WebClientCustomizer ssrfWebClientCustomizer( + SsrfGuardExchangeFilterFunction filter, + ObjectProvider ssrfReactorConnector) { + return builder -> { + builder.filter(filter); + // Attach our DNS-filtering connector iff the reactor-netty path + // is active. Pulling the connector via ObjectProvider keeps this + // tolerant of non-Netty backends (Jetty Reactive / Helidon). + ReactorClientHttpConnector connector = ssrfReactorConnector.getIfAvailable(); + if (connector != null) { + builder.clientConnector(connector); + } + }; + } + + /** + * Builds the reactor-netty {@link HttpClient} with our DNS-filtering + * resolver attached. Gated on {@link HttpClient} being on the classpath + * so the autoconfig stays compatible with non-Netty WebFlux backends — + * those will boot with just the URL-time filter, no connector swap. + * + *

    Marked {@code @ConditionalOnMissingBean(ReactorClientHttpConnector.class)} + * so consumers who have their own connector (custom pool, proxy, + * mutual TLS) take precedence. Those consumers can still get the DNS + * filter by calling {@code httpClient.resolver(...)} themselves with + * a bean of {@link SsrfGuardReactorAddressResolverGroup}. + */ + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(HttpClient.class) + static class ReactorNettyConnectorConfiguration { + + @Bean + @ConditionalOnMissingBean + SsrfGuardReactorAddressResolverGroup ssrfReactorAddressResolverGroup( + SsrfGuardProperties props, + SsrfGuardMetrics metrics) { + return new SsrfGuardReactorAddressResolverGroup(props.isBlockPrivateNetworks(), metrics); + } + + @Bean + @ConditionalOnMissingBean + ReactorClientHttpConnector ssrfReactorClientHttpConnector( + SsrfGuardReactorAddressResolverGroup resolverGroup) { + HttpClient httpClient = HttpClient.create().resolver(resolverGroup); + return new ReactorClientHttpConnector(httpClient); + } } @Configuration(proxyBeanMethods = false) diff --git a/ssrf-guard-webclient/src/test/java/kr/devslab/ssrfguard/webclient/SsrfGuardReactorAddressResolverGroupTest.java b/ssrf-guard-webclient/src/test/java/kr/devslab/ssrfguard/webclient/SsrfGuardReactorAddressResolverGroupTest.java new file mode 100644 index 0000000..f79751c --- /dev/null +++ b/ssrf-guard-webclient/src/test/java/kr/devslab/ssrfguard/webclient/SsrfGuardReactorAddressResolverGroupTest.java @@ -0,0 +1,169 @@ +package kr.devslab.ssrfguard.webclient; + +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.resolver.AbstractAddressResolver; +import io.netty.resolver.AddressResolver; +import io.netty.resolver.AddressResolverGroup; +import io.netty.util.concurrent.DefaultPromise; +import io.netty.util.concurrent.EventExecutor; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.Promise; +import kr.devslab.ssrfguard.core.NoOpSsrfGuardMetrics; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests the DNS-filtering address resolver against a stub upstream that + * returns a fixed answer set — covers the public IP, private IP, and + * mixed-resolution cases without depending on real DNS. + */ +class SsrfGuardReactorAddressResolverGroupTest { + + private static NioEventLoopGroup eventLoop; + + @BeforeAll + static void startEventLoop() { + eventLoop = new NioEventLoopGroup(1); + } + + @AfterAll + static void stopEventLoop() throws Exception { + eventLoop.shutdownGracefully().await(2, TimeUnit.SECONDS); + } + + /** Stub group that always resolves the unresolved host to a fixed list. */ + private static AddressResolverGroup stubGroup(InetSocketAddress... answers) { + return new AddressResolverGroup<>() { + @Override + protected AddressResolver newResolver(EventExecutor executor) { + return new AbstractAddressResolver<>(executor, InetSocketAddress.class) { + @Override + protected boolean doIsResolved(InetSocketAddress address) { + return !address.isUnresolved(); + } + + @Override + protected void doResolve(InetSocketAddress unresolved, Promise promise) { + promise.setSuccess(answers[0]); + } + + @Override + protected void doResolveAll(InetSocketAddress unresolved, Promise> promise) { + promise.setSuccess(List.of(answers)); + } + }; + } + }; + } + + private static InetSocketAddress addr(String ip, int port) throws UnknownHostException { + return new InetSocketAddress(InetAddress.getByName(ip), port); + } + + @Test + void public_ip_passes_through_single_resolve() throws Exception { + var group = new SsrfGuardReactorAddressResolverGroup( + stubGroup(addr("8.8.8.8", 443)), + true, + NoOpSsrfGuardMetrics.INSTANCE); + AddressResolver resolver = group.getResolver(eventLoop.next()); + + Future future = resolver.resolve(InetSocketAddress.createUnresolved("dns.google", 443)) + .await(); + + assertThat(future.isSuccess()).isTrue(); + assertThat(future.getNow().getAddress().getHostAddress()).isEqualTo("8.8.8.8"); + } + + @Test + void private_ip_fails_single_resolve() throws Exception { + var group = new SsrfGuardReactorAddressResolverGroup( + stubGroup(addr("127.0.0.1", 80)), + true, + NoOpSsrfGuardMetrics.INSTANCE); + AddressResolver resolver = group.getResolver(eventLoop.next()); + + Future future = resolver.resolve(InetSocketAddress.createUnresolved("localhost", 80)) + .await(); + + assertThat(future.isSuccess()).isFalse(); + assertThat(future.cause()) + .isInstanceOf(UnknownHostException.class) + .hasMessageContaining("private/loopback"); + } + + @Test + void aws_metadata_link_local_blocked() throws Exception { + var group = new SsrfGuardReactorAddressResolverGroup( + stubGroup(addr("169.254.169.254", 80)), + true, + NoOpSsrfGuardMetrics.INSTANCE); + AddressResolver resolver = group.getResolver(eventLoop.next()); + + Future future = resolver.resolve(InetSocketAddress.createUnresolved("metadata.attacker", 80)) + .await(); + + assertThat(future.isSuccess()).isFalse(); + assertThat(future.cause()).isInstanceOf(UnknownHostException.class); + } + + @Test + void resolveAll_filters_private_keeps_public() throws Exception { + // Multi-A scenario: host resolves to one public + one private IP. + // Should succeed with only the public IP returned. + var group = new SsrfGuardReactorAddressResolverGroup( + stubGroup(addr("8.8.8.8", 443), addr("10.0.0.5", 443)), + true, + NoOpSsrfGuardMetrics.INSTANCE); + AddressResolver resolver = group.getResolver(eventLoop.next()); + + Future> future = resolver.resolveAll( + InetSocketAddress.createUnresolved("mixed.example.com", 443)).await(); + + assertThat(future.isSuccess()).isTrue(); + assertThat(future.getNow()).hasSize(1); + assertThat(future.getNow().get(0).getAddress().getHostAddress()).isEqualTo("8.8.8.8"); + } + + @Test + void resolveAll_fails_when_all_private() throws Exception { + // Multi-A scenario: every IP is private. Should fail entirely + // rather than connecting to one of them. + var group = new SsrfGuardReactorAddressResolverGroup( + stubGroup(addr("10.0.0.5", 443), addr("192.168.1.1", 443)), + true, + NoOpSsrfGuardMetrics.INSTANCE); + AddressResolver resolver = group.getResolver(eventLoop.next()); + + Future> future = resolver.resolveAll( + InetSocketAddress.createUnresolved("all-private.example.com", 443)).await(); + + assertThat(future.isSuccess()).isFalse(); + assertThat(future.cause()).isInstanceOf(UnknownHostException.class); + } + + @Test + void filtering_disabled_passes_through() throws Exception { + // block-private-networks=false → private IPs pass. + var group = new SsrfGuardReactorAddressResolverGroup( + stubGroup(addr("10.0.0.5", 80)), + false, + NoOpSsrfGuardMetrics.INSTANCE); + AddressResolver resolver = group.getResolver(eventLoop.next()); + + Future future = resolver.resolve(InetSocketAddress.createUnresolved("internal.corp", 80)) + .await(); + + assertThat(future.isSuccess()).isTrue(); + assertThat(future.getNow().getAddress().getHostAddress()).isEqualTo("10.0.0.5"); + } +}