Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/changelog.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>This closes the v3.0.0 follow-up gap: the existing
* {@link SsrfGuardExchangeFilterFunction} only validates the URL <i>string</i>
* (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}.
*
* <h2>Design</h2>
* <ul>
* <li><b>Delegate to the JDK resolver.</b> {@link DefaultAddressResolverGroup}
* — same DNS behaviour the consumer would have without us — then
* filter results.</li>
* <li><b>Filter, don't fail per-address.</b> A host with multiple A
* records keeps any public ones; only fails if <i>every</i> resolved
* IP is private.</li>
* <li><b>Block-private toggle.</b> Mirrors {@code ssrf.guard.block-private-networks}
* — same default-on, same property name.</li>
* <li><b>Metrics integration.</b> Records {@link BlockReason#BLOCKED_PRIVATE_IP}
* blocks through the configured {@link SsrfGuardMetrics} so the same
* {@code ssrf_guard_blocked_total} counter the RestClient module
* populates also reflects WebClient blocks.</li>
* </ul>
*
* <h2>What this does NOT cover</h2>
* The URL-time check (scheme / whitelist / port / IP literal / userinfo)
* stays in {@link SsrfGuardExchangeFilterFunction} — that runs <i>before</i>
* 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<InetSocketAddress> {

private static final Logger log = LoggerFactory.getLogger(SsrfGuardReactorAddressResolverGroup.class);

private final AddressResolverGroup<InetSocketAddress> 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<InetSocketAddress> delegate,
boolean blockPrivate,
SsrfGuardMetrics metrics) {
this.delegate = delegate;
this.blockPrivate = blockPrivate;
this.metrics = (metrics == null) ? NoOpSsrfGuardMetrics.INSTANCE : metrics;
}

@Override
protected AddressResolver<InetSocketAddress> newResolver(EventExecutor executor) throws Exception {
AddressResolver<InetSocketAddress> 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.
*
* <p>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<InetSocketAddress> {

private final AddressResolver<InetSocketAddress> delegate;
private final boolean blockPrivate;
private final SsrfGuardMetrics metrics;

FilteringAddressResolver(EventExecutor executor,
AddressResolver<InetSocketAddress> 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<InetSocketAddress> 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<InetSocketAddress> 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<List<InetSocketAddress>> promise) {
delegate.resolveAll(unresolved).addListener((Future<List<InetSocketAddress>> future) -> {
if (!future.isSuccess()) {
promise.setFailure(future.cause());
return;
}
List<InetSocketAddress> raw = future.getNow();
if (!blockPrivate) {
promise.setSuccess(raw);
return;
}
List<InetSocketAddress> 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());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +28,21 @@
* <li>{@code ssrf.guard.enabled=true} (default).</li>
* </ul>
*
* <p>Two defense layers wired here:
* <ol>
* <li>{@link SsrfGuardExchangeFilterFunction} — URL-time check on every
* outbound request before reactor-netty even resolves the host.</li>
* <li>{@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.</li>
* </ol>
*
* <p>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
Expand Down Expand Up @@ -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<ReactorClientHttpConnector> 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.
*
* <p>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)
Expand Down
Loading