本文是 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,RestClient、RestTemplate、WebClient、声明式 HTTP Service 全部自动生效。Spring Boot 4.1 把这个防护能力做进了 spring-boot-http-client 模块(@since 4.1.0)。本文从 InetAddressFilter、IpAddress、FilteredAddresses 以及四个 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 实测输出。)
几条结论:
- 同一个内网地址有无数种写法,只要校验发生在"字符串层",攻击者总能找到一个没列进黑名单的等价写法。
127.1、2130706433、::ffff:127.0.0.1在 Java 里统统等价于127.0.0.1。 - 黑名单天然是失败的。内网地址空间是一张巨大的 CIDR 列表(RFC1918、CGNAT、链路本地、IPv6 ULA……),不可能列全;而允许清单只需要一句"只放行公网"。
- 域名才是真正的难点。
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::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 清单:
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/24、192.0.0.0/29 | IETF 协议分配 | 特殊用途 |
192.0.2.0/24、198.51.100.0/24、203.0.113.0/24 | TEST-NET-1/2/3 | 文档示例网段 |
192.88.99.0/24 | 6to4 中继 | 特殊用途 |
198.18.0.0/15 | 基准测试 | 特殊用途 |
240.0.0.0/4、255.255.255.255/32 | 保留 / 广播 | 特殊用途 |
::1/128、::/128 | IPv6 回环 / 未指定 | 打本机 |
64:ff9b::/96 | NAT64 | 可映射到内网 IPv4 |
100::/64 | 丢弃前缀 | 特殊用途 |
2001::/23、2001::/32、2001:2::/48、2001:db8::/32、2001: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") 背后的解析类同样值得一看,因为它的准入校验本身就是一道防线:
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.Factory 和 ReactiveHttpClientsProperties.Connector 中,每个枚举值直接映射到对应的 builder 工厂方法。
所以正确的说法是:一个 @Bean(方法体一行)覆盖全部自动配置的客户端,而不是一行 yml。
3.4 全局 Bean 的生产陷阱:它会拦住你对内网服务的正常调用
这一点必须单独强调,因为它是"全局 Bean"写法最容易出的事故:
@Bean
InetAddressFilter inetAddressFilter() {
return InetAddressFilter.externalAddresses(); // 全局:只允许公网
}
如果应用同时还有内部服务调用——比如通过 RestClient 调 http://order-service:8080、http://config-server:8888,或者访问内网的 S3 兼容存储——这些域名会解析到 10.x / 192.168.x / 172.16.x,全部会被这个全局过滤器拦掉,接口直接报 FilteredHostException,而且是在运行期才炸。
正确做法二选一:
- 全局过滤器写成"拦内网"的黑名单 + 显式放行白名单,而不是一刀切的
externalAddresses():
@Bean
InetAddressFilter inetAddressFilter() {
// 放行内部服务所在网段,其余内网地址一律拒绝
return InetAddressFilter.not(InetAddressFilter.internalAddresses()
.andNot("10.10.0.0/16")); // 10.10/16 是内部服务网段
}
- 全局不设过滤器,只在"处理用户输入 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.8 和 127.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;
}
}
三个要点:
- 它不改变代理行为,
select()照常返回 delegate 的代理列表——过滤器在这里只是借这个钩子做校验。 - 源码注释明确写了"follow the same resolution logic as
jdk.internal.net.http.HttpRequestImpl.getAddress()",即刻意与 JDK 内部解析路径保持一致(都走InetAddress,共享 JVM 的 DNS 缓存)。 UnknownHostException→resolve返回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;
}
- 先调用用户自己的 delegate selector,再过滤——保证用户既有配置不被覆盖;
- 非
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 为例:
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 一个必须注意的异常路径:它可能被包装一层
FilteredHostException 是 RuntimeException,但在某些客户端/工厂里它可能被包在别的异常里抛出来。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.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 客户端无关的兜底。
兜底清单:
- 网络层出站管控(最有效)。Kubernetes NetworkPolicy / 安全组限制 Pod 的 egress,只放行需要的目标。SSRF 打的是"网络位置",从网络上把位置限制住,是最根本的解法——应用层的过滤器永远可能被绕过,网络策略不会。
- 云元数据服务的强化配置。AWS 用 IMDSv2(要求 token,SSRF 拿不到带 PUT 的 token)、GCP 用元数据请求头
Metadata-Flavor: Google、Azure 用Metadata: true。这是"即使 SSRF 成功也拿不到凭证"的纵深防御。同时把169.254.169.254加入出口拒绝清单。 - 响应约束。响应体大小上限(防内存打爆)、
Content-Type白名单、超时(防慢速 DoS)、不把原始响应体回显给用户(很多 SSRF 是"盲打",回显让它变成"有回显")。 - 重定向策略。最省心的是
HttpRedirects.DONT_FOLLOW;如果必须跟随,记住过滤发生在每次建连的解析环节,所以重定向到内网同样会被拦——但协议跳变(https→http)和跨主机跳转带来的其他风险仍需自己评估。 - 出口代理。让所有用户可控的出站请求走一个统一代理,在代理上做统一策略与审计。中间件层比应用层更容易做集中管控。
- 日志与告警。
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 的三条行动项:
- 盘点所有"接收用户 URL/回调地址/webhook"的接口,把它们收敛到独立的 HTTP 客户端上,加上
InetAddressFilter.externalAddresses(); - 检查是否显式配置了
spring.http.clients.imperative.factory=simple——如果配了,过滤能力不可用,需要换客户端(用jdk或引入httpclient5走http-components); - 把
FilteredHostException接进日志与告警,它是现成的 SSRF 探测告警源。
九、总结
三个要点:
- 校验必须发生在 IP 层,不是字符串层。
127.1、2130706433、::ffff:127.0.0.1都等价于127.0.0.1,黑名单永远列不全;InetAddressFilter是地址级允许清单,默认拒绝、显式放行。 - 一个
@Bean就能覆盖所有自动配置的 HTTP 客户端(RestClient/RestTemplate/WebClient/@HttpExchange),但没有对应的 yml 属性——这是过滤器逻辑性使然,也是选型时必须知道的。全局 Bean 会连内网服务调用一起拦,安全边界建议画在"危险入口"而不是"整个应用"。 - 过滤发生的位置决定防护强度:Apache/Jetty/Reactor 三个实现是"解析结果就地过滤",返回的列表直接用于连接,结构性免疫 DNS Rebinding;JDK 实现(
starter-restclient的默认)靠ProxySelector钩子 + DNS 缓存对齐做缓解,是"强缓解"而非"杜绝"——高安全场景建议叠加网络层 egress 管控与云元数据 IMDSv2。