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

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


写在前面

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

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

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

ruby 复制代码
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。

传统做法是手写校验:

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

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

java 复制代码
@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.1 127.0.0.1
127.1.1.1 127.1.1.1 漏(整个 127.0.0.0/8 都是回环)
127.1 127.0.0.1 漏(短形式)
2130706433 127.0.0.1 漏(十进制单整数形式)
::ffff:127.0.0.1 127.0.0.1(归一化为 Inet4Address) 漏(IPv4-mapped IPv6)
::1 ::1(getHostAddress() 打印为 0:0:0:0:0:0:0:1) 漏(IPv6 回环)
0.0.0.0 0.0.0.0 否(但同样危险)
0177.0.0.1 177.0.0.1 ---(Java 不按八进制解析,0177 就是十进制 177,反而是公网地址)
0x7f000001 UnknownHostException --- ---(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):

java 复制代码
@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 工厂方法:从"一个地址"到"一整类地址"

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

java 复制代码
// ------ 构造:支持单个 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,原过滤器保持不变(不可变、可安全复用):

java 复制代码
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 与它的唯一区别就是末尾那个 !:

java 复制代码
result = (address) -> ours.matches(address) && !filter.matches(address);

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

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

java 复制代码
/**
 * 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 里既不算回环、也不算链路本地、也不算站点本地,很容易漏:

java 复制代码
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(包级私有)负责"什么算内网",它的源码里有一个重要的坑:

java 复制代码
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::1 true RFC 2373(2004 年已废弃)的站点本地 fec0::/10 早已废弃的网段
fc00::1 false 不在 JVM 的站点本地判定内 ULA 唯一本地地址,是私网
fd00::1 false 同上 同上,且是最常用的那一段
fe80::1 false(但 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 清单:

java 复制代码
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/10 CGNAT 运营商级 NAT 公网 100.64.x.x 在云 VPC 里常被内部使用
192.0.0.0/24192.0.0.0/29 IETF 协议分配 特殊用途
192.0.2.0/24198.51.100.0/24203.0.113.0/24 TEST-NET-1/2/3 文档示例网段
192.88.99.0/24 6to4 中继 特殊用途
198.18.0.0/15 基准测试 特殊用途
240.0.0.0/4255.255.255.255/32 保留 / 广播 特殊用途
::1/128::/128 IPv6 回环 / 未指定 打本机
64:ff9b::/96 NAT64 可映射到内网 IPv4
100::/64 丢弃前缀 特殊用途
2001::/232001::/322001:2::/482001:db8::/322001:10::/28 Teredo / 基准 / 文档 / ORCHID 特殊用途
2002::/16 6to4 可携带 IPv4 地址
fc00::/7 IPv6 ULA IPv6 私网
fe80::/10 IPv6 链路本地 私网
(追加)InternalInetAddressFilter RFC1918 三段 + 回环 + 链路本地 兜住全部 IPv4 私网

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

2.6 CIDR 是怎么匹配的------IpAddress

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

java 复制代码
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") 直接抛:

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

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

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

java 复制代码
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,文档源码直接引用):

java 复制代码
@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:

java 复制代码
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:

java 复制代码
/**
 * 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):

java 复制代码
@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 入参):

java 复制代码
@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);
	}
}

链路很清晰:

python 复制代码
你声明的 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() 的最后一行是

java 复制代码
return settings.orElse(this.settings);

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

java 复制代码
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 的全部字段):

yaml 复制代码
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"写法最容易出的事故:

java 复制代码
@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():
java 复制代码
@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 客户端:

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

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

makefile 复制代码
java.lang.IllegalStateException: Simple HTTP request factory does not support InetAddress filtering

源码就在 SimpleClientHttpRequestFactoryBuilder:

java 复制代码
@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 为每种客户端挑了它自己最合适的那个钩子

scss 复制代码
                    ┌───────────────────────┐
                    │  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 里:

java 复制代码
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 的边界说明)。实测验证了列表淘汰这个行为:

csharp 复制代码
=== 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 内部抛出),携带两个信息:

java 复制代码
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):

java 复制代码
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 的全部安全逻辑就是先校验、再委托:

java 复制代码
@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 扩展点,这是四个实现里最直接的一个。

java 复制代码
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:

java 复制代码
@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 包住回调:

java 复制代码
@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 时拦截:

java 复制代码
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:

java 复制代码
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;
}

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

java 复制代码
@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 HttpClient Apache HC5 Jetty Client Reactor Netty
扩展点 ProxySelector DnsResolver SocketAddressResolver ResolvedAddressSelector
适配类 JdkFilteredProxySelector HttpComponentsFilteredDnsResolver JettyFilteredSocketAddressResolver ReactorFilteredResolvedAddressSelector
过滤对象 单地址(getByName 首个) 完整地址数组 完整地址列表 完整地址列表
过滤后地址是否用于连接 否(独立解析,见 4.2 边界)
异常传递 直接抛 直接抛 Promise.failed 直接抛(Stream 处理)
默认出现在 starter-restclient(无客户端库时) 加了 httpclient5 加了 jetty-client starter-webclient
响应式支持 是(connector 复用同一 builder) 是(含 async client)

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

java 复制代码
public final class JdkClientHttpConnectorBuilder extends AbstractClientHttpConnectorBuilder<JdkClientHttpConnector> {

	private final JdkHttpClientBuilder httpClientBuilder;
	...
}

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

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

css 复制代码
命令式: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 客户端(不污染全局)

java 复制代码
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

java 复制代码
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 全局异常处理

java 复制代码
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):

java 复制代码
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() 验证"被拦",两次用同一个本地端口,逻辑闭环:

java 复制代码
@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,检查是公网,再发起请求。

less 复制代码
时间线:
T0  攻击者把 evil.com 的 DNS TTL 设为 0
T1  你的校验代码解析 evil.com  → 1.2.3.4(公网)通过校验
T2  攻击者立刻把 evil.com 的 A 记录改成 127.0.0.1 / 169.254.169.254
T3  你的客户端真正建立连接,重新解析 evil.com → 127.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.x Boot 4.0 Boot 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。

相关推荐
白远山1 小时前
健身场馆无人自动化系统实战指南:从架构设计到部署全流程解析
java·开发语言·架构·需求分析
user_admin_god1 小时前
第 09 篇:实践一 —— 文本摘要(Map-Reduce 分块)
java·人工智能·spring boot·语言模型
右耳朵猫AI1 小时前
Node.js周刊2026W37 | 三处进程崩溃修复、fs 内置 glob、Workers 模块注册表、Vitest 5.0
javascript·后端·node.js
giszhc1 小时前
腾讯地图瓦片接入方案:一个 Bun 代理,让 Mapbox / Leaflet / OpenLayers 通用
前端·后端
咖啡八杯1 小时前
Controller 基类封装:BaseController 的模板方法设计
java·架构·设计
vx_BS813301 小时前
【项目编号:project49759】Spring Boot 宠物综合服务平台:商城、预约、咨询、活动与健康档案的一体化实现
java·spring boot·eclipse·tomcat·intellij-idea
她的男孩1 小时前
加了 @Idempotent 还是重复扣款了 3 笔:扒完 1279 行幂等 Starter,我挖出 5 个隐蔽的坑
java·后端·架构
步行cgn1 小时前
Spring 注入内部 Bean 详解
java·spring·rpc
user_admin_god2 小时前
第 05 篇:结构化输出 —— 让模型稳定返回 JSON
java·人工智能·spring boot·语言模型