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"); + } +}