Spring Boot 4 SSRF 防护源码剖析:InetAddressFilter 挡住内网地址与云元数据

4 阅读30分钟

本文是 Spring Boot 4 系列第 17 篇 | 基于 Spring Boot 4.1.0 源码与官方文档 | 预计阅读 25 分钟。 文末附「三版本能力对照表」与「升级行动项」,落地时可直接对照。文中所有过滤行为(含 IPv6 ULA、NAT64、IPv4-mapped、十进制 IP、CIDR 组合、列表淘汰语义等 50 条断言)均在 Spring Boot 4.1.0 真实源码上编译运行验证通过。


写在前面

一个常见的需求:订单详情页加链接预览,用户在备注里贴了 URL,前端展示标题和缩略图。后端加一个接口:

@GetMapping("/api/preview")
public Preview preview(@RequestParam String url) {
    // 服务端替用户去抓这个 URL
    String html = restClient.get().uri(url).retrieve().body(String.class);
    return parseTitleAndImage(html);
}

这段代码本身没有语法问题,但以下三类请求一来,性质就完全变了:

GET /api/preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
GET /api/preview?url=http://127.0.0.1:8080/actuator/env
GET /api/preview?url=http://192.168.1.10:6379/%0D%0A...

第一个拿云主机上 EC2/ECS 元数据服务的临时凭证,第二个扫本机 Actuator,第三个开始横向探测同 VPC 里的每一个服务。服务端拿着自己的身份、在自己的网络位置上去请求这些地址——攻击者借的是服务端的网络身份,这就是服务端请求伪造(Server-Side Request Forgery,SSRF)。它常年稳居 OWASP Top 10(2021 版 A10),因为它比 SQL 注入更容易被忽略:参数看起来只是个 URL。

传统做法是手写校验:

// 看起来很严谨,其实漏洞百出
if (url.contains("127.0.0.1") || url.contains("localhost") || url.contains("192.168.")) {
    throw new IllegalArgumentException("非法 URL");
}

这段代码至少能绕过五种写法。Spring Boot 4.1 给出的答案是另一个层级的:不在 URL 字符串上做校验,而是在 IP 地址上做过滤:

@Bean
InetAddressFilter httpClientInetAddressFilter() {
    return InetAddressFilter.externalAddresses();   // 只允许公网地址
}

一个 Bean,RestClientRestTemplateWebClient、声明式 HTTP Service 全部自动生效。Spring Boot 4.1 把这个防护能力做进了 spring-boot-http-client 模块(@since 4.1.0)。本文从 InetAddressFilterIpAddressFilteredAddresses 以及四个 HTTP 客户端适配层的源码出发,把"配置怎么写、过滤发生在哪一层、DNS Rebinding 能不能防住"逐层拆开。

内容速览

  • 字符串校验为什么必然漏:Java 的 IP 解析有九种等价写法
  • InetAddressFilter 接口设计:地址级允许清单 + and/or/negate 组合子
  • internalAddresses 的坑:JVM 的 isSiteLocalAddress 判不了 fc00::/7
  • specialPurpose:一份 RFC 6890 的 CIDR 清单,覆盖云元数据网段
  • 两种用法:HttpClientSettings 精确到单客户端,一个 Bean 覆盖全部
  • filter 没有配置属性,不是 application.yml 里的一行配置
  • 全局 Bean 的生产陷阱:会拦住内网服务调用
  • simple() 客户端不支持过滤,启动即抛异常
  • 四个客户端适配层:JDK 走 ProxySelector,其余三个走 DNS 解析钩子
  • DNS Rebinding:三个实现结构性免疫,JDK 实现是强缓解
  • 手写校验与 externalAddresses 的覆盖面对比
  • Boot 3.x → 4.0 → 4.1 能力对照与升级行动项

一、为什么字符串校验必然漏——先看 Java 的 IP 解析有多"宽容"

在讲设计之前,先把"手写校验为什么不行"说清。把下面这些地址逐个传给 JDK 21 的 InetAddress.getByName(),实测结果如下(java 单文件直接运行,无第三方依赖):

输入的 host 字符串getByName 解析结果是否回环手写 contains("127.0.0.1") 能拦住吗
127.0.0.1127.0.0.1
127.1.1.1127.1.1.1漏(整个 127.0.0.0/8 都是回环)
127.1127.0.0.1漏(短形式)
2130706433127.0.0.1漏(十进制单整数形式)
::ffff:127.0.0.1127.0.0.1(归一化为 Inet4Address)漏(IPv4-mapped IPv6)
::1::1(getHostAddress() 打印为 0:0:0:0:0:0:0:1)漏(IPv6 回环)
0.0.0.00.0.0.0否(但同样危险)
0177.0.0.1177.0.0.1—(Java 不按八进制解析,0177 就是十进制 177,反而是公网地址)
0x7f000001UnknownHostException—(Java 不支持十六进制)

(以上均为本机 JDK 21 实测输出。)

几条结论:

  1. 同一个内网地址有无数种写法,只要校验发生在"字符串层",攻击者总能找到一个没列进黑名单的等价写法。127.12130706433::ffff:127.0.0.1 在 Java 里统统等价于 127.0.0.1
  2. 黑名单天然是失败的。内网地址空间是一张巨大的 CIDR 列表(RFC1918、CGNAT、链路本地、IPv6 ULA……),不可能列全;而允许清单只需要一句"只放行公网"。
  3. 域名才是真正的难点http://evil.com 完全可以让 DNS 解析到 127.0.0.1——攻击者控制的域名指向哪里,字符串校验根本无从判断。

所以正确的校验点只有一个:DNS 解析之后、建立连接之前的那个 IP 地址。这也正是 InetAddressFilter 的位置——接口名就叫 InetAddress(网络地址),不是 Url,不是 Host


二、InetAddressFilter 是什么:地址级允许清单 + 组合子

2.1 接口本体:一个函数式接口

module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/InetAddressFilter.java(@since 4.1.0):

@FunctionalInterface
public interface InetAddressFilter {

	/**
	 * Determine whether the given socket address matches.
	 * @param address the socket address string to check
	 * @return if the address matches
	 */
	default boolean matches(InetSocketAddress address) {
		Assert.notNull(address, "'address' must not be null");
		return matches(address.getAddress());
	}

	/**
	 * Whether the given address matches.
	 */
	boolean matches(InetAddress address);
}

设计上有三个关键点:

第一,语义是"匹配 = 放行"(allow-list),不是"匹配 = 拒绝"。 这是安全设计的基本纪律:默认拒绝,显式放行。写过滤规则时不要搞反,否则一个空过滤器 none() 就会变成"全部放行"。

第二,提供 InetSocketAddress 重载,是为了适配 InetSocketAddress 形态的地址列表。 Reactor Netty、Jetty 拿到的解析结果都是 InetSocketAddress(带端口),这个 default 方法让适配层不用自己拆包。

