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}. + * + *
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 Two defense layers wired here:
+ * 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 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> promise) {
+ delegate.resolveAll(unresolved).addListener((Future
> future) -> {
+ if (!future.isSuccess()) {
+ promise.setFailure(future.cause());
+ return;
+ }
+ List
+ *
+ *
* > 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
> 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
> 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