我用 Spring Boot 4.1.1 把 InetAddressFilter.externalAddresses() 分别装到 JDK HttpClient、Apache HttpComponents、Jetty 和 Reactor Netty 上,直接请求 127.0.0.1、169.254.169.254、10.0.0.2、192.168.1.1、本机网卡地址、[::1]、0.0.0.0 和十进制写法的 2130706433,RestClient 和 WebClient 共八种组合全部在建连之前就被拦下,公网地址 1.1.1.1 照常返回 200。差别出在异常类型上:RestClient 底下换成 JDK、Apache 或 Jetty 时,抛出来的是裸的 FilteredHostException,它直接继承 RuntimeException,不是 RestClientException,只有底层是 Reactor Netty 时才被包成 ResourceAccessException,而 WebClient 四种客户端统一是 WebClientRequestException。另一个容易误判的地方在 DNS,机器上如果跑着 fake-ip 模式的透明代理,所有域名都会解析到 198.18.0.1,这个网段在 externalAddresses() 里属于特殊用途地址,于是 example.com 这类正常域名也一起被拦。
六月那篇 Spring 近期更新汇总 里提过一句 Boot 4.1 的 SSRF 防护,这次把它放到四种底层客户端上实际跑了一遍。
API 实际长什么样
这个功能来自 gh-49687 Support InetAddress filtering for HTTP Clients,里程碑是 4.1.0-RC1,InetAddressFilter 类上标的是 @since 4.1.0。它在 spring-boot-http-client 模块里,包名是 org.springframework.boot.http.client,和它一起进来的还有 FilteredHostException 以及四个按客户端区分的实现类:
$ unzip -l spring-boot-http-client-4.1.1.jar | grep -E "Filter|Filtered"
3034 1980-02-01 00:00 org/springframework/boot/http/client/FilteredAddresses$Filtered.class
3474 1980-02-01 00:00 org/springframework/boot/http/client/FilteredAddresses.class
1051 1980-02-01 00:00 org/springframework/boot/http/client/FilteredHostException.class
3762 1980-02-01 00:00 org/springframework/boot/http/client/HttpComponentsFilteredDnsResolver.class
8625 1980-02-01 00:00 org/springframework/boot/http/client/InetAddressFilter.class
1838 1980-02-01 00:00 org/springframework/boot/http/client/InternalInetAddressFilter.class
3225 1980-02-01 00:00 org/springframework/boot/http/client/JdkFilteredProxySelector.class
3887 1980-02-01 00:00 org/springframework/boot/http/client/JettyFilteredSocketAddressResolver$FilteredPromise.class
1827 1980-02-01 00:00 org/springframework/boot/http/client/JettyFilteredSocketAddressResolver.class
6033 1980-02-01 00:00 org/springframework/boot/http/client/ReactorFilteredResolvedAddressSelector.class
接入方式只有两种。一种是在构建客户端时把过滤器塞进 HttpClientSettings,对应方法是 HttpClientSettings.withInetAddressFilter(...);另一种是声明一个 InetAddressFilter 类型的 @Bean,Boot 的 HttpClientAutoConfiguration 会把它放进自动配置的 HttpClientSettings,所有自动配置的 ClientHttpRequestFactoryBuilder 和 ClientHttpConnectorBuilder 都会用上。过滤器没有对应的 spring.http.clients.* 配置属性,规则只能写在代码里。官方文档的说明在 REST Clients 一节的 InetAddress Filtering and SSRF Protection。
过滤器的语义是放行名单,matches 返回 true 的地址才允许连接。externalAddresses() 在 4.1.1 的源码里是这样定义的:
static InetAddressFilter externalAddresses() {
return routable().andNot(multicast(), specialPurpose());
}
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);
}
specialPurpose() 这张表是 RFC 6890 的特殊用途地址,再并上 10/8、172.16/12、192.168/16 和 fc00::/7 这些内网段。RC 阶段的 externalAddresses() 还没有排除 RFC 6890 地址,gh-50668 在 4.1.0 GA 前补上了,所以 RC 版本测出来的结果和 GA 不一样。
四种客户端各挂在哪一层
Apache、Jetty、Reactor Netty 三家都是在解析结果上做筛选:Apache 包了一层 DnsResolver(HttpComponentsFilteredDnsResolver),Jetty 包的是 SocketAddressResolver,Reactor Netty 用的是 ResolvedAddressSelector。解析出多个地址时只剔除不合格的那几个,全部不合格才抛 FilteredHostException。
JDK HttpClient 没有可替换的解析器,Boot 换了个位置,把检查放进 ProxySelector:
@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);
}
也就是说 JDK 这一路是 Boot 自己先用 InetAddress.getByName 解析一次主机名再判断,代码注释里写的是“We follow the same resolution logic as jdk.internal.net.http.HttpRequestImpl.getAddress()”。源码在 JdkFilteredProxySelector.java(v4.1.1)。下面堆栈里的 HttpRequestImpl.retrieveProxy 就是这一步:
----- stack trace rest/jdk http://127.0.0.1:8080/hello
org.springframework.boot.http.client.FilteredHostException: Filtered host '127.0.0.1'
at org.springframework.boot.http.client.FilteredAddresses$Filtered.orElseThrow(FilteredAddresses.java:78)
at org.springframework.boot.http.client.FilteredAddresses$Filtered.orElseThrow(FilteredAddresses.java:73)
at org.springframework.boot.http.client.JdkFilteredProxySelector.select(JdkFilteredProxySelector.java:51)
at java.net.http/jdk.internal.net.http.HttpRequestImpl.retrieveProxy(HttpRequestImpl.java:316)
at java.net.http/jdk.internal.net.http.HttpRequestImpl.<init>(HttpRequestImpl.java:133)
SimpleClientHttpRequestFactory 背后是 HttpURLConnection,没有地方可以挂过滤器,Boot 的选择是直接拒绝构建:
[rest/simple] java.lang.IllegalStateException: Simple HTTP request factory does not support InetAddress filtering
测试项目
环境是 OpenJDK 21.0.12.1、Spring Boot 4.1.1(Spring Framework 7.0.9、Spring Security 7.1.1),底层客户端版本由 Boot 依赖管理决定:reactor-netty-http 1.3.7、httpclient5 5.6.4、jetty-client 12.1.12。应用本身是一个监听 8080 的 Spring MVC 服务,/hello 返回 hello,这样请求回环地址和本机网卡地址时能看出是不是真的打到了自己。
每种客户端用同一份 HttpClientSettings 构建,只换 builder:
static HttpClientSettings settings(InetAddressFilter filter) {
return HttpClientSettings.defaults()
.withTimeouts(Duration.ofSeconds(2), Duration.ofSeconds(3))
.withInetAddressFilter(filter);
}
static Function<String, String> restClient(ClientHttpRequestFactoryBuilder<?> builder, HttpClientSettings settings) {
RestClient client = RestClient.builder().requestFactory(builder.build(settings))
.defaultHeader("accept", "application/dns-json").build();
return (url) -> {
ResponseEntity<String> response = client.get().uri(url).retrieve().toEntity(String.class);
return response.getStatusCode().value() + " " + abbreviate(response.getBody());
};
}
HttpClientSettings settings = Probe.settings(InetAddressFilter.externalAddresses());
Probe.run("rest/jdk", Probe.restClient(ClientHttpRequestFactoryBuilder.jdk(), settings), Probe.TARGETS);
Probe.run("rest/apache", Probe.restClient(ClientHttpRequestFactoryBuilder.httpComponents(), settings), Probe.TARGETS);
Probe.run("rest/jetty", Probe.restClient(ClientHttpRequestFactoryBuilder.jetty(), settings), Probe.TARGETS);
Probe.run("rest/reactor", Probe.restClient(ClientHttpRequestFactoryBuilder.reactor(), settings), Probe.TARGETS);
Probe.run("web/reactor", Probe.webClient(ClientHttpConnectorBuilder.reactor(), settings), Probe.TARGETS);
Probe.run("web/jdk", Probe.webClient(ClientHttpConnectorBuilder.jdk(), settings), Probe.TARGETS);
Probe.run("web/apache", Probe.webClient(ClientHttpConnectorBuilder.httpComponents(), settings), Probe.TARGETS);
Probe.run("web/jetty", Probe.webClient(ClientHttpConnectorBuilder.jetty(), settings), Probe.TARGETS);
公网目标选的是 https://1.1.1.1/dns-query?name=localtest.me&type=A,这是 Cloudflare 的 DoH 接口,直接用 IP 访问,顺便还能拿到 localtest.me 在公网 DNS 上的解析结果。172.30.0.2 是测试机自己的网卡地址。
不加过滤时能打到哪些地址
先用 JDK 客户端、不设过滤器跑一遍做对照:
$ java -jar target/inet-filter-demo-0.0.1-SNAPSHOT.jar baseline
[rest/jdk/no-filter] loopback http://127.0.0.1:8080/hello -> OK 200 hello (130 ms)
[rest/jdk/no-filter] metadata http://169.254.169.254/latest/meta-data/ -> FAILED org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://169.254.169.254/latest/meta-data/": HTTP connect timed out <- java.net.http.HttpConnectTimeoutException: HTTP connect timed out <- java.net.ConnectException: HTTP connect timed out (2003 ms)
[rest/jdk/no-filter] private-10 http://10.0.0.2/ -> FAILED org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://10.0.0.2/": Request cancelled <- java.net.http.HttpTimeoutException: Request cancelled (3002 ms)
[rest/jdk/no-filter] private-192 http://192.168.1.1/ -> FAILED org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://192.168.1.1/": HTTP connect timed out <- java.net.http.HttpConnectTimeoutException: HTTP connect timed out <- java.net.ConnectException: HTTP connect timed out (2003 ms)
[rest/jdk/no-filter] private-172 http://172.30.0.2:8080/hello -> OK 200 hello (5 ms)
[rest/jdk/no-filter] public-ip https://1.1.1.1/dns-query?name=localtest.me&type=A -> OK 200 {"Status":0,"TC":false,"RD":true,"RA":true,"AD":false,"CD":f... (208 ms)
[rest/jdk/no-filter] localhost http://localhost:8080/hello -> OK 200 hello (6 ms)
[rest/jdk/no-filter] localtest.me http://localtest.me:8080/hello -> FAILED org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localtest.me:8080/hello": HTTP connect timed out <- java.net.http.HttpConnectTimeoutException: HTTP connect timed out <- java.net.ConnectException: HTTP connect timed out (2003 ms)
[rest/jdk/no-filter] example.com https://example.com/ -> OK 200 <!doctype html><html lang=en><head><meta charset=utf-8><link... (139 ms)
[rest/jdk/no-filter] ipv6-loopback http://[::1]:8080/hello -> OK 200 hello (4 ms)
[rest/jdk/no-filter] zero http://0.0.0.0:8080/hello -> OK 200 hello (3 ms)
[rest/jdk/no-filter] decimal http://2130706433:8080/hello -> OK 200 hello (2 ms)
回环、本机网卡、[::1]、0.0.0.0 和 2130706433 都拿到了 hello。2130706433 能打通,是因为 JDK 的 InetAddress 把纯十进制数字当成 IPv4 字面量解析成 127.0.0.1,很多基于字符串的黑名单校验就是在这种写法上漏掉的。169.254.169.254、192.168.1.1 和 localtest.me 是连接超时,这台机器上没有对应的服务。
加上 externalAddresses() 之后
同样的目标,JDK 客户端的结果:
$ java -jar target/inet-filter-demo-0.0.1-SNAPSHOT.jar external
[rest/jdk] loopback http://127.0.0.1:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '127.0.0.1' (24 ms)
[rest/jdk] metadata http://169.254.169.254/latest/meta-data/ -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '169.254.169.254' (0 ms)
[rest/jdk] private-10 http://10.0.0.2/ -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '10.0.0.2' (0 ms)
[rest/jdk] private-192 http://192.168.1.1/ -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '192.168.1.1' (0 ms)
[rest/jdk] private-172 http://172.30.0.2:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '172.30.0.2' (0 ms)
[rest/jdk] public-ip https://1.1.1.1/dns-query?name=localtest.me&type=A -> OK 200 {"Status":0,"TC":false,"RD":true,"RA":true,"AD":false,"CD":f... (202 ms)
[rest/jdk] localhost http://localhost:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'localhost' (1 ms)
[rest/jdk] localtest.me http://localtest.me:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'localtest.me' (1 ms)
[rest/jdk] example.com https://example.com/ -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'example.com' (1 ms)
[rest/jdk] ipv6-loopback http://[::1]:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '[::1]' (0 ms)
[rest/jdk] zero http://0.0.0.0:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '0.0.0.0' (0 ms)
[rest/jdk] decimal http://2130706433:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '2130706433' (0 ms)
内网和特殊地址全部被拒,除了第一条回环请求用了 24 ms,其余都在 0 到 1 毫秒内返回,1.1.1.1 正常返回 200。另外七种组合对回环地址的结果:
[rest/apache] loopback http://127.0.0.1:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '127.0.0.1' (23 ms)
[rest/jetty] loopback http://127.0.0.1:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '127.0.0.1' (74 ms)
[rest/reactor] loopback http://127.0.0.1:8080/hello -> FAILED org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://127.0.0.1:8080/hello": Filtered host '127.0.0.1' <- java.io.IOException: Filtered host '127.0.0.1' <- org.springframework.boot.http.client.FilteredHostException: Filtered host '127.0.0.1' (190 ms)
[web/reactor] loopback http://127.0.0.1:8080/hello -> FAILED org.springframework.web.reactive.function.client.WebClientRequestException: Filtered host '127.0.0.1' <- org.springframework.boot.http.client.FilteredHostException: Filtered host '127.0.0.1' (20 ms)
[web/jdk] loopback http://127.0.0.1:8080/hello -> FAILED org.springframework.web.reactive.function.client.WebClientRequestException: Filtered host '127.0.0.1' <- org.springframework.boot.http.client.FilteredHostException: Filtered host '127.0.0.1' (4 ms)
[web/apache] loopback http://127.0.0.1:8080/hello -> FAILED org.springframework.web.reactive.function.client.WebClientRequestException: Filtered host '127.0.0.1' <- org.springframework.boot.http.client.FilteredHostException: Filtered host '127.0.0.1' (12 ms)
[web/jetty] loopback http://127.0.0.1:8080/hello -> FAILED org.springframework.web.reactive.function.client.WebClientRequestException: Filtered host '127.0.0.1' <- org.springframework.boot.http.client.FilteredHostException: Filtered host '127.0.0.1' (16 ms)
其余目标在这七种组合上的拦截结果和 JDK 一致,1.1.1.1 都是 200,完整日志一共八段,这里不全贴。
异常类型取决于底层客户端
上面几行放在一起看,RestClient 的行为分成了两类。JDK、Apache、Jetty 三种 request factory 在 execute 时直接把 FilteredHostException 抛出来,DefaultRestClient 只会把 IOException 转成 ResourceAccessException,运行时异常原样透传。Reactor Netty 的 request factory 先把它包成 IOException,于是到了 RestClient 这一层就变成了 ResourceAccessException。
这会直接影响错误处理。FilteredHostException 的父类是 RuntimeException,按 RestClientException 或 ResourceAccessException 写的 catch 块,在 JDK、Apache、Jetty 三种客户端上接不住它;而 Boot 自动探测底层客户端时的顺序是 Apache、Jetty、Reactor Netty、JDK,classpath 上有 Apache 时默认就是它。想统一处理,可以直接 catch FilteredHostException,或者从异常链里找它。WebClient 这边四种客户端是一致的,外层都是 WebClientRequestException,cause 是 FilteredHostException。
异常消息里的 host 也不一样。请求 http://localhost:8080/hello 时:
[rest/jdk] localhost http://localhost:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'localhost' (1 ms)
[rest/apache] localhost http://localhost:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'localhost' (0 ms)
[rest/jetty] localhost http://localhost:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'localhost' (1 ms)
[rest/reactor] localhost http://localhost:8080/hello -> FAILED org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost:8080/hello": Filtered host '127.0.0.1' <- java.io.IOException: Filtered host '127.0.0.1' <- org.springframework.boot.http.client.FilteredHostException: Filtered host '127.0.0.1' (8 ms)
[web/reactor] localhost http://localhost:8080/hello -> FAILED org.springframework.web.reactive.function.client.WebClientRequestException: Filtered host '127.0.0.1' <- org.springframework.boot.http.client.FilteredHostException: Filtered host '127.0.0.1' (2 ms)
JDK、Apache、Jetty 报的是 URL 里写的主机名,Reactor Netty 报的是解析后的 IP。FilteredHostException.getHost() 拿到的也是这个值,如果要把被拦的地址写进审计日志,不同客户端记下来的东西不同。
十进制写法也是这样分叉的:
[rest/jdk] decimal http://2130706433:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '2130706433' (0 ms)
[rest/apache] decimal http://2130706433:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '2130706433' (0 ms)
[rest/jetty] decimal http://2130706433:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '2130706433' (0 ms)
[rest/reactor] decimal http://2130706433:8080/hello -> FAILED org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://2130706433:8080/hello": Filtered host '198.18.0.1' <- java.io.IOException: Filtered host '198.18.0.1' <- org.springframework.boot.http.client.FilteredHostException: Filtered host '198.18.0.1' (8 ms)
JDK、Apache、Jetty 走的都是 InetAddress,把 2130706433 解析成 127.0.0.1 后拦下;Reactor Netty 用自己的 DNS 解析器,把它当成主机名去查,查到的是 198.18.0.1,同样被拦,但拦下的原因不同。
域名为什么全被拦了
example.com 是正常的公网域名,加了 externalAddresses() 后在八种组合上全部被拒。原因在测试机的 DNS:
$ getent hosts localtest.me example.com one.one.one.one
198.18.0.1 localtest.me
198.18.0.1 example.com
198.18.0.1 one.one.one.one
$ curl -s -H 'accept: application/dns-json' 'https://1.1.1.1/dns-query?name=localtest.me&type=A'
{"Status":0,"TC":false,"RD":true,"RA":true,"AD":false,"CD":false,"Question":[{"name":"localtest.me","type":1}],"Answer":[{"name":"localtest.me","type":1,"TTL":258875,"data":"127.0.0.1"}]}
这台机器的系统 DNS 前面有一层 fake-ip 模式的透明代理,任何域名都先拿到一个 198.18.0.0/15 段的假地址,真正的连接由代理接管。Clash 系列内核 mihomo 的默认 fake-ip-range 就是 198.18.0.1/16(见 mihomo DNS 配置文档),开发机上开着这类代理的人不少。198.18.0.0/15 在 RFC 6890 里是基准测试保留段,externalAddresses() 把它排除在外,结果就是所有域名请求都抛 FilteredHostException:
[rest/jdk] example.com https://example.com/ -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'example.com' (1 ms)
[rest/apache] example.com https://example.com/ -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'example.com' (0 ms)
[rest/reactor] example.com https://example.com/ -> FAILED org.springframework.web.client.ResourceAccessException: I/O error on GET request for "https://example.com/": Filtered host '198.18.0.1' <- java.io.IOException: Filtered host '198.18.0.1' <- org.springframework.boot.http.client.FilteredHostException: Filtered host '198.18.0.1' (11 ms)
同一个原因也让 localtest.me 的测试没法直接做。公网 DNS 上 localtest.me 解析到 127.0.0.1(上面 DoH 的返回里能看到),这是测试"域名指向内网"的常用域名,但在这台机器上它和别的域名一样被解析成 198.18.0.1。为了让它真正指向回环地址,我用 JDK 的 jdk.net.hosts.file 系统属性指定了一个只有两行的 hosts 文件:
127.0.0.1 localhost
127.0.0.1 localtest.me
$ java -Djdk.net.hosts.file=hosts -jar target/inet-filter-demo-0.0.1-SNAPSHOT.jar hostsfile
[rest/jdk/no-filter] localtest.me http://localtest.me:8080/hello -> OK 200 hello (144 ms)
[rest/jdk] localtest.me http://localtest.me:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'localtest.me' (1 ms)
[rest/apache] localtest.me http://localtest.me:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'localtest.me' (26 ms)
[rest/jetty] localtest.me http://localtest.me:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'localtest.me' (81 ms)
[rest/reactor] localtest.me http://localtest.me:8080/hello -> FAILED org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localtest.me:8080/hello": Filtered host '198.18.0.1' <- java.io.IOException: Filtered host '198.18.0.1' <- org.springframework.boot.http.client.FilteredHostException: Filtered host '198.18.0.1' (292 ms)
不加过滤器时 JDK 客户端通过 localtest.me 打到了本机的 /hello,加上之后 JDK、Apache、Jetty 都拦下了。Reactor Netty 报的仍然是 198.18.0.1,它的解析器不经过 InetAddress,也就不读 jdk.net.hosts.file。所以在 Reactor Netty 上,这个测试只证明了 fake-ip 地址被拦,域名指向 127.0.0.1 的情况要在 DNS 真正返回 127.0.0.1 的环境里看。
换成 Spring Security 的 InetAddressMatcher
Spring Security 7.1 在 spring-security-core 里加了 org.springframework.security.util.matcher.InetAddressMatcher 和工厂类 InetAddressMatchers(Javadoc),类上标的是 @since 7.1。两边没有直接的类型关系,用 InetAddressFilter.adapt 接一下即可,这次用的是 Bean 方式,让自动配置的 builder 去用它:
@Bean
@ConditionalOnProperty(name = "app.filter", havingValue = "external")
InetAddressFilter externalOnly() {
return InetAddressFilter.externalAddresses();
}
@Bean
@ConditionalOnProperty(name = "app.filter", havingValue = "security")
InetAddressFilter securityMatcher() {
InetAddressMatcher matcher = InetAddressMatchers.matchExternal().build();
return InetAddressFilter.adapt(matcher::matches);
}
注入的 RestClient.Builder 和 WebClient.Builder 在这个项目里自动选中了 Apache 和 Reactor Netty。用 Security 的 matchExternal() 跑出来是这样:
$ java -jar target/inet-filter-demo-0.0.1-SNAPSHOT.jar bean --app.filter=security
[bean] filter bean: security
[bean] request factory builder: HttpComponentsClientHttpRequestFactoryBuilder
[bean] connector builder: ReactorClientHttpConnectorBuilder
[bean/rest] loopback http://127.0.0.1:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '127.0.0.1' (46 ms)
[bean/rest] metadata http://169.254.169.254/latest/meta-data/ -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '169.254.169.254' (0 ms)
[bean/rest] private-10 http://10.0.0.2/ -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '10.0.0.2' (0 ms)
[bean/rest] private-192 http://192.168.1.1/ -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '192.168.1.1' (0 ms)
[bean/rest] private-172 http://172.30.0.2:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '172.30.0.2' (0 ms)
[bean/rest] public-ip https://1.1.1.1/dns-query?name=localtest.me&type=A -> OK 200 {"Status":0,"TC":false,"RD":true,"RA":true,"AD":false,"CD":f... (172 ms)
[bean/rest] localhost http://localhost:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'localhost' (2 ms)
[bean/rest] localtest.me http://localtest.me:8080/hello -> FAILED org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localtest.me:8080/hello": Connect to http://localtest.me:8080 failed: Connect timed out <- org.apache.hc.client5.http.ConnectTimeoutException: Connect to http://localtest.me:8080 failed: Connect timed out (2006 ms)
[bean/rest] example.com https://example.com/ -> OK 200 <!doctype html><html lang=en><head><meta charset=utf-8><link... (149 ms)
[bean/rest] ipv6-loopback http://[::1]:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '[::1]' (1 ms)
[bean/rest] zero http://0.0.0.0:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '0.0.0.0' (0 ms)
[bean/rest] decimal http://2130706433:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host '2130706433' (0 ms)
[bean/web] localtest.me http://localtest.me:8080/hello -> FAILED org.springframework.web.reactive.function.client.WebClientRequestException: connection timed out after 2000 ms: localtest.me/198.18.0.1:8080 <- io.netty.channel.ConnectTimeoutException: connection timed out after 2000 ms: localtest.me/198.18.0.1:8080 (2044 ms)
[bean/web] example.com https://example.com/ -> OK 200 <!doctype html><html lang=en><head><meta charset=utf-8><link... (149 ms)
[bean/web] decimal http://2130706433:8080/hello -> FAILED org.springframework.web.reactive.function.client.WebClientRequestException: connection timed out after 2000 ms: 2130706433/198.18.0.1:8080 <- io.netty.channel.ConnectTimeoutException: connection timed out after 2000 ms: 2130706433/198.18.0.1:8080 (2012 ms)
回环、元数据、内网段、[::1]、0.0.0.0 照样被拦,但 example.com 返回了 200,localtest.me 和 WebClient 下的十进制地址则放过了过滤器、在 198.18.0.1 上连接超时。Security 的 matchExternal() 只把回环、链路本地、私有网段、任意地址、fc00::/7 以及 64:ff9b:: 映射到这些地址的情况当作内部地址,没有 Boot 那张 RFC 6890 表,198.18.0.1 在它眼里是外部地址。两个“external”的范围并不相同,选哪一个,决定了 198.18.0.0/15、100.64.0.0/10 这些地址能不能放行。把 app.filter 换成 external 跑同一套 Bean 配置,example.com 和 localtest.me 都回到了被拦的结果,自动配置选中的 builder 不变:
$ java -jar target/inet-filter-demo-0.0.1-SNAPSHOT.jar bean --app.filter=external
[bean] filter bean: external
[bean] request factory builder: HttpComponentsClientHttpRequestFactoryBuilder
[bean] connector builder: ReactorClientHttpConnectorBuilder
[bean/rest] localtest.me http://localtest.me:8080/hello -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'localtest.me' (1 ms)
[bean/rest] example.com https://example.com/ -> FAILED org.springframework.boot.http.client.FilteredHostException: Filtered host 'example.com' (0 ms)
走代理时过滤器管不到
gh-51863 给官方文档补了一段说明,里程碑是 4.1.2:客户端如果配了代理,例如 Jetty 的 HttpProxy,主机名是作为请求的一部分交给代理,由代理去解析和连接,InetAddressFilter 不会限制代理替你发出的请求,这时应该在代理服务器上做限制,应用里只需要用过滤器保证只能连到代理本身。这一条我没有单独搭代理验证,以文档为准。
一起交流
分享你的思考,让讨论更进一步。