第三,Assert.notNull(address, ...) 保证 null 直接抛 IllegalArgumentException,而不是静默返回 false。 这一点在安全组件里非常重要——静默放过(fail-open)是 SSRF 防护最典型的实现事故,Spring 选择 fail-fast。

2.2 工厂方法:从"一个地址"到"一整类地址"

接口里提供了完整的工厂方法与组合子,全部是静态/默认方法,无需任何实现类:

// —— 构造:支持单个 IP 与 CIDR 网段,IPv4/IPv6 均可 ——
InetAddressFilter.of("192.168.1.1")
InetAddressFilter.of("192.168.0.0/24")
InetAddressFilter.of("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")

// —— 组合:and / or / andNot / negate / not ——
InetAddressFilter.of("192.168.0.0/24").andNot("192.168.0.1")
InetAddressFilter.of("8.8.8.8").or("1.1.1.1")
InetAddressFilter.of("8.8.8.8").negate()          // 逻辑取反
InetAddressFilter.not(InetAddressFilter.externalAddresses())  // all().andNot(...)

// —— 边界:all / none ——
InetAddressFilter.all()    // 全部放行(等价于不过滤)
InetAddressFilter.none()   // 全部拒绝

// —— 预设:三个"半成品",用来拼装 ——
InetAddressFilter.routable()         // 非全零地址
InetAddressFilter.multicast()        // 组播地址
InetAddressFilter.specialPurpose()   // RFC 6890 特殊用途地址

// —— 两个开箱即用的成品 ——
InetAddressFilter.externalAddresses()  // 只放行"公网"地址
InetAddressFilter.internalAddresses()  // 只放行"内网"地址

// —— 适配你自己的判断逻辑 ——
InetAddressFilter.adapt(predicate)

组合子是短路求值的(源码里就是 && / ||),下面这段源码展示了 and 的实现方式,注意它每次组合都产生一个新的 lambda,原过滤器保持不变(不可变、可安全复用):

default InetAddressFilter and(Collection<? extends InetAddressFilter> filters) {
	InetAddressFilter result = this;
	for (InetAddressFilter filter : filters) {
		InetAddressFilter ours = result;
		result = (address) -> ours.matches(address) && filter.matches(address);
	}
	return result;
}

andNot 与它的唯一区别就是末尾那个 !:

result = (address) -> ours.matches(address) && !filter.matches(address);

2.3 两个"成品"过滤器的源码

日常最常用的就是这两个静态工厂,它们的实现非常短,但信息量很大:

/**
 * Return a filter that will match external (non-private) IP addresses. External
 * addresses are all {@link #routable() routable} addresses that are not:
 * <ul>
 * <li>{@link #multicast() multicast}</li>
 * <li>{@link #specialPurpose() special purpose}</li>
 * </ul>
 */
static InetAddressFilter externalAddresses() {
	return routable().andNot(multicast(), specialPurpose());
}

/**
 * Return a filter that will match internal (private) IP addresses.
 */
static InetAddressFilter internalAddresses() {
	return routable().and(InternalInetAddressFilter.instance);
}
过滤器语义等价表达
externalAddresses()可路由 且 非组播 且 非特殊用途"只允许公网"
internalAddresses()可路由 且 是内网地址"只允许内网"

routable() 的作用是排掉 0.0.0.0::——这两个全零地址在 JVM 的 InetAddress 里既不算回环、也不算链路本地、也不算站点本地,很容易漏:

static InetAddressFilter routable() {
	return (address) -> {
		Assert.notNull(address, "'address' must not be null");
		byte[] bytes = address.getAddress();
		for (byte b : bytes) {
			if (b != 0) {
				return true;
			}
		}
		return false;
	};
}

2.4 内网判定:为什么不能直接信 isSiteLocalAddress()

InternalInetAddressFilter(包级私有)负责"什么算内网",它的源码里有一个重要的坑:

final class InternalInetAddressFilter implements InetAddressFilter {

	private static final byte[] NAT64_PREFIX = { (byte) 0x00, (byte) 0x64, (byte) 0xff, (byte) 0x9b };

	static final InternalInetAddressFilter instance = new InternalInetAddressFilter();

	@Override
	public boolean matches(InetAddress address) {
		Assert.notNull(address, "'address' must not be null");
		return isLocal(address) || isSiteLocalIpv6Address(address.getAddress());
	}

	/**
	 * Check for Unique Local IPv6 Addresses. We cannot rely on
	 * {@code Inet6Address.isSiteLocalAddress()} because the JVM implementation dictates
	 * that {@code fec0::/10} is the only site-local IPv6 address space, based on the
	 * outdated RFC 2373. That RFC was deprecated by the IETF in 2004 in favor of
	 * {@code fc00::/7} (RFC 4193). To keep our private network checking accurate to
	 * modern subnets, we maintain manual parsing.
	 */
	private boolean isSiteLocalIpv6Address(byte[] address) {
		return (address.length == 16)
				&& (address[0] == (byte) 0xfc || address[0] == (byte) 0xfd || isNat64Local(address));
	}

	private boolean isLocal(InetAddress address) {
		return address.isLoopbackAddress() || address.isLinkLocalAddress() || address.isSiteLocalAddress();
	}
}

注释里说的这个坑,实测验证最直观(JDK 21,实测输出):

IPv6 地址isSiteLocalAddress()Inet6Address 的判定依据现代 IPv6 私网的真实含义
fec0::1trueRFC 2373(2004 年已废弃)的站点本地 fec0::/10早已废弃的网段
fc00::1false不在 JVM 的站点本地判定内ULA 唯一本地地址,是私网
fd00::1false同上同上,且是最常用的那一段
fe80::1false(但 isLinkLocalAddress() = true)链路本地私网

也就是说,如果只依赖 JVM 的 isSiteLocalAddress(),fc00::/7 这两个最常用的 IPv6 私网段会被当成公网放行。Spring 选择手工按首字节判断(0xfc / 0xfd),并在注释里写清了理由——这是一个"不要相信过时标准实现"的标准案例。

另外注意 isNat64Local():它先比对前 4 字节是否等于 64:ff9b,命中后取地址的最后 4 字节(Arrays.copyOfRange(address, 12, 16),也就是内嵌的那个 IPv4 地址),再调一次 isLocal()。因为 NAT64 会把 IPv6 映射成 IPv4,攻击者完全可以用 64:ff9b::127.0.0.1 这种写法去打内网。实测确认:externalAddresses() 会拦截 64:ff9b::127.0.0.1

2.5 specialPurpose():一份 RFC 6890 的落地清单

externalAddresses() 排除了 specialPurpose(),后者的实现就是一份 CIDR 清单:

static InetAddressFilter specialPurpose() {
	return of("0.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "192.0.0.0/24", "192.0.0.0/29",
			"192.0.2.0/24", "192.88.99.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "240.0.0.0/4",
			"255.255.255.255/32", "::1/128", "::/128", "64:ff9b::/96", "100::/64", "2001::/23", "2001::/32",
			"2001:2::/48", "2001:db8::/32", "2001:10::/28", "2002::/16", "fc00::/7", "fe80::/10")
			.or(InternalInetAddressFilter.instance);
}

对照 RFC 6890 的用途:

网段用途为什么必须拦
0.0.0.0/8本网络全零地址
127.0.0.0/8回环打本机服务
169.254.0.0/16链路本地169.254.169.254 云元数据服务(AWS/GCP/Azure 都在这里)
100.64.0.0/10CGNAT 运营商级 NAT公网 100.64.x.x 在云 VPC 里常被内部使用
192.0.0.0/24192.0.0.0/29IETF 协议分配特殊用途
192.0.2.0/24198.51.100.0/24203.0.113.0/24TEST-NET-1/2/3文档示例网段
192.88.99.0/246to4 中继特殊用途
198.18.0.0/15基准测试特殊用途
240.0.0.0/4255.255.255.255/32保留 / 广播特殊用途
::1/128::/128IPv6 回环 / 未指定打本机
64:ff9b::/96NAT64可映射到内网 IPv4
100::/64丢弃前缀特殊用途
2001::/232001::/322001:2::/482001:db8::/322001:10::/28Teredo / 基准 / 文档 / ORCHID特殊用途
2002::/166to4可携带 IPv4 地址
fc00::/7IPv6 ULAIPv6 私网
fe80::/10IPv6 链路本地私网
(追加)InternalInetAddressFilterRFC1918 三段 + 回环 + 链路本地兜住全部 IPv4 私网

注意最后一行:specialPurpose()or 了一个 InternalInetAddressFilter.instance。所以 externalAddresses() 的覆盖面是"RFC6890 特殊用途 ∪ 全部内网地址"——比大多数人手写的黑名单严格得多。顺带一提,正因为包含了 TEST-NET 网段,它也会拦掉 192.0.2.1 这类文档地址,写单测时如果用文档地址做"公网"样本会踩坑。

2.6 CIDR 是怎么匹配的——IpAddress

of("192.168.0.0/24") 背后的解析类同样值得一看,因为它的准入校验本身就是一道防线:

static InetAddress parseInetAddress(String address) {
	Assert.isTrue(isLikelyIpAddress(address),
			() -> "'address' [%s] must be an IP address and not a host name".formatted(address));
	try {
		return InetAddress.getByName(address);
	}
	catch (UnknownHostException ex) {
		throw new IllegalArgumentException("'address' [%s] must be parsable to an InetAddress".formatted(address), ex);
	}
}

只接受 IP 字面量,拒绝域名。 实测 InetAddressFilter.of("example.com") 直接抛:

java.lang.IllegalArgumentException: 'address' [example.com] must be an IP address and not a host name

这个设计是对的:如果允许在过滤器里写域名,就相当于把"域名 → 地址"的判断留给了配置者,而 DNS 是可变的——等于把刚堵上的洞又开回来。

掩码匹配部分按位与,IPv4/IPv6 通吃:

private boolean matchesMasked(byte[] ours, byte[] theirs) {
	boolean result = true;
	for (int i = 0; i < ours.length; i++) {
		int remain = Math.max(this.maskBitSize - (i * 8), 0);
		byte mask = (byte) ((remain < 8) ? 0xFF << (8 - remain) : 0xFF);
		result = result && (ours[i] & mask) == (theirs[i] & mask);
	}
	return result;
}

maskBitSize == -1 表示没写 /,即精确匹配单个地址;maskBitSize == 0 表示 /0,匹配所有地址。


三、怎么用:两种写法,以及一个必须澄清的"配置陷阱"

3.1 写法一:跟着 HttpClientSettings 走(精确到某一个客户端)

这是官方文档 io/rest-client.adoc 中的示例(MyService,文档源码直接引用):

@Service
public class MyService {

	private final RestClient restClient;

	public MyService() {
		InetAddressFilter onlyExternalAddresses = InetAddressFilter.externalAddresses();
		HttpClientSettings settings = HttpClientSettings.defaults().withInetAddressFilter(onlyExternalAddresses);
		ClientHttpRequestFactory requestFactory = ClientHttpRequestFactoryBuilder.jdk().build(settings);
		this.restClient = RestClient.builder().requestFactory(requestFactory).baseUrl("https://example.org").build();
	}
}

HttpClientSettings 是个 record(@since 3.5.0),4.1 新增了第 6 个组件 inetAddressFilter:

public record HttpClientSettings(@Nullable HttpCookieHandling cookieHandling, @Nullable HttpRedirects redirects,
		@Nullable Duration connectTimeout, @Nullable Duration readTimeout, @Nullable SslBundle sslBundle,
		@Nullable InetAddressFilter inetAddressFilter) {

配套的 withInetAddressFilter(...)@since 4.1.0:

/**
 * Return a new {@link HttpClientSettings} instance with an updated inetAddress
 * filter.
 * @since 4.1.0
 */
public HttpClientSettings withInetAddressFilter(@Nullable InetAddressFilter inetAddressFilter) {
	return new HttpClientSettings(this.cookieHandling, this.redirects, this.connectTimeout, this.readTimeout,
			this.sslBundle, inetAddressFilter);
}

这种写法适合"只有某一个客户端需要限制地址"(比如只有 URL 预览这一个功能抓用户传的 URL,其余内部调用不受影响)。

3.2 写法二:一个 Bean 覆盖全部自动配置的客户端

官方文档推荐的全局写法(MyHttpClientConfiguration):

@Configuration(proxyBeanMethods = false)
public class MyHttpClientConfiguration {

	@Bean
	public InetAddressFilter httpClientInetAddressFilter() {
		return InetAddressFilter.of("192.168.1.0/24").andNot("192.168.1.1", "192.168.1.10");
	}
}

为什么一个 Bean 就能全局生效?看 HttpClientAutoConfiguration(@since 4.0.0,4.1 增加了 filter 入参):

@AutoConfiguration(after = SslAutoConfiguration.class)
@EnableConfigurationProperties(HttpClientsProperties.class)
public final class HttpClientAutoConfiguration {

	@Bean
	@ConditionalOnMissingBean
	HttpClientSettings httpClientSettings(HttpClientsProperties properties,
			ObjectProvider<SslBundles> sslBundlesProvider,
			ObjectProvider<InetAddressFilter> inetAddressFilterProvider) {
		InetAddressFilter inetAddressFilter = inetAddressFilterProvider.getIfAvailable();
		HttpClientSettings settings = (inetAddressFilter != null)
				? HttpClientSettings.defaults().withInetAddressFilter(inetAddressFilter)
				: HttpClientSettings.defaults();
		HttpClientSettingsPropertyMapper propertyMapper = new HttpClientSettingsPropertyMapper(
				sslBundlesProvider.getIfAvailable(), settings);
		return propertyMapper.map(properties);
	}
}

链路很清晰:

你声明的 InetAddressFilter @Bean
  → ObjectProvider<InetAddressFilter>.getIfAvailable()
  → HttpClientSettings(默认设置 + filter)
  → HttpClientSettingsPropertyMapper.map(properties) 合并 spring.http.clients.*
  → 容器里唯一的 HttpClientSettings Bean
  → RestClient.Builder / RestTemplateBuilder / WebClient / HTTP Service Client
     各自的自动配置拿它去 build(settings)
  → 底层 HTTP 客户端装上过滤适配器

这里有个容易看漏的细节:HttpClientSettingsPropertyMapper.map() 的最后一行是

return settings.orElse(this.settings);

orElse 是逐字段"本地有值优先、否则取另一个"的合并:

InetAddressFilter inetAddressFilter = (inetAddressFilter() != null) ? inetAddressFilter()
		: other.inetAddressFilter();

也就是说,map() 里先构造了一个只装了属性的 settings,再用 orElse 合并回"带 filter 的基准设置"——spring.http.clients.* 属性不会把 filter 覆盖掉。这是"Bean + 配置文件混用"能正常工作的原因。

3.3 必须澄清:filter 没有配置属性,不是 application.yml 里的一行配置

有一个常见误解需要精确澄清,因为这关系到能不能真正落地。

spring.http.clients.* 下与"客户端通用设置"相关的属性只有这几项(即 HttpClientSettingsProperties 的全部字段):

spring:
  http:
    clients:
      connect-timeout: 2s
      read-timeout: 1s
      redirects: dont-follow
      cookie-handling: disable
      ssl:
        bundle: my-bundle
      imperative:
        factory: jdk          # http-components / jetty / reactor / jdk / simple
      reactive:
        connector: reactor    # reactor / jetty / http-components / jdk

没有 inet-address-filter 原因也合理:过滤器是一段逻辑(CIDR 列表、与/或/非组合、甚至自定义 Predicate),不是标量配置项,绑定到字符串属性上会立刻退化成第一节里说的"字符串校验"。

顺带把 Boot 4 里"选客户端"的属性也一并说清(Boot 3 的 spring.http.client.factory 在 4.x 已不存在):命令式用 spring.http.clients.imperative.factory(取值 http-components / jetty / reactor / jdk / simple),响应式用 spring.http.clients.reactive.connector(取值 reactor / jetty / http-components / jdk)。两者的枚举定义分别在 ImperativeHttpClientsProperties.FactoryReactiveHttpClientsProperties.Connector 中,每个枚举值直接映射到对应的 builder 工厂方法。

所以正确的说法是:一个 @Bean(方法体一行)覆盖全部自动配置的客户端,而不是一行 yml。

3.4 全局 Bean 的生产陷阱:它会拦住你对内网服务的正常调用

这一点必须单独强调,因为它是"全局 Bean"写法最容易出的事故:

@Bean
InetAddressFilter inetAddressFilter() {
    return InetAddressFilter.externalAddresses();   // 全局:只允许公网
}

如果应用同时还有内部服务调用——比如通过 RestClient 调 http://order-service:8080http://config-server:8888,或者访问内网的 S3 兼容存储——这些域名会解析到 10.x / 192.168.x / 172.16.x,全部会被这个全局过滤器拦掉,接口直接报 FilteredHostException,而且是在运行期才炸。

正确做法二选一:

  1. 全局过滤器写成"拦内网"的黑名单 + 显式放行白名单,而不是一刀切的 externalAddresses():
@Bean
InetAddressFilter inetAddressFilter() {
    // 放行内部服务所在网段,其余内网地址一律拒绝
    return InetAddressFilter.not(InetAddressFilter.internalAddresses()
        .andNot("10.10.0.0/16"));     // 10.10/16 是内部服务网段
}
  1. 全局不设过滤器,只在"处理用户输入 URL"的那一个客户端上通过 HttpClientSettings 精确加装(即 3.1 的写法一)。

建议 2 优先:安全边界应该画出"哪个入口危险",而不是"整个应用都危险"。一刀切的全局策略会让后续任何一次内网调用都变成一次线上故障。

3.5 一个启动期就会炸的坑:simple() 客户端不支持过滤

如果显式指定了 Simple 客户端:

spring:
  http:
    clients:
      imperative:
        factory: simple   # 或代码里 ClientHttpRequestFactoryBuilder.simple()

再配上 InetAddressFilter,启动(准确说是构建 request factory)时就会抛:

java.lang.IllegalStateException: Simple HTTP request factory does not support InetAddress filtering

源码就在 SimpleClientHttpRequestFactoryBuilder:

@Override
protected SimpleClientHttpRequestFactory createClientHttpRequestFactory(HttpClientSettings settings) {
	Assert.state(settings.cookieHandling() != HttpCookieHandling.ENABLE,
			"Simple HTTP request factory does not support HTTP cookie handling");
	Assert.state(settings.inetAddressFilter() == null,
			"Simple HTTP request factory does not support InetAddress filtering");
	...
}

原因是 SimpleClientHttpRequestFactory 底层是 HttpURLConnection,没有暴露 DNS 解析钩子,装不了过滤器。这是 fail-fast 而非静默降级——安全组件应该这样,静默忽略过滤器的后果比启动失败严重得多。

所以:要用 SSRF 防护,别用 Simple 客户端。默认情况下(spring-boot-starter-restclient 不带任何 HTTP 客户端库)检测链路会落到 JDK 客户端,是支持过滤的。


四、源码:过滤到底发生在哪一层?

这是本文的核心。InetAddressFilter 只是"判断逻辑",真正让它生效的是四个 HTTP 客户端的适配层。每个客户端暴露的扩展点不一样,Spring 为每种客户端挑了它自己最合适的那个钩子

                    ┌───────────────────────┐
                    │  InetAddressFilter    │  ← 你写的过滤逻辑(唯一)
                    └───────────┬───────────┘
        ┌───────────────┬───────┴────────┬──────────────────┐
        ▼               ▼                ▼                  ▼
  JDK HttpClient  Apache HC5        Jetty Client     Reactor Netty
  ProxySelector   DnsResolver      SocketAddress-   ResolvedAddress-
  (JdkFiltered-   (HttpComponents  Resolver         Selector
   ProxySelector)  FilteredDns-    (JettyFiltered-  (ReactorFiltered-
                   Resolver)        SocketAddress-   ResolvedAddress-
                                    Resolver)        Selector)
        │               │                │                  │
        └───────────────┴────────────────┴──────────────────┘
                                │
                    ┌───────────▼───────────┐
                    │  FilteredAddresses    │  过滤地址列表
                    │  + FilteredHostException │  全被过滤 → 抛异常
                    └───────────────────────┘

4.1 公共地基:FilteredAddresses

四个适配层共用同一段"过滤 + 抛异常"逻辑。核心语义在 FilteredAddresses 里:

Filtered<List<T>> toList() {
	return new Filtered<>(this.stream.toList(), List::isEmpty);
}

static <T> FilteredAddresses<T> of(Stream<T> stream, Predicate<? super T> predicate) {
	return new FilteredAddresses<>(stream.filter(Objects::nonNull).filter(predicate));
}

static final class Filtered<T> {
	T orElseThrow(Supplier<String> host, InetAddressFilter filter) {
		if (this.result == null || this.check.test(this.result)) {
			throw new FilteredHostException(host.get(), filter);
		}
		return this.result;
	}
}

这段代码定义了一个重要的语义:过滤是"列表淘汰",不是"全有全无"。

  • 一次 DNS 解析返回多个地址时,不匹配的地址被逐个剔除,匹配的保留;
  • 只有全部地址都被剔除(列表为空)时,才抛 FilteredHostException

也就是说,a.com 同时解析到 8.8.8.8127.0.0.1 时,客户端会拿着"只剩下 8.8.8.8"的列表去连接。注意这条"列表淘汰"只发生在拿到完整解析结果的那三个实现上(Apache HC5 / Jetty / Reactor Netty);JDK 适配层走的是另一个分支 get()——它的流里只有一个 host 字符串,压根没有"列表"可淘汰(见 4.2 的边界说明)。实测验证了列表淘汰这个行为:

=== FilteredAddresses: 部分匹配保留 / 全不匹配抛异常 ===
[PASS] 混合列表(8.8.8.8+127.0.0.1) 只保留 1 个
[PASS] 保留的是 8.8.8.8
[PASS] 全被过滤时抛 FilteredHostException
        message: Filtered host 'internal.example.com' | host=internal.example.com

抛出的是 FilteredHostException(@since 4.1.0,包构造器,由 Spring 内部抛出),携带两个信息:

public class FilteredHostException extends RuntimeException {

	private final String host;

	private final InetAddressFilter filter;

	FilteredHostException(String host, InetAddressFilter filter) {
		super("Filtered host '%s'".formatted(host));
		this.host = host;
		this.filter = filter;
	}
	// getHost() / getFilter()
}

getFilter() 这个设计很实用:线上排查时能直接知道"是哪个过滤器拦的",而不是只看到一个 host。

4.2 适配一:JDK HttpClient → ProxySelector

JDK 自带的 java.net.http.HttpClient 没有暴露 DNS 解析钩子,Spring 借用了它唯一一个"每个请求建立前一定会调用"的钩子:ProxySelector.select(URI)

JdkHttpClientBuilder.build(settings):

public HttpClient build(@Nullable HttpClientSettings settings) {
	...
	map.from(proxySelector(settings.inetAddressFilter())).to(builder::proxy);
	this.customizer.accept(builder);
	return builder.build();
}

private @Nullable ProxySelector proxySelector(@Nullable InetAddressFilter filter) {
	return (filter != null) ? new JdkFilteredProxySelector(this.proxySelector, filter) : this.proxySelector;
}

没有过滤器时用 this.proxySelector(默认是 ProxySelector.getDefault());有过滤器时就地包一层。

JdkFilteredProxySelector 的全部安全逻辑就是先校验、再委托:

@Override
public List<Proxy> select(URI uri) {
	String host = uri.getHost();
	FilteredAddresses.of(Stream.of(host), this::matchesResolvedHost).get().orElseThrow(host, this.filter);
	return this.delegate.select(uri);
}

private boolean matchesResolvedHost(String host) {
	InetAddress resolved = resolve(host);
	return (resolved != null) && this.filter.matches(resolved);
}

private @Nullable InetAddress resolve(String host) {
	try {
		// We follow the same resolution logic as
		// jdk.internal.net.http.HttpRequestImpl.getAddress()
		return InetAddress.getByName(host);
	}
	catch (UnknownHostException ex) {
		return null;
	}
}

三个要点:

  1. 它不改变代理行为,select() 照常返回 delegate 的代理列表——过滤器在这里只是借这个钩子做校验。
  2. 源码注释明确写了"follow the same resolution logic as jdk.internal.net.http.HttpRequestImpl.getAddress()",即刻意与 JDK 内部解析路径保持一致(都走 InetAddress,共享 JVM 的 DNS 缓存)。
  3. UnknownHostExceptionresolve 返回 null → 校验失败 → 抛 FilteredHostException。解析不了的域名一律拒绝(fail-closed)。

这个适配层的边界(重点,后面 DNS Rebinding 一节要用):它是四个实现里唯一"校验与连接分离"的。 InetAddress.getByName(host) 在 JDK 语义下只返回解析结果中的第一个地址,而真正建立连接是 JDK 客户端内部另一次动作。其他三个实现都是"在完整解析结果列表上过滤,返回的列表直接用于连接"。

4.3 适配二:Apache HttpClient 5 → DnsResolver

Apache HC5 有正统的 DnsResolver 扩展点,这是四个实现里最直接的一个。

private PoolingHttpClientConnectionManager createConnectionManager(HttpClientSettings settings) {
	PoolingHttpClientConnectionManagerBuilder builder = PoolingHttpClientConnectionManagerBuilder.create()
		.useSystemProperties();
	...
	DnsResolver dnsResolver = this.dnsResolver;
	if (settings.inetAddressFilter() != null) {
		dnsResolver = new HttpComponentsFilteredDnsResolver(dnsResolver, settings.inetAddressFilter());
	}
	builder.setDnsResolver(dnsResolver);
	this.connectionManagerCustomizer.accept(builder);
	return builder.build();
}

HttpComponentsFilteredDnsResolver 直接实现 HC5 的 DnsResolver:

@Override
public InetAddress[] resolve(String host) throws UnknownHostException {
	InetAddress[] resolved = this.delegate.resolve(host);
	if (ObjectUtils.isEmpty(resolved)) {
		return resolved;
	}
	return FilteredAddresses.of(Arrays.stream(resolved), this.filter::matches)
		.toArray(InetAddress[]::new)
		.orElseThrow(host, this.filter);
}

返回的数组就是连接要用的地址数组——过滤后的列表直接进入连接池的建连流程,不存在"校验一个地址、连接另一个地址"的窗口。同步客户端(HttpComponentsHttpClientBuilder)和异步客户端(HttpComponentsHttpAsyncClientBuilder)都装了这一层。

4.4 适配三:Jetty Client → SocketAddressResolver

Jetty 的钩子是 SocketAddressResolver,它是异步回调风格(返回 Promise),所以适配器要用一个 FilteredPromise 包住回调:

@Override
public void resolve(String host, int port, Map<String, Object> context, Promise<List<InetSocketAddress>> promise) {
	this.delegate.resolve(host, port, context, new FilteredPromise(host, promise));
}

class FilteredPromise implements Promise<List<InetSocketAddress>> {

	@Override
	public void succeeded(List<InetSocketAddress> result) {
		try {
			this.delegate.succeeded(filter(result));
		}
		catch (FilteredHostException ex) {
			failed(ex);       // 过滤失败 → 转成 Promise 的 failed 回调
		}
	}

	private List<InetSocketAddress> filter(List<InetSocketAddress> result) {
		InetAddressFilter filter = JettyFilteredSocketAddressResolver.this.filter;
		return FilteredAddresses.of(result.stream(), filter::matches).toList().orElseThrow(this.host, filter);
	}
}

注意 succeeded 里那个 try/catch:过滤器抛出的 FilteredHostException转成 Promise 的 failed(ex) 回调,从而以 Jetty 自己的错误语义传出,而不是让异常穿透异步回调栈。

Jetty 的装配方式也比较特别——它用一个 HttpClient 子类在 setSocketAddressResolver 时拦截:

private HttpClient createHttpClient(HttpClientSettings settings, HttpClientTransport transport) {
	return (settings.readTimeout() != null || settings.inetAddressFilter() != null)
			? new CustomizedHttpClient(transport, settings) : new HttpClient(transport);
}

static class CustomizedHttpClient extends HttpClient {
	@Override
	public void setSocketAddressResolver(SocketAddressResolver resolver) {
		if (this.settings.inetAddressFilter() != null) {
			Assert.notNull(resolver, "'resolver' must not be null when addresses are filtered");
			resolver = new JettyFilteredSocketAddressResolver(resolver, this.settings.inetAddressFilter());
		}
		super.setSocketAddressResolver(resolver);
	}
}

注意那个 Assert.notNull(resolver, ...):当设置了过滤器、但底层却拿不到 resolver 时,Spring 选择直接失败,而不是"跳过过滤继续请求"。fail-fast 而不是 fail-open,这是安全组件的基本要求。

4.5 适配四:Reactor Netty → ResolvedAddressSelector

响应式栈(WebClient 默认走 Reactor Netty)用的是 ResolvedAddressSelector:

public HttpClient build(@Nullable HttpClientSettings settings) {
	...
	httpClient = map.from(resolvedAddressSelector(settings.inetAddressFilter()))
		.to(httpClient, HttpClient::resolvedAddressesSelector);
	return this.customizer.apply(httpClient);
}

private @Nullable ResolvedAddressSelector<? super HttpClientConfig> resolvedAddressSelector(
		@Nullable InetAddressFilter inetAddressFilter) {
	return (inetAddressFilter != null)
			? new ReactorFilteredResolvedAddressSelector<>(this.resolvedAddressSelector, inetAddressFilter)
			: this.resolvedAddressSelector;
}

适配器本身还做了两件"防御性"的事:

@Override
public @Nullable List<? extends SocketAddress> apply(C config, List<? extends SocketAddress> resolvedAddresses) {
	return filter((this.delegate != null) ? this.delegate.apply(config, resolvedAddresses) : resolvedAddresses);
}

private boolean matches(SocketAddress address) {
	return (address instanceof InetSocketAddress socketAddress) ? this.filter.matches(socketAddress) : true;
}
  1. 先调用用户自己的 delegate selector,再过滤——保证用户既有配置不被覆盖;
  2. InetSocketAddress 类型的地址直接放行(true)——因为过滤器只懂 IP 地址,对 Unix Domain Socket 之类不做误判。这里是一个有意的"仅对 IP 生效"的边界。

4.6 四种实现横向对比

维度JDK HttpClientApache HC5Jetty ClientReactor Netty
扩展点ProxySelectorDnsResolverSocketAddressResolverResolvedAddressSelector
适配类JdkFilteredProxySelectorHttpComponentsFilteredDnsResolverJettyFilteredSocketAddressResolverReactorFilteredResolvedAddressSelector
过滤对象单地址(getByName 首个)完整地址数组完整地址列表完整地址列表
过滤后地址是否用于连接否(独立解析,见 4.2 边界)
异常传递直接抛直接抛Promise.failed直接抛(Stream 处理)
默认出现在starter-restclient(无客户端库时)加了 httpclient5加了 jetty-clientstarter-webclient
响应式支持是(connector 复用同一 builder)是(含 async client)

补充一点:响应式的 connector builder 不是独立实现,它们内部持有的就是上面这些命令式 builder。以 JdkClientHttpConnectorBuilder 为例:

public final class JdkClientHttpConnectorBuilder extends AbstractClientHttpConnectorBuilder<JdkClientHttpConnector> {

	private final JdkHttpClientBuilder httpClientBuilder;
	...
}

AbstractClientHttpConnectorBuilder.build(settings) 最终把 settings 交给 httpClientBuilder.build(settings),所以 RestClient / WebClient / @HttpExchange 声明式接口共享同一套过滤实现——不需要为响应式和阻塞式写两份安全配置。

客户端自动探测顺序(决定没指定时用的是哪一个):

命令式:httpComponents → jetty → reactor → jdk → simple     (ClientHttpRequestFactoryBuilder.detect())
响应式:reactor → jetty → httpComponents → jdk              (ClientHttpConnectorBuilder.detect())

所以:spring-boot-starter-restclient(不引入任何 HTTP 客户端库)→ 落到 JDK;spring-boot-starter-webclient(带 reactor-netty-http)→ Reactor Netty


五、实战:一个"URL 预览服务"的安全实现

把上面的东西拼成一个能落地的例子。需求:用户提交 URL,服务端抓取页面标题与首图。防御目标:只能访问公网 HTTP/HTTPS 资源,禁止内网、禁止云元数据。

5.1 单独的 HTTP 客户端(不污染全局)

package com.example.preview;

import java.time.Duration;

import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.HttpClientSettings;
import org.springframework.boot.http.client.HttpRedirects;
import org.springframework.boot.http.client.InetAddressFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

@Configuration(proxyBeanMethods = false)
public class PreviewHttpClientConfiguration {

	@Bean
	RestClient previewRestClient() {
		// 1. 只允许公网地址:RFC6890 特殊用途 + 全部内网(含 IPv6 ULA、NAT64 映射)一律拒绝
		InetAddressFilter onlyExternalAddresses = InetAddressFilter.externalAddresses();

		// 2. 不跟随重定向:少一个需要推理的攻击面(即便跟随,过滤器也会逐跳校验)
		HttpClientSettings settings = HttpClientSettings.defaults()
			.withInetAddressFilter(onlyExternalAddresses)
			.withRedirects(HttpRedirects.DONT_FOLLOW)
			.withConnectTimeout(Duration.ofSeconds(2))
			.withReadTimeout(Duration.ofSeconds(5));

		ClientHttpRequestFactory requestFactory = ClientHttpRequestFactoryBuilder.jdk().build(settings);
		return RestClient.builder().requestFactory(requestFactory).build();
	}
}

带超时不只是"健壮性",更是安全:没有超时的出站请求是天然的 DoS 放大器,攻击者让服务去抓 http://slow-server/ 就能占满连接。

5.2 业务侧:把异常翻译成 400

package com.example.preview;

import java.net.URI;

import org.springframework.boot.http.client.FilteredHostException;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

@Service
public class UrlPreviewService {

	private final RestClient restClient;

	public UrlPreviewService(RestClient previewRestClient) {
		this.restClient = previewRestClient;
	}

	public Preview preview(String rawUrl) {
		URI uri = URI.create(rawUrl);
		// 协议白名单:过滤器只管 IP,协议要自己管
		String scheme = uri.getScheme();
		if (scheme == null || !(scheme.equals("http") || scheme.equals("https"))) {
			throw new IllegalArgumentException("仅支持 http/https");
		}
		String html = this.restClient.get().uri(uri).retrieve().body(String.class);
		return Preview.parse(html);
	}
}

关键认知:InetAddressFilter 只管"连到哪里",不管"用什么协议、带什么头"。 协议白名单(挡 file:gopher:jar:)、响应体大小上限、Content-Type 校验这些还得自己写。安全是分层的,没有银弹。

5.3 全局异常处理

package com.example.preview;

import org.springframework.boot.http.client.FilteredHostException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class PreviewExceptionHandler {

	@ExceptionHandler(FilteredHostException.class)
	ProblemDetail handleFilteredHost(FilteredHostException ex) {
		ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
		problem.setTitle("URL 不允许访问");
		problem.setDetail("目标地址被安全策略拒绝");   // 不要回显 host,避免被当成内网探测器
		return problem;
	}
}

两个细节:

  • 返回 400 而不是 500:这是"用户输入非法",不是服务端故障;
  • 不要回显 ex.getHost():那等于把过滤器规则暴露给攻击者,让他可以逐次试探。getHost() / getFilter() 留给日志和监控。

5.4 一个必须注意的异常路径:它可能被包装一层

FilteredHostExceptionRuntimeException,但在某些客户端/工厂里它可能被包在别的异常里抛出来。Spring Boot 仓库自己的集成测试就是这么写的(AbstractClientHttpRequestFactoryBuilderTests#filteredInetAddress):

ClientHttpRequestFactory requestFactory = this.builder
	.build(HttpClientSettings.defaults().withInetAddressFilter(InetAddressFilter.externalAddresses()));
ClientHttpRequest request = requestFactory.createRequest(uri, HttpMethod.GET);
assertThatException().isThrownBy(request::execute)
	.matches((ex) -> ex instanceof FilteredHostException || ex.getCause() instanceof FilteredHostException);

参考这个判断方式:ex instanceof FilteredHostException || ex.getCause() instanceof FilteredHostException。线上排查时如果发现"没拦住",先确认异常是不是被包住了、handler 有没有匹配上。

5.5 单测怎么写(不用起真实内网服务)

直接用"本地服务 + externalAddresses()"这一组合即可稳定复现拦截(这也是仓库测试的思路):先用 InetAddressFilter.internalAddresses() 验证"能连上",再换成 externalAddresses() 验证"被拦",两次用同一个本地端口,逻辑闭环:

@Test
void blocksLocalServerWhenOnlyExternalAddressesAllowed() throws Exception {
	// 起一个本地 Tomcat,端口随机
	WebServer webServer = new TomcatServletWebServerFactory(0)
		.getWebServer((context) -> context.addServlet("test", TestServlet.class).addMapping("/"));
	webServer.start();
	try {
		URI uri = URI.create("http://localhost:" + webServer.getPort() + "/");

		// 1) 允许内网 → 能正常访问
		ClientHttpRequestFactory lenient = ClientHttpRequestFactoryBuilder.jdk()
			.build(HttpClientSettings.defaults().withInetAddressFilter(InetAddressFilter.internalAddresses()));
		assertThat(lenient.createRequest(uri, HttpMethod.GET).execute().getStatusCode()).isEqualTo(HttpStatus.OK);

		// 2) 只允许公网 → localhost 解析到 127.0.0.1,被拦
		ClientHttpRequestFactory strict = ClientHttpRequestFactoryBuilder.jdk()
			.build(HttpClientSettings.defaults().withInetAddressFilter(InetAddressFilter.externalAddresses()));
		assertThatException()
			.isThrownBy(() -> strict.createRequest(uri, HttpMethod.GET).execute())
			.matches((ex) -> ex instanceof FilteredHostException
					|| ex.getCause() instanceof FilteredHostException);
	}
	finally {
		webServer.stop();
	}
}

六、DNS Rebinding:能防住吗?

先明确攻击模型。

6.1 攻击模型

假设做了"看起来正确"的校验:先解析域名拿到 IP,检查是公网,再发起请求。

时间线:
T0  攻击者把 evil.comDNS TTL 设为 0
T1  你的校验代码解析 evil.com1.2.3.4(公网)通过校验
T2  攻击者立刻把 evil.comA 记录改成 127.0.0.1 / 169.254.169.254
T3  你的客户端真正建立连接,重新解析 evil.com127.0.0.1,打到自己

这就是 TOCTOU(Time-of-Check to Time-of-Use):检查时用的是 A 结果,使用时用的是 B 结果。DNS Rebinding 之所以经典,就是因为它把"域名指向哪里"这个控制权交给了攻击者。

6.2 Spring 的四种实现分别处于什么位置

回到第四节那张对比表的关键一行——过滤后的地址列表是否就是连接使用的列表:

实现过滤时机能否被 TOCTOU 绕过
Apache HC5(DnsResolver)连接池建连时解析 → 过滤 → 返回的数组用于连接不能:没有第二次解析
Jetty(SocketAddressResolver)建连前解析 → 过滤 → 返回的列表用于连接不能:同上
Reactor Netty(ResolvedAddressSelector)建连前解析 → 过滤 → 返回的列表用于连接不能:同上
JDK(ProxySelector)select() 前独立解析校验;随后 JDK 内部再次解析用于连接理论上存在 TOCTOU 窗口

三个基于"解析结果就地过滤"的实现,从机制上就消灭了 TOCTOU——校验对象和连接对象是同一份数据,DNS 再怎么变,用的还是过滤过的那份地址列表。这是这四个实现里最扎实的部分。

JDK 实现是唯一的例外,需要单独说清楚两点:

第一,TOCTOU 窗口被刻意压缩了。 源码注释写着"follow the same resolution logic as jdk.internal.net.http.HttpRequestImpl.getAddress()"——它调用 InetAddress.getByName(),走的是 JVM 的 InetAddress 解析缓存(受 networkaddress.cache.ttl 控制)。JDK 内部建连时用的是同一条解析路径、同一份缓存,所以在缓存有效期内两次解析结果一致,窗口极小。但这是"依赖缓存对齐"的一致性,而不是"同一份数据"的强一致性——如果 networkaddress.cache.ttl=0(每次都重新查询 DNS),窗口就会被放大。

第二,它只校验解析结果里的第一个地址。 InetAddress.getByName(host) 在 JDK 语义下只返回第一个地址(getAllByName(host)[0])。所以当一个域名同时有多个 A 记录时:

  • 若内网地址排在第一个 → 拦截(业务误伤,但方向是安全的);
  • 若公网地址排在第一个 → 校验就基于这个公网地址通过,而校验只看到了这一个地址。

相比之下,Apache/Jetty/Reactor 三个实现是在完整地址列表上做过滤,多 A 记录场景下覆盖面更全。

6.3 结论与建议

结论:InetAddressFilter 能挡住经典 DNS Rebinding 的绝大部分场景,但不要把它当唯一防线。

  • Apache HC5 / Jetty / Reactor Netty 三个客户端之一时,防护是"结构性"的,可以放心把 externalAddresses() 当作主防线;
  • JDK 客户端(也就是 spring-boot-starter-restclient 的默认情况)时,防护依赖 DNS 缓存一致性,属于"强缓解"而非"结构性杜绝";
  • 无论用哪个,都建议叠加下面这些与 HTTP 客户端无关的兜底

兜底清单:

  1. 网络层出站管控(最有效)。Kubernetes NetworkPolicy / 安全组限制 Pod 的 egress,只放行需要的目标。SSRF 打的是"网络位置",从网络上把位置限制住,是最根本的解法——应用层的过滤器永远可能被绕过,网络策略不会。
  2. 云元数据服务的强化配置。AWS 用 IMDSv2(要求 token,SSRF 拿不到带 PUT 的 token)、GCP 用元数据请求头 Metadata-Flavor: Google、Azure 用 Metadata: true。这是"即使 SSRF 成功也拿不到凭证"的纵深防御。同时把 169.254.169.254 加入出口拒绝清单。
  3. 响应约束。响应体大小上限(防内存打爆)、Content-Type 白名单、超时(防慢速 DoS)、不把原始响应体回显给用户(很多 SSRF 是"盲打",回显让它变成"有回显")。
  4. 重定向策略。最省心的是 HttpRedirects.DONT_FOLLOW;如果必须跟随,记住过滤发生在每次建连的解析环节,所以重定向到内网同样会被拦——但协议跳变(httpshttp)和跨主机跳转带来的其他风险仍需自己评估。
  5. 出口代理。让所有用户可控的出站请求走一个统一代理,在代理上做统一策略与审计。中间件层比应用层更容易做集中管控。
  6. 日志与告警FilteredHostException高质量的攻击信号:正常业务几乎不会命中它。把它接进告警(连同 getHost() 与调用方 IP),能第一时间发现有人在扫内网。

七、手写校验 vs InetAddressFilter:覆盖面差距

把两份方案摊开对比(右列基于真实源码上运行验证的输出):

攻击面手写 contains 黑名单手写"解析后判断"(自研)InetAddressFilter.externalAddresses()
127.0.0.1通常能拦通常能拦实测拦截
127.1.1.1(整个 127/8)大概率漏取决于是否用 isLoopbackAddress()实测拦截
127.1 / 2130706433(等价写法)拦(InetAddress 已归一化)实测拦截
::ffff:127.0.0.1(IPv4-mapped)拦(JVM 归一化为 Inet4Address)实测拦截
::1(IPv6 回环)常被忘取决于实现实测拦截
10.0.0.1 / 172.16.0.1 / 192.168.1.1部分实测拦截
169.254.169.254(云元数据)常被忘部分实测拦截
100.64.0.1(CGNAT)基本会漏基本会漏实测拦截
192.0.2.1 / 198.18.0.1(特殊用途)实测拦截(RFC 6890)
fc00::1 / fd00::1(IPv6 ULA)isSiteLocalAddress() 必漏实测拦截(手工按首字节判断)
64:ff9b::127.0.0.1(NAT64 映射)实测拦截
0.0.0.0实测拦截(routable())
域名指向内网(DNS Rebinding)完全无效取决于校验点位置在解析层拦截
多 A 记录部分命中取决于实现淘汰非法地址,保留合法地址
短路求值 / 不可变组合组合子每次都返回新实例
覆盖所有客户端需要自己实现四套一个 Bean 覆盖 RestClient / RestTemplate / WebClient / @HttpExchange

上表最后三行是自研方案最容易出问题的地方:要么漏掉地址类型,要么只在自己手写的那个客户端上生效,而 InetAddressFilter 是框架级实现,四个客户端适配层由 Spring 维护。


八、一张表看懂:Boot 3.x → 4.0 → 4.1

能力Boot 3.xBoot 4.0Boot 4.1
InetAddressFilter有(@since 4.1.0,gh-49687)
externalAddresses() / internalAddresses()
routable()有,随 gh-49687 引入(externalAddresses() 最初的实现就是 routable().andNot(InternalInetAddressFilter.instance))
specialPurpose() / multicast()有,随 gh-50668(RFC 6890)引入
internalAddresses() 覆盖 IPv6 ULA(fc00::/7)有,手工解析,绕过 JVM 的过时 fec0::/10 判定
NAT64(64:ff9b::/96)映射内网识别有,isNat64Local()
HttpClientSettings.withInetAddressFilter(...)
InetAddressFilter Bean 全局生效有,HttpClientAutoConfiguration
FilteredHostException(含 getFilter())
JDK 客户端 withProxySelector(...)有,自定义 ProxySelector 也能被过滤器包裹
JDK HttpClient 默认客户端支持过滤不支持支持
Simple 客户端 + filter启动即抛 IllegalStateException(fail-fast)

升级到 4.1 的三条行动项:

  1. 盘点所有"接收用户 URL/回调地址/webhook"的接口,把它们收敛到独立的 HTTP 客户端上,加上 InetAddressFilter.externalAddresses();
  2. 检查是否显式配置了 spring.http.clients.imperative.factory=simple——如果配了,过滤能力不可用,需要换客户端(用 jdk 或引入 httpclient5http-components);
  3. FilteredHostException 接进日志与告警,它是现成的 SSRF 探测告警源。

九、总结

三个要点:

  1. 校验必须发生在 IP 层,不是字符串层。 127.12130706433::ffff:127.0.0.1 都等价于 127.0.0.1,黑名单永远列不全;InetAddressFilter 是地址级允许清单,默认拒绝、显式放行。
  2. 一个 @Bean 就能覆盖所有自动配置的 HTTP 客户端(RestClient / RestTemplate / WebClient / @HttpExchange),但没有对应的 yml 属性——这是过滤器逻辑性使然,也是选型时必须知道的。全局 Bean 会连内网服务调用一起拦,安全边界建议画在"危险入口"而不是"整个应用"。
  3. 过滤发生的位置决定防护强度:Apache/Jetty/Reactor 三个实现是"解析结果就地过滤",返回的列表直接用于连接,结构性免疫 DNS Rebinding;JDK 实现(starter-restclient 的默认)靠 ProxySelector 钩子 + DNS 缓存对齐做缓解,是"强缓解"而非"杜绝"——高安全场景建议叠加网络层 egress 管控与云元数据 IMDSv2。