From 52cef1868635b48bfafad0b9c9af91f1caf76b4f Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Thu, 26 Mar 2026 16:05:40 -0700 Subject: [PATCH] Support InetAddress filtering for HTTP Clients Add `InetAddressFilter` interface which can be provided by `HttpSettings` to filter out addresses in order to harden applications against SSRF attacks. Closes gh-49687 --- .../reference/pages/io/rest-client.adoc | 19 + .../inetaddressfiltering/Details.java | 21 + .../MyHttpClientConfiguration.java | 31 + .../inetaddressfiltering/MyService.java | 42 + .../inetaddressfiltering/Details.kt | 4 + .../MyHttpClientConfiguration.kt | 15 + .../inetaddressfiltering/MyService.kt | 27 + module/spring-boot-http-client/build.gradle | 1 + .../boot/http/client/FilteredAddresses.java | 85 ++ .../http/client/FilteredHostException.java | 53 ++ .../boot/http/client/HttpClientSettings.java | 51 +- ...onentsClientHttpRequestFactoryBuilder.java | 14 + .../HttpComponentsFilteredDnsResolver.java | 70 ++ .../HttpComponentsHttpAsyncClientBuilder.java | 40 +- .../HttpComponentsHttpClientBuilder.java | 41 +- .../boot/http/client/InetAddressFilter.java | 320 ++++++++ .../client/InternalInetAddressFilter.java | 84 ++ .../boot/http/client/IpAddress.java | 136 ++++ .../JdkClientHttpRequestFactoryBuilder.java | 14 + .../http/client/JdkFilteredProxySelector.java | 76 ++ .../http/client/JdkHttpClientBuilder.java | 26 +- .../JettyClientHttpRequestFactoryBuilder.java | 14 + .../JettyFilteredSocketAddressResolver.java | 80 ++ .../http/client/JettyHttpClientBuilder.java | 66 +- ...eactorClientHttpRequestFactoryBuilder.java | 17 + ...eactorFilteredResolvedAddressSelector.java | 82 ++ .../http/client/ReactorHttpClientBuilder.java | 40 +- ...onentsClientHttpRequestFactoryBuilder.java | 1 + ...SimpleClientHttpRequestFactoryBuilder.java | 7 +- .../HttpClientAutoConfiguration.java | 11 +- ...pComponentsClientHttpConnectorBuilder.java | 14 + .../JdkClientHttpConnectorBuilder.java | 14 + .../JettyClientHttpConnectorBuilder.java | 14 + .../ReactorClientHttpConnectorBuilder.java | 17 + ...tClientHttpRequestFactoryBuilderTests.java | 25 + .../http/client/FilteredAddressesTests.java | 76 ++ .../client/FilteredHostExceptionTests.java | 39 + .../http/client/HttpClientSettingsTests.java | 38 +- ...sClientHttpRequestFactoryBuilderTests.java | 24 + ...ttpComponentsFilteredDnsResolverTests.java | 114 +++ .../http/client/InetAddressFilterAssert.java | 79 ++ .../http/client/InetAddressFilterTests.java | 723 ++++++++++++++++++ .../boot/http/client/IpAddressTests.java | 197 +++++ ...kClientHttpRequestFactoryBuilderTests.java | 26 + .../client/JdkFilteredProxySelectorTests.java | 75 ++ ...yClientHttpRequestFactoryBuilderTests.java | 22 + ...ttyFilteredSocketAddressResolverTests.java | 100 +++ ...rFilteredResolvedAddressSelectorTests.java | 83 ++ ...sClientHttpRequestFactoryBuilderTests.java | 9 + ...eClientHttpRequestFactoryBuilderTests.java | 12 +- .../HttpClientAutoConfigurationTests.java | 10 + ...stractClientHttpConnectorBuilderTests.java | 24 + ...onentsClientHttpConnectorBuilderTests.java | 25 + .../JdkClientHttpConnectorBuilderTests.java | 27 + .../JettyClientHttpConnectorBuilderTests.java | 33 +- ...eactorClientHttpConnectorBuilderTests.java | 27 + 56 files changed, 3272 insertions(+), 63 deletions(-) create mode 100644 documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/Details.java create mode 100644 documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyHttpClientConfiguration.java create mode 100644 documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyService.java create mode 100644 documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/Details.kt create mode 100644 documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyHttpClientConfiguration.kt create mode 100644 documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyService.kt create mode 100644 module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/FilteredAddresses.java create mode 100644 module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/FilteredHostException.java create mode 100644 module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsFilteredDnsResolver.java create mode 100644 module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/InetAddressFilter.java create mode 100644 module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/InternalInetAddressFilter.java create mode 100644 module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/IpAddress.java create mode 100644 module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkFilteredProxySelector.java create mode 100644 module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JettyFilteredSocketAddressResolver.java create mode 100644 module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReactorFilteredResolvedAddressSelector.java create mode 100644 module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/FilteredAddressesTests.java create mode 100644 module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/FilteredHostExceptionTests.java create mode 100644 module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/HttpComponentsFilteredDnsResolverTests.java create mode 100644 module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/InetAddressFilterAssert.java create mode 100644 module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/InetAddressFilterTests.java create mode 100644 module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/IpAddressTests.java create mode 100644 module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JdkFilteredProxySelectorTests.java create mode 100644 module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JettyFilteredSocketAddressResolverTests.java create mode 100644 module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/ReactorFilteredResolvedAddressSelectorTests.java diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/io/rest-client.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/io/rest-client.adoc index 2309a1edbcb..bb068499d0e 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/io/rest-client.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/io/rest-client.adoc @@ -446,3 +446,22 @@ spring: read-timeout: 1s redirects: dont-follow ---- + + + +[[io.rest-client.global-configuration.inetaddress-filtering]] +=== InetAddress Filtering and SSRF Protection + +It's sometimes useful to limit the remote addresses that an HTTP client is permitted to call. +This technique can be especially useful when hardening your application against Server-Side Request Forgery (SSRF) attacks. + +To limit the address that a client can call, you can use an javadoc:org.springframework.boot.http.client.InetAddressFilter[] which will only allow outgoing calls to addresses that match the filter. +The filter is a functional interface and you can either create your own implementation, or use one of the convenient factory methods. + +Filters may be applied to the javadoc:org.springframework.boot.http.client.HttpClientSettings[] you used when building a client: + +include-code::MyService[] + +Or you can also define one as a javadoc:org.springframework.context.annotation.Bean[format=annotation] if you want to apply it to all auto-confugured HTTP client builders: + +include-code::MyHttpClientConfiguration[] diff --git a/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/Details.java b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/Details.java new file mode 100644 index 00000000000..cf5d98f2730 --- /dev/null +++ b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/Details.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docs.io.restclient.globalconfiguration.inetaddressfiltering; + +public class Details { + +} diff --git a/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyHttpClientConfiguration.java b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyHttpClientConfiguration.java new file mode 100644 index 00000000000..6b88328ff44 --- /dev/null +++ b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyHttpClientConfiguration.java @@ -0,0 +1,31 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docs.io.restclient.globalconfiguration.inetaddressfiltering; + +import org.springframework.boot.http.client.InetAddressFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@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"); + } + +} diff --git a/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyService.java b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyService.java new file mode 100644 index 00000000000..a8a8002875e --- /dev/null +++ b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyService.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.docs.io.restclient.globalconfiguration.inetaddressfiltering; + +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.http.client.InetAddressFilter; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +@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(); + } + + public Details someRestCall(String name) { + return this.restClient.get().uri("/{name}/details", name).retrieve().body(Details.class); + } + +} diff --git a/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/Details.kt b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/Details.kt new file mode 100644 index 00000000000..da14b832eec --- /dev/null +++ b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/Details.kt @@ -0,0 +1,4 @@ +package org.springframework.boot.docs.io.restclient.globalconfiguration.inetaddressfiltering + +class Details { +} diff --git a/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyHttpClientConfiguration.kt b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyHttpClientConfiguration.kt new file mode 100644 index 00000000000..b6aaa99cfe7 --- /dev/null +++ b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyHttpClientConfiguration.kt @@ -0,0 +1,15 @@ +package org.springframework.boot.docs.io.restclient.globalconfiguration.inetaddressfiltering + +import org.springframework.boot.http.client.InetAddressFilter +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration(proxyBeanMethods = false) +class MyHttpClientConfiguration { + + @Bean + fun httpClientInetAddressFilter(): InetAddressFilter { + return InetAddressFilter.of("192.168.1.0/24").andNot("192.168.1.1", "192.168.1.10") + } + +} diff --git a/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyService.kt b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyService.kt new file mode 100644 index 00000000000..6895f32dac3 --- /dev/null +++ b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/globalconfiguration/inetaddressfiltering/MyService.kt @@ -0,0 +1,27 @@ +package org.springframework.boot.docs.io.restclient.globalconfiguration.inetaddressfiltering + +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder +import org.springframework.boot.http.client.HttpClientSettings +import org.springframework.boot.http.client.InetAddressFilter +import org.springframework.http.client.ClientHttpRequestFactory +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient + +@Service +class MyService { + + private val restClient: RestClient + + init { + val onlyExternalAddresses = InetAddressFilter.externalAddresses() + val settings = HttpClientSettings.defaults().withInetAddressFilter(onlyExternalAddresses) + val requestFactory: ClientHttpRequestFactory = ClientHttpRequestFactoryBuilder.jdk().build(settings) + restClient = RestClient.builder().requestFactory(requestFactory).baseUrl("https://example.org").build() + } + + fun someRestCall(name: String?): Details { + return restClient.get().uri("/{name}/details", name) + .retrieve().body(Details::class.java)!! + } + +} diff --git a/module/spring-boot-http-client/build.gradle b/module/spring-boot-http-client/build.gradle index 22aebdd62d7..f287b94acb2 100644 --- a/module/spring-boot-http-client/build.gradle +++ b/module/spring-boot-http-client/build.gradle @@ -42,6 +42,7 @@ dependencies { testImplementation(project(":module:spring-boot-tomcat")) testImplementation(project(":test-support:spring-boot-test-support")) testImplementation("org.springframework:spring-webflux") + testImplementation("org.springframework.security:spring-security-core") testImplementation("io.micrometer:micrometer-observation-test") testCompileOnly("com.google.code.findbugs:jsr305") diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/FilteredAddresses.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/FilteredAddresses.java new file mode 100644 index 00000000000..05b6f4ad66e --- /dev/null +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/FilteredAddresses.java @@ -0,0 +1,85 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.util.List; +import java.util.Objects; +import java.util.function.IntFunction; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; + +import org.springframework.util.ObjectUtils; + +/** + * Internal utility to handle filtering of addresses and possibly throwing a + * {@link FilteredHostException}. + * + * @param the address type + * @author Phillip Webb + */ +final class FilteredAddresses { + + private final Stream stream; + + private FilteredAddresses(Stream stream) { + this.stream = stream; + } + + Filtered> toList() { + return new Filtered<>(this.stream.toList(), List::isEmpty); + } + + Filtered toArray(IntFunction generator) { + return new Filtered<>(this.stream.toArray(generator), ObjectUtils::isEmpty); + } + + Filtered get() { + return new Filtered<>(this.stream.findAny().orElse(null), Objects::isNull); + } + + static FilteredAddresses of(Stream stream, Predicate predicate) { + return new FilteredAddresses<>(stream.filter(Objects::nonNull).filter(predicate)); + } + + static final class Filtered { + + private final @Nullable T result; + + private final Predicate check; + + Filtered(@Nullable T result, Predicate check) { + this.result = result; + this.check = check; + } + + T orElseThrow(String host, InetAddressFilter filter) { + return orElseThrow(() -> host, filter); + } + + T orElseThrow(Supplier host, InetAddressFilter filter) { + if (this.result == null || this.check.test(this.result)) { + throw new FilteredHostException(host.get(), filter); + } + return this.result; + } + + } + +} diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/FilteredHostException.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/FilteredHostException.java new file mode 100644 index 00000000000..489336705ac --- /dev/null +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/FilteredHostException.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +/** + * Exception thrown when a host was not used due to an {@link InetAddressFilter}. + * + * @author Phillip Webb + * @since 4.1.0 + */ +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; + } + + /** + * Return the host that was not matched. + * @return the unmatched host + */ + public String getHost() { + return this.host; + } + + /** + * Return the filter that was used. + * @return the filter that didn't match + */ + public InetAddressFilter getFilter() { + return this.filter; + } + +} diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpClientSettings.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpClientSettings.java index b6966fd9bee..d73187318dd 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpClientSettings.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpClientSettings.java @@ -32,11 +32,13 @@ import org.springframework.boot.ssl.SslBundle; * @param connectTimeout the connect timeout * @param readTimeout the read timeout * @param sslBundle the SSL bundle providing SSL configuration + * @param inetAddressFilter the inetAddress filter used to filter out matching requests * @author Phillip Webb * @since 3.5.0 */ public record HttpClientSettings(@Nullable HttpCookieHandling cookieHandling, @Nullable HttpRedirects redirects, - @Nullable Duration connectTimeout, @Nullable Duration readTimeout, @Nullable SslBundle sslBundle) { + @Nullable Duration connectTimeout, @Nullable Duration readTimeout, @Nullable SslBundle sslBundle, + @Nullable InetAddressFilter inetAddressFilter) { /** * Create a new {@link HttpClientSettings} instance. @@ -53,7 +55,22 @@ public record HttpClientSettings(@Nullable HttpCookieHandling cookieHandling, @N this(null, redirects, connectTimeout, readTimeout, sslBundle); } - private static final HttpClientSettings defaults = new HttpClientSettings(null, null, null, null, null); + /** + * Create a new {@link HttpClientSettings} instance. + * @param cookieHandling the cookie handling strategy to use or null to use the + * underlying library's default + * @param redirects the follow redirect strategy to use + * @param connectTimeout the connect timeout + * @param readTimeout the read timeout + * @param sslBundle the SSL bundle providing SSL configuration + * @since 3.5.0 + */ + public HttpClientSettings(@Nullable HttpCookieHandling cookieHandling, @Nullable HttpRedirects redirects, + @Nullable Duration connectTimeout, @Nullable Duration readTimeout, @Nullable SslBundle sslBundle) { + this(cookieHandling, redirects, connectTimeout, readTimeout, sslBundle, null); + } + + private static final HttpClientSettings defaults = new HttpClientSettings(null, null, null, null, null, null); /** * Return a new {@link HttpClientSettings} instance with an updated cookie handling @@ -64,7 +81,7 @@ public record HttpClientSettings(@Nullable HttpCookieHandling cookieHandling, @N */ public HttpClientSettings withCookieHandling(@Nullable HttpCookieHandling cookieHandling) { return new HttpClientSettings(cookieHandling, this.redirects, this.connectTimeout, this.readTimeout, - this.sslBundle); + this.sslBundle, this.inetAddressFilter); } /** @@ -76,7 +93,7 @@ public record HttpClientSettings(@Nullable HttpCookieHandling cookieHandling, @N */ public HttpClientSettings withConnectTimeout(@Nullable Duration connectTimeout) { return new HttpClientSettings(this.cookieHandling, this.redirects, connectTimeout, this.readTimeout, - this.sslBundle); + this.sslBundle, this.inetAddressFilter); } /** @@ -88,7 +105,7 @@ public record HttpClientSettings(@Nullable HttpCookieHandling cookieHandling, @N */ public HttpClientSettings withReadTimeout(@Nullable Duration readTimeout) { return new HttpClientSettings(this.cookieHandling, this.redirects, this.connectTimeout, readTimeout, - this.sslBundle); + this.sslBundle, this.inetAddressFilter); } /** @@ -100,7 +117,8 @@ public record HttpClientSettings(@Nullable HttpCookieHandling cookieHandling, @N * @since 4.0.0 */ public HttpClientSettings withTimeouts(@Nullable Duration connectTimeout, @Nullable Duration readTimeout) { - return new HttpClientSettings(this.cookieHandling, this.redirects, connectTimeout, readTimeout, this.sslBundle); + return new HttpClientSettings(this.cookieHandling, this.redirects, connectTimeout, readTimeout, this.sslBundle, + this.inetAddressFilter); } /** @@ -112,7 +130,7 @@ public record HttpClientSettings(@Nullable HttpCookieHandling cookieHandling, @N */ public HttpClientSettings withSslBundle(@Nullable SslBundle sslBundle) { return new HttpClientSettings(this.cookieHandling, this.redirects, this.connectTimeout, this.readTimeout, - sslBundle); + sslBundle, this.inetAddressFilter); } /** @@ -123,7 +141,19 @@ public record HttpClientSettings(@Nullable HttpCookieHandling cookieHandling, @N */ public HttpClientSettings withRedirects(@Nullable HttpRedirects redirects) { return new HttpClientSettings(this.cookieHandling, redirects, this.connectTimeout, this.readTimeout, - this.sslBundle); + this.sslBundle, this.inetAddressFilter); + } + + /** + * Return a new {@link HttpClientSettings} instance with an updated inetAddress + * filter. + * @param inetAddressFilter the new inetAddress filter + * @return a new {@link HttpClientSettings} instance + * @since 4.1.0 + */ + public HttpClientSettings withInetAddressFilter(@Nullable InetAddressFilter inetAddressFilter) { + return new HttpClientSettings(this.cookieHandling, this.redirects, this.connectTimeout, this.readTimeout, + this.sslBundle, inetAddressFilter); } /** @@ -142,7 +172,10 @@ public record HttpClientSettings(@Nullable HttpCookieHandling cookieHandling, @N Duration connectTimeout = (connectTimeout() != null) ? connectTimeout() : other.connectTimeout(); Duration readTimeout = (readTimeout() != null) ? readTimeout() : other.readTimeout(); SslBundle sslBundle = (sslBundle() != null) ? sslBundle() : other.sslBundle(); - return new HttpClientSettings(cookieHandling, redirects, connectTimeout, readTimeout, sslBundle); + InetAddressFilter inetAddressFilter = (inetAddressFilter() != null) ? inetAddressFilter() + : other.inetAddressFilter(); + return new HttpClientSettings(cookieHandling, redirects, connectTimeout, readTimeout, sslBundle, + inetAddressFilter); } /** diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilder.java index d0645da06c1..0f16fa2d50e 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilder.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.function.Consumer; import java.util.function.UnaryOperator; +import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; @@ -157,6 +158,19 @@ public final class HttpComponentsClientHttpRequestFactoryBuilder this.httpClientBuilder.withDefaultRequestConfigCustomizer(defaultRequestConfigCustomizer)); } + /** + * Return a new {@link HttpComponentsClientHttpRequestFactoryBuilder} with a + * replacement {@link DnsResolver}. + * @param dnsResolver the new DNS resolver + * @return a new {@link HttpComponentsClientHttpRequestFactoryBuilder} instance + * @since 4.1.0 + */ + public HttpComponentsClientHttpRequestFactoryBuilder withDnsResolver(DnsResolver dnsResolver) { + Assert.notNull(dnsResolver, "'dnsResolver' must not be null"); + return new HttpComponentsClientHttpRequestFactoryBuilder(getCustomizers(), + this.httpClientBuilder.withDnsResolver(dnsResolver)); + } + /** * Return a new {@link HttpComponentsClientHttpRequestFactoryBuilder} that applies the * given customizer. This can be useful for applying pre-packaged customizations. diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsFilteredDnsResolver.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsFilteredDnsResolver.java new file mode 100644 index 00000000000..10444a510d6 --- /dev/null +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsFilteredDnsResolver.java @@ -0,0 +1,70 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; + +import org.apache.hc.client5.http.DnsResolver; + +import org.springframework.util.ObjectUtils; + +/** + * HTTP Components {@link DnsResolver} that filters using a {@link InetAddressFilter}. + * + * @author Phillip Webb + */ +class HttpComponentsFilteredDnsResolver implements DnsResolver { + + private final DnsResolver delegate; + + private final InetAddressFilter filter; + + HttpComponentsFilteredDnsResolver(DnsResolver delegate, InetAddressFilter filter) { + this.delegate = delegate; + this.filter = filter; + } + + @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); + } + + @Override + public List resolve(String host, int port) throws UnknownHostException { + List resolved = this.delegate.resolve(host, port); + if (resolved.isEmpty()) { + return resolved; + } + return FilteredAddresses.of(resolved.stream(), this.filter::matches).toList().orElseThrow(host, this.filter); + } + + @Override + public String resolveCanonicalHostname(String host) throws UnknownHostException { + return this.delegate.resolveCanonicalHostname(host); + } + +} diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpAsyncClientBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpAsyncClientBuilder.java index 60986059954..9c509ae34fd 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpAsyncClientBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpAsyncClientBuilder.java @@ -21,6 +21,8 @@ import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Function; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; import org.apache.hc.client5.http.async.HttpAsyncClient; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; @@ -57,21 +59,24 @@ public final class HttpComponentsHttpAsyncClientBuilder { private final Function<@Nullable SslBundle, @Nullable TlsStrategy> tlsStrategyFactory; + private final DnsResolver dnsResolver; + public HttpComponentsHttpAsyncClientBuilder() { this(Empty.consumer(), Empty.consumer(), Empty.consumer(), Empty.consumer(), - HttpComponentsSslBundleTlsStrategy::get); + HttpComponentsSslBundleTlsStrategy::get, SystemDefaultDnsResolver.INSTANCE); } private HttpComponentsHttpAsyncClientBuilder(Consumer customizer, Consumer connectionManagerCustomizer, Consumer connectionConfigCustomizer, Consumer defaultRequestConfigCustomizer, - Function<@Nullable SslBundle, @Nullable TlsStrategy> tlsStrategyFactory) { + Function<@Nullable SslBundle, @Nullable TlsStrategy> tlsStrategyFactory, DnsResolver dnsResolver) { this.customizer = customizer; this.connectionManagerCustomizer = connectionManagerCustomizer; this.connectionConfigCustomizer = connectionConfigCustomizer; this.defaultRequestConfigCustomizer = defaultRequestConfigCustomizer; this.tlsStrategyFactory = tlsStrategyFactory; + this.dnsResolver = dnsResolver; } /** @@ -84,7 +89,7 @@ public final class HttpComponentsHttpAsyncClientBuilder { Assert.notNull(customizer, "'customizer' must not be null"); return new HttpComponentsHttpAsyncClientBuilder(this.customizer.andThen(customizer), this.connectionManagerCustomizer, this.connectionConfigCustomizer, this.defaultRequestConfigCustomizer, - this.tlsStrategyFactory); + this.tlsStrategyFactory, this.dnsResolver); } /** @@ -98,7 +103,7 @@ public final class HttpComponentsHttpAsyncClientBuilder { Assert.notNull(connectionManagerCustomizer, "'connectionManagerCustomizer' must not be null"); return new HttpComponentsHttpAsyncClientBuilder(this.customizer, this.connectionManagerCustomizer.andThen(connectionManagerCustomizer), this.connectionConfigCustomizer, - this.defaultRequestConfigCustomizer, this.tlsStrategyFactory); + this.defaultRequestConfigCustomizer, this.tlsStrategyFactory, this.dnsResolver); } /** @@ -113,7 +118,7 @@ public final class HttpComponentsHttpAsyncClientBuilder { Assert.notNull(connectionConfigCustomizer, "'connectionConfigCustomizer' must not be null"); return new HttpComponentsHttpAsyncClientBuilder(this.customizer, this.connectionManagerCustomizer, this.connectionConfigCustomizer.andThen(connectionConfigCustomizer), - this.defaultRequestConfigCustomizer, this.tlsStrategyFactory); + this.defaultRequestConfigCustomizer, this.tlsStrategyFactory, this.dnsResolver); } /** @@ -127,7 +132,8 @@ public final class HttpComponentsHttpAsyncClientBuilder { Function<@Nullable SslBundle, @Nullable TlsStrategy> tlsStrategyFactory) { Assert.notNull(tlsStrategyFactory, "'tlsStrategyFactory' must not be null"); return new HttpComponentsHttpAsyncClientBuilder(this.customizer, this.connectionManagerCustomizer, - this.connectionConfigCustomizer, this.defaultRequestConfigCustomizer, tlsStrategyFactory); + this.connectionConfigCustomizer, this.defaultRequestConfigCustomizer, tlsStrategyFactory, + this.dnsResolver); } /** @@ -143,7 +149,22 @@ public final class HttpComponentsHttpAsyncClientBuilder { Assert.notNull(defaultRequestConfigCustomizer, "'defaultRequestConfigCustomizer' must not be null"); return new HttpComponentsHttpAsyncClientBuilder(this.customizer, this.connectionManagerCustomizer, this.connectionConfigCustomizer, - this.defaultRequestConfigCustomizer.andThen(defaultRequestConfigCustomizer), this.tlsStrategyFactory); + this.defaultRequestConfigCustomizer.andThen(defaultRequestConfigCustomizer), this.tlsStrategyFactory, + this.dnsResolver); + } + + /** + * Return a new {@link HttpComponentsHttpAsyncClientBuilder} with a replacement + * {@link DnsResolver}. + * @param dnsResolver the new DNS resolver + * @return a new {@link HttpComponentsHttpAsyncClientBuilder} instance + * @since 4.1.0 + */ + public HttpComponentsHttpAsyncClientBuilder withDnsResolver(DnsResolver dnsResolver) { + Assert.notNull(dnsResolver, "'dnsResolver' must not be null"); + return new HttpComponentsHttpAsyncClientBuilder(this.customizer, this.connectionManagerCustomizer, + this.connectionConfigCustomizer, this.defaultRequestConfigCustomizer, this.tlsStrategyFactory, + dnsResolver); } /** @@ -168,6 +189,11 @@ public final class HttpComponentsHttpAsyncClientBuilder { PropertyMapper map = PropertyMapper.get(); builder.setDefaultConnectionConfig(createConnectionConfig(settings)); map.from(settings::sslBundle).as(this.tlsStrategyFactory::apply).to(builder::setTlsStrategy); + DnsResolver dnsResolver = this.dnsResolver; + if (settings.inetAddressFilter() != null) { + dnsResolver = new HttpComponentsFilteredDnsResolver(dnsResolver, settings.inetAddressFilter()); + } + builder.setDnsResolver(dnsResolver); this.connectionManagerCustomizer.accept(builder); return builder.build(); } diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpClientBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpClientBuilder.java index c14ecf3a3d9..a2e7e185702 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpClientBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpClientBuilder.java @@ -21,6 +21,8 @@ import java.time.Duration; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import org.apache.hc.client5.http.DnsResolver; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.cookie.StandardCookieSpec; @@ -60,9 +62,11 @@ public final class HttpComponentsHttpClientBuilder { private final TlsSocketStrategyFactory tlsSocketStrategyFactory; + private final DnsResolver dnsResolver; + public HttpComponentsHttpClientBuilder() { this(Empty.consumer(), Empty.consumer(), Empty.consumer(), Empty.consumer(), Empty.consumer(), - HttpComponentsSslBundleTlsStrategy::get); + HttpComponentsSslBundleTlsStrategy::get, SystemDefaultDnsResolver.INSTANCE); } private HttpComponentsHttpClientBuilder(Consumer customizer, @@ -70,13 +74,14 @@ public final class HttpComponentsHttpClientBuilder { Consumer socketConfigCustomizer, Consumer connectionConfigCustomizer, Consumer defaultRequestConfigCustomizer, - TlsSocketStrategyFactory tlsSocketStrategyFactory) { + TlsSocketStrategyFactory tlsSocketStrategyFactory, DnsResolver dnsResolver) { this.customizer = customizer; this.connectionManagerCustomizer = connectionManagerCustomizer; this.socketConfigCustomizer = socketConfigCustomizer; this.connectionConfigCustomizer = connectionConfigCustomizer; this.defaultRequestConfigCustomizer = defaultRequestConfigCustomizer; this.tlsSocketStrategyFactory = tlsSocketStrategyFactory; + this.dnsResolver = dnsResolver; } /** @@ -89,7 +94,7 @@ public final class HttpComponentsHttpClientBuilder { Assert.notNull(customizer, "'customizer' must not be null"); return new HttpComponentsHttpClientBuilder(this.customizer.andThen(customizer), this.connectionManagerCustomizer, this.socketConfigCustomizer, this.connectionConfigCustomizer, - this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory); + this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory, this.dnsResolver); } /** @@ -103,7 +108,8 @@ public final class HttpComponentsHttpClientBuilder { Assert.notNull(connectionManagerCustomizer, "'connectionManagerCustomizer' must not be null"); return new HttpComponentsHttpClientBuilder(this.customizer, this.connectionManagerCustomizer.andThen(connectionManagerCustomizer), this.socketConfigCustomizer, - this.connectionConfigCustomizer, this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory); + this.connectionConfigCustomizer, this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory, + this.dnsResolver); } /** @@ -118,7 +124,7 @@ public final class HttpComponentsHttpClientBuilder { Assert.notNull(socketConfigCustomizer, "'socketConfigCustomizer' must not be null"); return new HttpComponentsHttpClientBuilder(this.customizer, this.connectionManagerCustomizer, this.socketConfigCustomizer.andThen(socketConfigCustomizer), this.connectionConfigCustomizer, - this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory); + this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory, this.dnsResolver); } /** @@ -133,7 +139,7 @@ public final class HttpComponentsHttpClientBuilder { Assert.notNull(connectionConfigCustomizer, "'connectionConfigCustomizer' must not be null"); return new HttpComponentsHttpClientBuilder(this.customizer, this.connectionManagerCustomizer, this.socketConfigCustomizer, this.connectionConfigCustomizer.andThen(connectionConfigCustomizer), - this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory); + this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory, this.dnsResolver); } /** @@ -150,7 +156,7 @@ public final class HttpComponentsHttpClientBuilder { Assert.notNull(tlsSocketStrategyFactory, "'tlsSocketStrategyFactory' must not be null"); return new HttpComponentsHttpClientBuilder(this.customizer, this.connectionManagerCustomizer, this.socketConfigCustomizer, this.connectionConfigCustomizer, this.defaultRequestConfigCustomizer, - tlsSocketStrategyFactory); + tlsSocketStrategyFactory, this.dnsResolver); } /** @@ -167,7 +173,21 @@ public final class HttpComponentsHttpClientBuilder { return new HttpComponentsHttpClientBuilder(this.customizer, this.connectionManagerCustomizer, this.socketConfigCustomizer, this.connectionConfigCustomizer, this.defaultRequestConfigCustomizer.andThen(defaultRequestConfigCustomizer), - this.tlsSocketStrategyFactory); + this.tlsSocketStrategyFactory, this.dnsResolver); + } + + /** + * Return a new {@link HttpComponentsHttpClientBuilder} with a replacement + * {@link DnsResolver}. + * @param dnsResolver the new DNS resolver + * @return a new {@link HttpComponentsHttpClientBuilder} instance + * @since 4.1.0 + */ + public HttpComponentsHttpClientBuilder withDnsResolver(DnsResolver dnsResolver) { + Assert.notNull(dnsResolver, "'dnsResolver' must not be null"); + return new HttpComponentsHttpClientBuilder(this.customizer, this.connectionManagerCustomizer, + this.socketConfigCustomizer, this.connectionConfigCustomizer, this.defaultRequestConfigCustomizer, + this.tlsSocketStrategyFactory, dnsResolver); } /** @@ -196,6 +216,11 @@ public final class HttpComponentsHttpClientBuilder { .always() .as(this.tlsSocketStrategyFactory::getTlsSocketStrategy) .to(builder::setTlsSocketStrategy); + DnsResolver dnsResolver = this.dnsResolver; + if (settings.inetAddressFilter() != null) { + dnsResolver = new HttpComponentsFilteredDnsResolver(dnsResolver, settings.inetAddressFilter()); + } + builder.setDnsResolver(dnsResolver); this.connectionManagerCustomizer.accept(builder); return builder.build(); } diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/InetAddressFilter.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/InetAddressFilter.java new file mode 100644 index 00000000000..2dfd54be693 --- /dev/null +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/InetAddressFilter.java @@ -0,0 +1,320 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.Arrays; +import java.util.Collection; +import java.util.function.Predicate; + +import org.jspecify.annotations.Nullable; + +import org.springframework.util.Assert; + +/** + * Strategy interface used for {@link InetAddress}-based filtering. + *

+ * Allow HTTP clients to offer Server-Side Request Forgery (SSRF) mitigation features, for + * example by only allowing local addresses to be called. + *

+ * Filters are typically built using the static factory methods on this interface, + * optionally combined with one or more of the logic methods. For example: + *

+ * InetAddressFilter.of("192.168.0.0/24")
+ * 	.andNot("192.168.0.1");
+ * 
+ * + * @author Rossen Stoyanchev + * @author Rob Winch + * @author Phillip Webb + * @since 4.1.0 + * @see FilteredHostException + */ +@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. + * @param address the address to check + * @return if the address matches + */ + boolean matches(InetAddress address); + + /** + * Return a composed filter that represents a short-circuiting logical AND of this + * filter and other IP addresses. + * @param addresses the addresses that will be logically-ANDed with this filter in any + * form supported by {@link #of(String...)} + * @return a new composed filter instance + */ + default InetAddressFilter and(String... addresses) { + return and(Arrays.stream(addresses).map(IpAddress::of).map(IpAddress::filter).toList()); + } + + /** + * Return a composed filter that represents a short-circuiting logical AND of this + * filter and other filters. + * @param filters the filters that will be logically-ANDed with this filter + * @return a new composed filter instance + */ + default InetAddressFilter and(InetAddressFilter... filters) { + return and(Arrays.asList(filters)); + } + + /** + * Return a composed filter that represents a short-circuiting logical AND of this + * filter and other filters. + * @param filters the filters that will be logically-ANDed with this filter + * @return a new composed filter instance + */ + default InetAddressFilter and(Collection filters) { + InetAddressFilter result = this; + for (InetAddressFilter filter : filters) { + InetAddressFilter ours = result; + result = (address) -> ours.matches(address) && filter.matches(address); + } + return result; + } + + /** + * Return a composed filter that represents a short-circuiting logical AND of this + * filter and other {@link #negate() negated} IP addresses. + * @param addresses the addresses that will be {@link #negate() negated} and + * logically-ANDed with this filter in any form supported by {@link #of(String...)} + * @return a new composed filter instance + */ + default InetAddressFilter andNot(String... addresses) { + return andNot(Arrays.stream(addresses).map(IpAddress::of).map(IpAddress::filter).toList()); + } + + /** + * Return a composed filter that represents a short-circuiting logical AND of this + * filter and other {@link #negate() negated} filters. + * @param filters the filters that will be {@link #negate() negated} and + * logically-ANDed with this filter + * @return a new composed filter instance + */ + default InetAddressFilter andNot(InetAddressFilter... filters) { + return andNot(Arrays.asList(filters)); + } + + /** + * Return a composed filter that represents a short-circuiting logical AND of this + * filter and other {@link #negate() negated} filters. + * @param filters the filters that will be {@link #negate() negated} and + * logically-ANDed with this filter + * @return a new composed filter instance + */ + default InetAddressFilter andNot(Collection filters) { + InetAddressFilter result = this; + for (InetAddressFilter filter : filters) { + InetAddressFilter ours = result; + result = (address) -> ours.matches(address) && !filter.matches(address); + } + return result; + } + + /** + * Return a composed filter that represents a short-circuiting logical OR of this + * filter and other IP addresses. + * @param addresses the addresses that will be logically-ORed with this filter in any + * form supported by {@link #of(String...)} + * @return a new composed filter instance + */ + default InetAddressFilter or(String... addresses) { + return or(Arrays.stream(addresses).map(IpAddress::of).map(IpAddress::filter).toList()); + } + + /** + * Return a composed filter that represents a short-circuiting logical OR of this + * filter and other filters. + * @param filters the matchers that will be logically-ORed with this filter + * @return a new composed filter instance + */ + default InetAddressFilter or(InetAddressFilter... filters) { + return or(Arrays.asList(filters)); + } + + /** + * Return a composed filter that represents a short-circuiting logical OR of this + * filter and other filters. + * @param filters the filters that will be logically-ORed with this filter + * @return a new composed filter instance + */ + default InetAddressFilter or(Collection filters) { + InetAddressFilter result = this; + for (InetAddressFilter matcher : filters) { + InetAddressFilter ours = result; + result = (address) -> ours.matches(address) || matcher.matches(address); + } + return result; + } + + /** + * Return a new filter that represents the logical negation of this filter. + * @return the negated filter + */ + default InetAddressFilter negate() { + return (address) -> !matches(address); + } + + /** + * Return a filter that will match external (non-private) IP addresses. External + * addresses are all non-{@link #internalAddresses() internal addresses} + * @return a filter for external IP addresses + * @see #internalAddresses() + */ + static InetAddressFilter externalAddresses() { + return routable().andNot(InternalInetAddressFilter.instance); + } + + /** + * Return a filter that will match internal (private) IP addresses. + *

+ * Internal addresses include loopback addresses ({@code 127.0.0.0/8} for IPv4, + * {@code ::1} for IPv6), private IPv4 address ranges ({@code 10.0.0.0/8}, + * {@code 172.16.0.0/12}, {@code 192.168.0.0/16}), and IPv6 Unique Local Addresses + * ({@code fc00::/7}). + * @return a filter for external IP addresses + * @see #externalAddresses() + */ + static InetAddressFilter internalAddresses() { + return routable().and(InternalInetAddressFilter.instance); + } + + /** + * Returns a filter that will match all routable addresses (not all zeros). + * @return a filter for routable IP addresses + */ + 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; + }; + } + + /** + * Return a filter that is the negation of all the given addresses. + * @param addresses the addresses to negate in any form supported by + * {@link #of(String...)} + * @return a negated filter + * @see #negate() + */ + static InetAddressFilter not(String... addresses) { + return all().andNot(addresses); + } + + /** + * Return a filter that is the negation of all the given matchers. + * @param filters the filters to negate + * @return a negated filter + * @see #negate() + */ + static InetAddressFilter not(InetAddressFilter... filters) { + return all().andNot(filters); + } + + /** + * Return a filter that is the negation of all the given filters. + * @param filters the filters to negate + * @return a negated filter + * @see #negate() + */ + static InetAddressFilter not(Collection filters) { + return all().andNot(filters); + } + + /** + * Return a filter that matches any of the given IP addresses. Address may be either a + * full IP address (e.g. {@code 192.168.1.1}) or an IP address block spcified using + * CIDR notations (for example {@code 192.168.1.0/24}). Both IPv4 and IPv6 addresses + * are supported. + * @param addresses the IP addresses to match + * @return a filter that matches any of the given addresses + */ + static InetAddressFilter of(String... addresses) { + return none().or(addresses); + } + + /** + * Return a filter that matches any of the given filters. + * @param filters the filters to include + * @return a filter that matches any of the filters + */ + static InetAddressFilter of(InetAddressFilter... filters) { + return none().or(filters); + } + + /** + * Return a filter that matches any of the given filters. + * @param filters the filters to include + * @return a filter that matches any of the filters + */ + static InetAddressFilter of(Collection filters) { + return none().or(filters); + } + + /** + * Adapt the given {@link Predicate} into an {@link InetAddressFilter}. + * @param predicate the predicate to adapt + * @return a filter that matches using the predicate + */ + static InetAddressFilter adapt(Predicate<@Nullable InetAddress> predicate) { + Assert.notNull(predicate, "'predicate' must not be null"); + return (address) -> address != null && predicate.test(address); + } + + /** + * Return a filter that matches all addresses. + * @return a filter that matches all + */ + static InetAddressFilter all() { + return (address) -> { + Assert.notNull(address, "'address' must not be null"); + return true; + }; + } + + /** + * Return a filter that matches no addresses. + * @return a filter that matches none + */ + static InetAddressFilter none() { + return (address) -> { + Assert.notNull(address, "'address' must not be null"); + return false; + }; + } + +} diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/InternalInetAddressFilter.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/InternalInetAddressFilter.java new file mode 100644 index 00000000000..2182c596153 --- /dev/null +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/InternalInetAddressFilter.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; + +import org.springframework.util.Assert; + +/** + * An {@link InetAddressFilter} that matches internal (private) addresses. + *

+ * Internal addresses include loopback addresses (127.0.0.0/8 for IPv4, ::1 for IPv6), + * private IPv4 address ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), and IPv6 + * Unique Local Addresses (fc00::/7). + * + * @author Gábor Vaspöri + * @author Kian Jamali + * @author Rossen Stoyanchev + * @author Rob Winch + * @author Phillip Webb + */ +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(); + + private 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. + * @param address the address to check + * @return if the addess is site local + */ + private boolean isSiteLocalIpv6Address(byte[] address) { + return (address.length == 16) + && (address[0] == (byte) 0xfc || address[0] == (byte) 0xfd || isNat64Local(address)); + } + + private boolean isNat64Local(byte[] address) { + if (!Arrays.equals(address, 0, NAT64_PREFIX.length, NAT64_PREFIX, 0, NAT64_PREFIX.length)) { + return false; + } + try { // IPv4/IPv6 translation, 64:ff9b + return isLocal(InetAddress.getByAddress(Arrays.copyOfRange(address, 12, 16))); + } + catch (UnknownHostException ex) { + return false; // Should not happen for 4-byte array + } + } + + private boolean isLocal(InetAddress address) { + return address.isLoopbackAddress() || address.isLinkLocalAddress() || address.isSiteLocalAddress(); + } + +} diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/IpAddress.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/IpAddress.java new file mode 100644 index 00000000000..538c0074d7a --- /dev/null +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/IpAddress.java @@ -0,0 +1,136 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.regex.Pattern; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * An IP address and optional mask as used in Classless Inter-Domain Routing (CIDR). + * + * @author Luke Taylor + * @author Steve Riesenberg + * @author Andrey Litvitski + * @author Rob Winch + * @author Phillip Webb + */ +final class IpAddress { + + private static Pattern IPV4 = Pattern.compile("^\\d{1,3}(?:\\.\\d{1,3}){0,3}(?:/\\d{1,2})?$"); + + private final InetAddress address; + + private int maskBitSize; + + private IpAddress(InetAddress address, int maskBitSize) { + Assert.notNull(address, "'address' must not be null"); + Assert.isTrue(maskBitSize >= -1, "'maskBitSize' must be positive or -1"); + Assert.isTrue(address.getAddress().length * 8 >= maskBitSize, () -> String + .format("IP address %s is too short for bitmask of length %d", address.getHostAddress(), maskBitSize)); + this.address = address; + this.maskBitSize = maskBitSize; + } + + InetAddressFilter filter() { + return (address) -> { + Assert.notNull(address, "'address' must not be null"); + if (this.maskBitSize == -1) { + return this.address.equals(address); + } + if (this.maskBitSize == 0) { + return true; + } + byte[] ours = this.address.getAddress(); + byte[] theirs = address.getAddress(); + return (ours.length == theirs.length) && matchesMasked(ours, theirs); + }; + } + + 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; + } + + @Override + public String toString() { + String hostAddress = this.address.getHostAddress(); + String suffix = (this.maskBitSize != -1) ? "/" + this.maskBitSize : ""; + return hostAddress + suffix; + } + + /** + * Factory method to create a new {@link IpAddress} from a string. + * @param address the IP address (plain or in CIDR notation) + * @return a new {@link IpAddress} instance + */ + static IpAddress of(String address) { + Assert.hasText(address, "'address' must not be empty"); + int slash = address.indexOf('/'); + if (slash == -1) { + return new IpAddress(parseInetAddress(address), -1); + } + InetAddress parsedAddress = parseInetAddress(address.substring(0, slash)); + Assert.state(parsedAddress != null, "'address' [%s] did not parse".formatted(address)); + int parseMaskBitSize = parseMaskBitSize(address.substring(slash + 1)); + return new IpAddress(parsedAddress, parseMaskBitSize); + + } + + private static int parseMaskBitSize(String maskBitSize) { + try { + return Integer.parseInt(maskBitSize); + } + catch (NumberFormatException ex) { + throw new IllegalArgumentException("'address' subnet mask must be a number", ex); + } + } + + 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); + } + } + + private static boolean isLikelyIpAddress(String address) { + return StringUtils.hasText(address) && (IPV4.matcher(address).matches() || isLikelyIpv6Address(address)); + } + + private static boolean isLikelyIpv6Address(String address) { + char firstChar = address.charAt(0); + return (firstChar == '[' || firstChar == ':') || (isHexDigit(firstChar) && address.contains(":")); + } + + private static boolean isHexDigit(char ch) { + return Character.digit(ch, 16) != -1; + } + +} diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilder.java index b292371feff..314b17e58e3 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilder.java @@ -16,6 +16,7 @@ package org.springframework.boot.http.client; +import java.net.ProxySelector; import java.net.http.HttpClient; import java.util.Collection; import java.util.List; @@ -89,6 +90,19 @@ public final class JdkClientHttpRequestFactoryBuilder this.httpClientBuilder.withCustomizer(httpClientCustomizer)); } + /** + * Return a new {@link JdkClientHttpRequestFactoryBuilder} with a replacement + * {@link ProxySelector}. + * @param proxySelector the new proxy selector + * @return a new {@link JdkClientHttpRequestFactoryBuilder} instance + * @since 4.1.0 + */ + public JdkClientHttpRequestFactoryBuilder withProxySelector(ProxySelector proxySelector) { + Assert.notNull(proxySelector, "'proxySelector' must not be null"); + return new JdkClientHttpRequestFactoryBuilder(getCustomizers(), + this.httpClientBuilder.withProxySelector(proxySelector)); + } + /** * Return a new {@link JdkClientHttpRequestFactoryBuilder} that applies the given * customizer. This can be useful for applying pre-packaged customizations. diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkFilteredProxySelector.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkFilteredProxySelector.java new file mode 100644 index 00000000000..84ec8e9091a --- /dev/null +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkFilteredProxySelector.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.util.List; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; + +/** + * JDK {@link ProxySelector} to check a URL is not filtered by a + * {@link InetAddressFilter}. + * + * @author Phillip Webb + */ +class JdkFilteredProxySelector extends ProxySelector { + + private final ProxySelector delegate; + + private final InetAddressFilter filter; + + JdkFilteredProxySelector(ProxySelector delegate, InetAddressFilter filter) { + this.delegate = delegate; + this.filter = filter; + } + + @Override + public List 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; + } + } + + @Override + public void connectFailed(URI uri, SocketAddress address, IOException ex) { + this.delegate.connectFailed(uri, address, ex); + } + +} diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkHttpClientBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkHttpClientBuilder.java index 13e1f6d8970..cd88d9d884a 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkHttpClientBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JdkHttpClientBuilder.java @@ -18,6 +18,7 @@ package org.springframework.boot.http.client; import java.net.CookieHandler; import java.net.CookieManager; +import java.net.ProxySelector; import java.net.http.HttpClient; import java.net.http.HttpClient.Redirect; import java.util.concurrent.Executor; @@ -44,12 +45,15 @@ public final class JdkHttpClientBuilder { private final Consumer customizer; + private final ProxySelector proxySelector; + public JdkHttpClientBuilder() { - this(Empty.consumer()); + this(Empty.consumer(), ProxySelector.getDefault()); } - private JdkHttpClientBuilder(Consumer customizer) { + private JdkHttpClientBuilder(Consumer customizer, ProxySelector proxySelector) { this.customizer = customizer; + this.proxySelector = proxySelector; } /** @@ -72,7 +76,18 @@ public final class JdkHttpClientBuilder { */ public JdkHttpClientBuilder withCustomizer(Consumer customizer) { Assert.notNull(customizer, "'customizer' must not be null"); - return new JdkHttpClientBuilder(this.customizer.andThen(customizer)); + return new JdkHttpClientBuilder(this.customizer.andThen(customizer), this.proxySelector); + } + + /** + * Return a new {@link JdkHttpClientBuilder} with a replacement {@link ProxySelector}. + * @param proxySelector the new proxy selector + * @return a new {@link JdkHttpClientBuilder} instance + * @since 4.1.0 + */ + public JdkHttpClientBuilder withProxySelector(ProxySelector proxySelector) { + Assert.notNull(proxySelector, "'proxySelector' must not be null"); + return new JdkHttpClientBuilder(this.customizer, proxySelector); } /** @@ -90,6 +105,7 @@ public final class JdkHttpClientBuilder { map.from(settings::connectTimeout).to(builder::connectTimeout); map.from(settings::sslBundle).as(SslBundle::createSslContext).to(builder::sslContext); map.from(settings::sslBundle).as(this::asSslParameters).to(builder::sslParameters); + map.from(proxySelector(settings.inetAddressFilter())).to(builder::proxy); this.customizer.accept(builder); return builder.build(); } @@ -119,4 +135,8 @@ public final class JdkHttpClientBuilder { }; } + private @Nullable ProxySelector proxySelector(@Nullable InetAddressFilter filter) { + return (filter != null) ? new JdkFilteredProxySelector(this.proxySelector, filter) : this.proxySelector; + } + } diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilder.java index c65ef61e352..662023a051f 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilder.java @@ -26,6 +26,7 @@ import java.util.function.UnaryOperator; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.HttpClientTransport; import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.SocketAddressResolver; import org.jspecify.annotations.Nullable; import org.springframework.boot.context.properties.PropertyMapper; @@ -119,6 +120,19 @@ public final class JettyClientHttpRequestFactoryBuilder this.httpClientBuilder.withClientConnectorCustomizerCustomizer(clientConnectorCustomizerCustomizer)); } + /** + * Return a new {@link JettyClientHttpRequestFactoryBuilder} with a replacement + * {@link SocketAddressResolver}. + * @param socketAddressResolver the new socket address resolver + * @return a new {@link JettyClientHttpRequestFactoryBuilder} instance + * @since 4.1.0 + */ + public JettyClientHttpRequestFactoryBuilder withSocketAddressResolver(SocketAddressResolver socketAddressResolver) { + Assert.notNull(socketAddressResolver, "'socketAddressResolver' must not be null"); + return new JettyClientHttpRequestFactoryBuilder(getCustomizers(), + this.httpClientBuilder.withSocketAddressResolver(socketAddressResolver)); + } + /** * Return a new {@link JettyClientHttpRequestFactoryBuilder} that applies the given * customizer. This can be useful for applying pre-packaged customizations. diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JettyFilteredSocketAddressResolver.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JettyFilteredSocketAddressResolver.java new file mode 100644 index 00000000000..0c545e7025d --- /dev/null +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JettyFilteredSocketAddressResolver.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.InetSocketAddress; +import java.util.List; +import java.util.Map; + +import org.eclipse.jetty.util.Promise; +import org.eclipse.jetty.util.SocketAddressResolver; + +/** + * Jetty {@link SocketAddressResolver} that filters using a {@link InetAddressFilter}. + * + * @author Phillip Webb + */ +class JettyFilteredSocketAddressResolver implements SocketAddressResolver { + + private final SocketAddressResolver delegate; + + private final InetAddressFilter filter; + + JettyFilteredSocketAddressResolver(SocketAddressResolver delegate, InetAddressFilter filter) { + this.delegate = delegate; + this.filter = filter; + } + + @Override + public void resolve(String host, int port, Map context, Promise> promise) { + this.delegate.resolve(host, port, context, new FilteredPromise(host, promise)); + } + + class FilteredPromise implements Promise> { + + private final String host; + + private final Promise> delegate; + + FilteredPromise(String host, Promise> delegate) { + this.host = host; + this.delegate = delegate; + } + + @Override + public void succeeded(List result) { + try { + this.delegate.succeeded(filter(result)); + } + catch (FilteredHostException ex) { + failed(ex); + } + } + + private List filter(List result) { + InetAddressFilter filter = JettyFilteredSocketAddressResolver.this.filter; + return FilteredAddresses.of(result.stream(), filter::matches).toList().orElseThrow(this.host, filter); + } + + @Override + public void failed(Throwable ex) { + this.delegate.failed(ex); + } + + } + +} diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JettyHttpClientBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JettyHttpClientBuilder.java index e01647be3d3..3dd83d6471a 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JettyHttpClientBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/JettyHttpClientBuilder.java @@ -30,6 +30,7 @@ import org.eclipse.jetty.client.transport.HttpClientTransportDynamic; import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP; import org.eclipse.jetty.http.HttpCookieStore; import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.SocketAddressResolver; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.jspecify.annotations.Nullable; @@ -56,18 +57,23 @@ public final class JettyHttpClientBuilder { private final Consumer clientConnectorCustomizerCustomizer; + private final @Nullable SocketAddressResolver socketAddressResolver; + public JettyHttpClientBuilder() { - this(Empty.consumer(), JettyHttpClientBuilder::createHttpClientTransport, Empty.consumer(), Empty.consumer()); + this(Empty.consumer(), JettyHttpClientBuilder::createHttpClientTransport, Empty.consumer(), Empty.consumer(), + null); } private JettyHttpClientBuilder(Consumer customizer, Function httpClientTransportFactory, Consumer httpClientTransportCustomizer, - Consumer clientConnectorCustomizerCustomizer) { + Consumer clientConnectorCustomizerCustomizer, + @Nullable SocketAddressResolver socketAddressResolver) { this.customizer = customizer; this.httpClientTransportFactory = httpClientTransportFactory; this.httpClientTransportCustomizer = httpClientTransportCustomizer; this.clientConnectorCustomizerCustomizer = clientConnectorCustomizerCustomizer; + this.socketAddressResolver = socketAddressResolver; } private static HttpClientTransport createHttpClientTransport(ClientConnector connector) { @@ -84,7 +90,8 @@ public final class JettyHttpClientBuilder { public JettyHttpClientBuilder withCustomizer(Consumer customizer) { Assert.notNull(customizer, "'customizer' must not be null"); return new JettyHttpClientBuilder(this.customizer.andThen(customizer), this.httpClientTransportFactory, - this.httpClientTransportCustomizer, this.clientConnectorCustomizerCustomizer); + this.httpClientTransportCustomizer, this.clientConnectorCustomizerCustomizer, + this.socketAddressResolver); } /** @@ -98,7 +105,8 @@ public final class JettyHttpClientBuilder { Function httpClientTransportFactory) { Assert.notNull(httpClientTransportFactory, "'httpClientTransportFactory' must not be null"); return new JettyHttpClientBuilder(this.customizer, httpClientTransportFactory, - this.httpClientTransportCustomizer, this.clientConnectorCustomizerCustomizer); + this.httpClientTransportCustomizer, this.clientConnectorCustomizerCustomizer, + this.socketAddressResolver); } /** @@ -112,7 +120,7 @@ public final class JettyHttpClientBuilder { Assert.notNull(httpClientTransportCustomizer, "'httpClientTransportCustomizer' must not be null"); return new JettyHttpClientBuilder(this.customizer, this.httpClientTransportFactory, this.httpClientTransportCustomizer.andThen(httpClientTransportCustomizer), - this.clientConnectorCustomizerCustomizer); + this.clientConnectorCustomizerCustomizer, this.socketAddressResolver); } /** @@ -126,7 +134,21 @@ public final class JettyHttpClientBuilder { Assert.notNull(clientConnectorCustomizerCustomizer, "'clientConnectorCustomizerCustomizer' must not be null"); return new JettyHttpClientBuilder(this.customizer, this.httpClientTransportFactory, this.httpClientTransportCustomizer, - this.clientConnectorCustomizerCustomizer.andThen(clientConnectorCustomizerCustomizer)); + this.clientConnectorCustomizerCustomizer.andThen(clientConnectorCustomizerCustomizer), + this.socketAddressResolver); + } + + /** + * Return a new {@link JettyHttpClientBuilder} with a replacement + * {@link SocketAddressResolver}. + * @param socketAddressResolver the new socket address resolver + * @return a new {@link JettyHttpClientBuilder} instance + * @since 4.1.0 + */ + public JettyHttpClientBuilder withSocketAddressResolver(SocketAddressResolver socketAddressResolver) { + Assert.notNull(socketAddressResolver, "'socketAddressResolver' must not be null"); + return new JettyHttpClientBuilder(this.customizer, this.httpClientTransportFactory, + this.httpClientTransportCustomizer, this.clientConnectorCustomizerCustomizer, socketAddressResolver); } /** @@ -138,18 +160,19 @@ public final class JettyHttpClientBuilder { settings = (settings != null) ? settings : HttpClientSettings.defaults(); HttpClientTransport transport = createTransport(settings); this.httpClientTransportCustomizer.accept(transport); - HttpClient httpClient = createHttpClient(settings.readTimeout(), transport); + HttpClient httpClient = createHttpClient(settings, transport); PropertyMapper map = PropertyMapper.get(); map.from(settings::connectTimeout).as(Duration::toMillis).to(httpClient::setConnectTimeout); map.from(settings::cookieHandling).as(this::asCookieStore).to(httpClient::setHttpCookieStore); map.from(settings::redirects).always().as(this::followRedirects).to(httpClient::setFollowRedirects); + map.from(this.socketAddressResolver).to(httpClient::setSocketAddressResolver); this.customizer.accept(httpClient); return httpClient; } - private HttpClient createHttpClient(@Nullable Duration readTimeout, HttpClientTransport transport) { - return (readTimeout != null) ? new HttpClientWithReadTimeout(transport, readTimeout) - : new HttpClient(transport); + private HttpClient createHttpClient(HttpClientSettings settings, HttpClientTransport transport) { + return (settings.readTimeout() != null || settings.inetAddressFilter() != null) + ? new CustomizedHttpClient(transport, settings) : new HttpClient(transport); } private HttpClientTransport createTransport(HttpClientSettings settings) { @@ -202,21 +225,32 @@ public final class JettyHttpClientBuilder { } /** - * {@link HttpClient} subclass that sets the read timeout. + * {@link HttpClient} subclass to support customization. */ - static class HttpClientWithReadTimeout extends HttpClient { + static class CustomizedHttpClient extends HttpClient { - private final Duration readTimeout; + private final HttpClientSettings settings; - HttpClientWithReadTimeout(HttpClientTransport transport, Duration readTimeout) { + CustomizedHttpClient(HttpClientTransport transport, HttpClientSettings settings) { super(transport); - this.readTimeout = readTimeout; + this.settings = settings; + } + + @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); } @Override public org.eclipse.jetty.client.Request newRequest(java.net.URI uri) { Request request = super.newRequest(uri); - request.timeout(this.readTimeout.toMillis(), TimeUnit.MILLISECONDS); + if (this.settings.readTimeout() != null) { + request.timeout(this.settings.readTimeout().toMillis(), TimeUnit.MILLISECONDS); + } return request; } diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReactorClientHttpRequestFactoryBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReactorClientHttpRequestFactoryBuilder.java index c35cb485ae4..35d3d61304e 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReactorClientHttpRequestFactoryBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReactorClientHttpRequestFactoryBuilder.java @@ -25,6 +25,8 @@ import java.util.function.UnaryOperator; import org.jspecify.annotations.Nullable; import reactor.netty.http.client.HttpClient; +import reactor.netty.http.client.HttpClientConfig; +import reactor.netty.transport.ClientTransport.ResolvedAddressSelector; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.http.client.ReactorClientHttpRequestFactory; @@ -107,6 +109,21 @@ public final class ReactorClientHttpRequestFactoryBuilder this.httpClientBuilder.withHttpClientCustomizer(httpClientCustomizer)); } + /** + * Return a new {@link ReactorHttpClientBuilder} that uses the + * {@link ResolvedAddressSelector}. This method should be used in favor of a + * customizer so that {@link HttpClientSettings#inetAddressFilter()} can be applied. + * @param resolvedAddressSelector the resolved address selector to use + * @return a new {@link ReactorHttpClientBuilder} instance + * @since 4.1.0 + */ + public ReactorClientHttpRequestFactoryBuilder withResolvedAddressSelector( + ResolvedAddressSelector resolvedAddressSelector) { + Assert.notNull(resolvedAddressSelector, "'resolvedAddressSelector' must not be null"); + return new ReactorClientHttpRequestFactoryBuilder(getCustomizers(), + this.httpClientBuilder.withResolvedAddressSelector(resolvedAddressSelector)); + } + /** * Return a new {@link ReactorClientHttpRequestFactoryBuilder} that applies the given * customizer. This can be useful for applying pre-packaged customizations. diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReactorFilteredResolvedAddressSelector.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReactorFilteredResolvedAddressSelector.java new file mode 100644 index 00000000000..7dd0439bb5c --- /dev/null +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReactorFilteredResolvedAddressSelector.java @@ -0,0 +1,82 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.List; + +import org.jspecify.annotations.Nullable; +import reactor.netty.transport.ClientTransport.ResolvedAddressSelector; + +import org.springframework.util.CollectionUtils; + +/** + * Reactor Netty {@link ResolvedAddressSelector} that filters using a + * {@link InetAddressFilter}. + * + * @param the client configuration implementation + * @author Phillip Webb + */ +class ReactorFilteredResolvedAddressSelector implements ResolvedAddressSelector { + + private final @Nullable ResolvedAddressSelector delegate; + + private final InetAddressFilter filter; + + ReactorFilteredResolvedAddressSelector(@Nullable ResolvedAddressSelector delegate, + InetAddressFilter filter) { + this.delegate = delegate; + this.filter = filter; + } + + @Override + public @Nullable List apply(C config, List resolvedAddresses) { + return filter((this.delegate != null) ? this.delegate.apply(config, resolvedAddresses) : resolvedAddresses); + } + + private @Nullable List filter(@Nullable List resolvedAddresses) { + if (CollectionUtils.isEmpty(resolvedAddresses)) { + return resolvedAddresses; + } + return FilteredAddresses.of(resolvedAddresses.stream(), this::matches) + .toList() + .orElseThrow(() -> hostString(resolvedAddresses), this.filter); + } + + private boolean matches(SocketAddress address) { + return (address instanceof InetSocketAddress socketAddress) ? this.filter.matches(socketAddress) : true; + } + + private String hostString(List resolvedAddresses) { + List hosts = resolvedAddresses.stream() + .filter(InetSocketAddress.class::isInstance) + .map(InetSocketAddress.class::cast) + .map(InetSocketAddress::getAddress) + .map(InetAddress::getHostAddress) + .toList(); + if (hosts.isEmpty()) { + return "unknown"; + } + if (hosts.size() == 1) { + return hosts.get(0); + } + return hosts.toString(); + } + +} diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReactorHttpClientBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReactorHttpClientBuilder.java index c004e316210..c5dc7329c03 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReactorHttpClientBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReactorHttpClientBuilder.java @@ -26,7 +26,9 @@ import io.netty.channel.ChannelOption; import io.netty.handler.ssl.SslContextBuilder; import org.jspecify.annotations.Nullable; import reactor.netty.http.client.HttpClient; +import reactor.netty.http.client.HttpClientConfig; import reactor.netty.tcp.SslProvider.SslContextSpec; +import reactor.netty.transport.ClientTransport.ResolvedAddressSelector; import org.springframework.boot.context.properties.PropertyMapper; import org.springframework.boot.ssl.SslBundle; @@ -50,13 +52,17 @@ public final class ReactorHttpClientBuilder { private final UnaryOperator customizer; + private final @Nullable ResolvedAddressSelector resolvedAddressSelector; + public ReactorHttpClientBuilder() { - this(HttpClient::create, UnaryOperator.identity()); + this(HttpClient::create, UnaryOperator.identity(), null); } - private ReactorHttpClientBuilder(Supplier httpClientFactory, UnaryOperator customizer) { + private ReactorHttpClientBuilder(Supplier httpClientFactory, UnaryOperator customizer, + @Nullable ResolvedAddressSelector resolvedAddressSelector) { this.factory = httpClientFactory; this.customizer = customizer; + this.resolvedAddressSelector = resolvedAddressSelector; } /** @@ -68,7 +74,8 @@ public final class ReactorHttpClientBuilder { public ReactorHttpClientBuilder withReactorResourceFactory(ReactorResourceFactory reactorResourceFactory) { Assert.notNull(reactorResourceFactory, "'reactorResourceFactory' must not be null"); return new ReactorHttpClientBuilder(() -> HttpClient.create(reactorResourceFactory.getConnectionProvider()), - (httpClient) -> this.customizer.apply(httpClient).runOn(reactorResourceFactory.getLoopResources())); + (httpClient) -> this.customizer.apply(httpClient).runOn(reactorResourceFactory.getLoopResources()), + this.resolvedAddressSelector); } /** @@ -79,7 +86,7 @@ public final class ReactorHttpClientBuilder { */ public ReactorHttpClientBuilder withHttpClientFactory(Supplier factory) { Assert.notNull(factory, "'factory' must not be null"); - return new ReactorHttpClientBuilder(factory, this.customizer); + return new ReactorHttpClientBuilder(factory, this.customizer, this.resolvedAddressSelector); } /** @@ -91,7 +98,21 @@ public final class ReactorHttpClientBuilder { public ReactorHttpClientBuilder withHttpClientCustomizer(UnaryOperator customizer) { Assert.notNull(customizer, "'customizer' must not be null"); return new ReactorHttpClientBuilder(this.factory, - (httpClient) -> customizer.apply(this.customizer.apply(httpClient))); + (httpClient) -> customizer.apply(this.customizer.apply(httpClient)), this.resolvedAddressSelector); + } + + /** + * Return a new {@link ReactorHttpClientBuilder} that uses the + * {@link ResolvedAddressSelector}. This method should be used in favor of a + * customizer so that {@link HttpClientSettings#inetAddressFilter()} can be applied. + * @param resolvedAddressSelector the resolved address selector to use + * @return a new {@link ReactorHttpClientBuilder} instance + * @since 4.1.0 + */ + public ReactorHttpClientBuilder withResolvedAddressSelector( + ResolvedAddressSelector resolvedAddressSelector) { + Assert.notNull(resolvedAddressSelector, "'resolvedAddressSelector' must not be null"); + return new ReactorHttpClientBuilder(this.factory, this.customizer, resolvedAddressSelector); } /** @@ -113,9 +134,18 @@ public final class ReactorHttpClientBuilder { throw new IllegalArgumentException("Reactor Netty HTTP client does not support cookie handling"); } httpClient = map.from(settings::sslBundle).to(httpClient, this::secure); + httpClient = map.from(resolvedAddressSelector(settings.inetAddressFilter())) + .to(httpClient, HttpClient::resolvedAddressesSelector); return this.customizer.apply(httpClient); } + private @Nullable ResolvedAddressSelector resolvedAddressSelector( + @Nullable InetAddressFilter inetAddressFilter) { + return (inetAddressFilter != null) + ? new ReactorFilteredResolvedAddressSelector<>(this.resolvedAddressSelector, inetAddressFilter) + : this.resolvedAddressSelector; + } + HttpClient applyDefaults(HttpClient httpClient) { // Aligns with Spring Framework defaults return httpClient.compress(true); diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReflectiveComponentsClientHttpRequestFactoryBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReflectiveComponentsClientHttpRequestFactoryBuilder.java index 25ae283d228..e5706ab5e82 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReflectiveComponentsClientHttpRequestFactoryBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/ReflectiveComponentsClientHttpRequestFactoryBuilder.java @@ -82,6 +82,7 @@ final class ReflectiveComponentsClientHttpRequestFactoryBuilder setConnectTimeout(unwrapped, connectTimeout)); diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/SimpleClientHttpRequestFactoryBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/SimpleClientHttpRequestFactoryBuilder.java index fb9040a46ac..f78002d8b00 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/SimpleClientHttpRequestFactoryBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/SimpleClientHttpRequestFactoryBuilder.java @@ -78,9 +78,10 @@ public final class SimpleClientHttpRequestFactoryBuilder @Override protected SimpleClientHttpRequestFactory createClientHttpRequestFactory(HttpClientSettings settings) { - if (settings.cookieHandling() == HttpCookieHandling.ENABLE) { - throw new IllegalArgumentException("Simple HTTP request factory does not support HTTP cookie handling"); - } + 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"); SslBundle sslBundle = settings.sslBundle(); SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpsRequestFactory(settings); Assert.state(sslBundle == null || !sslBundle.getOptions().isSpecified(), diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfiguration.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfiguration.java index 6cb2740759c..2aa10702fd4 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfiguration.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfiguration.java @@ -23,6 +23,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.http.client.InetAddressFilter; import org.springframework.boot.ssl.SslBundles; import org.springframework.context.annotation.Bean; @@ -38,9 +39,15 @@ public final class HttpClientAutoConfiguration { @Bean @ConditionalOnMissingBean - HttpClientSettings httpClientSettings(ObjectProvider sslBundles, HttpClientsProperties properties) { + HttpClientSettings httpClientSettings(HttpClientsProperties properties, + ObjectProvider sslBundlesProvider, + ObjectProvider inetAddressFilterProvider) { + InetAddressFilter inetAddressFilter = inetAddressFilterProvider.getIfAvailable(); + HttpClientSettings settings = (inetAddressFilter != null) + ? HttpClientSettings.defaults().withInetAddressFilter(inetAddressFilter) + : HttpClientSettings.defaults(); HttpClientSettingsPropertyMapper propertyMapper = new HttpClientSettingsPropertyMapper( - sslBundles.getIfAvailable(), null); + sslBundlesProvider.getIfAvailable(), settings); return propertyMapper.map(properties); } diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilder.java index d47f8c424ed..33b2b2189e6 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilder.java @@ -21,6 +21,7 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.function.UnaryOperator; +import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; @@ -128,6 +129,19 @@ public final class HttpComponentsClientHttpConnectorBuilder this.httpClientBuilder.withDefaultRequestConfigCustomizer(defaultRequestConfigCustomizer)); } + /** + * Return a new {@link HttpComponentsClientHttpConnectorBuilder} with a replacement + * {@link DnsResolver}. + * @param dnsResolver the new DNS resolver + * @return a new {@link HttpComponentsClientHttpConnectorBuilder} instance + * @since 4.1.0 + */ + public HttpComponentsClientHttpConnectorBuilder withDnsResolver(DnsResolver dnsResolver) { + Assert.notNull(dnsResolver, "'dnsResolver' must not be null"); + return new HttpComponentsClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withDnsResolver(dnsResolver)); + } + /** * Return a new {@link HttpComponentsClientHttpConnectorBuilder} that applies the * given customizer. This can be useful for applying pre-packaged customizations. diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilder.java index 92cab4d5772..58aaa4a9818 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilder.java @@ -16,6 +16,7 @@ package org.springframework.boot.http.client.reactive; +import java.net.ProxySelector; import java.net.http.HttpClient; import java.util.Collection; import java.util.List; @@ -85,6 +86,19 @@ public final class JdkClientHttpConnectorBuilder extends AbstractClientHttpConne this.httpClientBuilder.withCustomizer(httpClientCustomizer)); } + /** + * Return a new {@link JdkClientHttpConnectorBuilder} with a replacement + * {@link ProxySelector}. + * @param proxySelector the new proxy selector + * @return a new {@link JdkClientHttpConnectorBuilder} instance + * @since 4.1.0 + */ + public JdkClientHttpConnectorBuilder withProxySelector(ProxySelector proxySelector) { + Assert.notNull(proxySelector, "'proxySelector' must not be null"); + return new JdkClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withProxySelector(proxySelector)); + } + /** * Return a new {@link JdkClientHttpConnectorBuilder} that applies the given * customizer. This can be useful for applying pre-packaged customizations. diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilder.java index 626807eb807..2d120daf234 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilder.java @@ -25,6 +25,7 @@ import java.util.function.UnaryOperator; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.HttpClientTransport; import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.SocketAddressResolver; import org.jspecify.annotations.Nullable; import org.springframework.boot.http.client.HttpClientSettings; @@ -116,6 +117,19 @@ public final class JettyClientHttpConnectorBuilder this.httpClientBuilder.withClientConnectorCustomizerCustomizer(clientConnectorCustomizerCustomizer)); } + /** + * Return a new {@link JettyClientHttpConnectorBuilder} with a replacement + * {@link SocketAddressResolver}. + * @param socketAddressResolver the new socket address resolver + * @return a new {@link JettyClientHttpConnectorBuilder} instance + * @since 4.1.0 + */ + public JettyClientHttpConnectorBuilder withSocketAddressResolver(SocketAddressResolver socketAddressResolver) { + Assert.notNull(socketAddressResolver, "'socketAddressResolver' must not be null"); + return new JettyClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withSocketAddressResolver(socketAddressResolver)); + } + /** * Return a new {@link JettyClientHttpConnectorBuilder} that applies the given * customizer. This can be useful for applying pre-packaged customizations. diff --git a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilder.java b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilder.java index 9a5d537f900..e8ab415456e 100644 --- a/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilder.java +++ b/module/spring-boot-http-client/src/main/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilder.java @@ -24,6 +24,8 @@ import java.util.function.UnaryOperator; import org.jspecify.annotations.Nullable; import reactor.netty.http.client.HttpClient; +import reactor.netty.http.client.HttpClientConfig; +import reactor.netty.transport.ClientTransport.ResolvedAddressSelector; import org.springframework.boot.http.client.HttpClientSettings; import org.springframework.boot.http.client.ReactorHttpClientBuilder; @@ -100,6 +102,21 @@ public final class ReactorClientHttpConnectorBuilder this.httpClientBuilder.withHttpClientCustomizer(httpClientCustomizer)); } + /** + * Return a new {@link ReactorHttpClientBuilder} that uses the + * {@link ResolvedAddressSelector}. This method should be used in favor of a + * customizer so that {@link HttpClientSettings#inetAddressFilter()} can be applied. + * @param resolvedAddressSelector the resolved address selector to use + * @return a new {@link ReactorHttpClientBuilder} instance + * @since 4.1.0 + */ + public ReactorClientHttpConnectorBuilder withResolvedAddressSelector( + ResolvedAddressSelector resolvedAddressSelector) { + Assert.notNull(resolvedAddressSelector, "'resolvedAddressSelector' must not be null"); + return new ReactorClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withResolvedAddressSelector(resolvedAddressSelector)); + } + /** * Return a new {@link ReactorClientHttpConnectorBuilder} that applies the given * customizer. This can be useful for applying pre-packaged customizations. diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/AbstractClientHttpRequestFactoryBuilderTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/AbstractClientHttpRequestFactoryBuilderTests.java index 2b13595e11e..e2930eaec5a 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/AbstractClientHttpRequestFactoryBuilderTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/AbstractClientHttpRequestFactoryBuilderTests.java @@ -53,6 +53,7 @@ import org.springframework.http.client.ClientHttpResponse; import org.springframework.util.StreamUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatException; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; /** @@ -188,6 +189,26 @@ abstract class AbstractClientHttpRequestFactoryBuilderTests context.addServlet("test", TestServlet.class).addMapping("/")); + try { + webServer.start(); + int port = webServer.getPort(); + URI uri = new URI("http://localhost:%s".formatted(port) + "/redirect"); + 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); + } + finally { + webServer.stop(); + } + } + private ClientHttpRequest request(ClientHttpRequestFactory factory, URI uri, String method) throws IOException { return factory.createRequest(uri, HttpMethod.valueOf(method)); } @@ -222,6 +243,10 @@ abstract class AbstractClientHttpRequestFactoryBuilderTests getBuilder() { + return this.builder; + } + public static class TestServlet extends HttpServlet { @Override diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/FilteredAddressesTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/FilteredAddressesTests.java new file mode 100644 index 00000000000..e5e5e4da0ee --- /dev/null +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/FilteredAddressesTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link FilteredAddresses}. + * + * @author Phillip Webb + */ +class FilteredAddressesTests { + + @Test + void toListOrElseThrowWhenNotEmptyReturnsResult() { + FilteredAddresses result = FilteredAddresses.of(Stream.of("127.0.0.1"), (address) -> true); + assertThat(result.toList().orElseThrow("localhost", mock())).containsExactly("127.0.0.1"); + } + + @Test + void toListOrElseThrowWhenEmptyThrowsException() { + FilteredAddresses result = FilteredAddresses.of(Stream.of("127.0.0.1"), (address) -> false); + assertThatExceptionOfType(FilteredHostException.class) + .isThrownBy(() -> result.toList().orElseThrow("localhost", mock())) + .withMessage("Filtered host 'localhost'"); + } + + @Test + void toArrayOrElseThrowWhenNotEmptyReturnsResult() { + FilteredAddresses result = FilteredAddresses.of(Stream.of("127.0.0.1"), (address) -> true); + assertThat(result.toArray(String[]::new).orElseThrow("localhost", mock())).containsExactly("127.0.0.1"); + } + + @Test + void toArrayOrElseThrowWhenEmptyThrowsException() { + FilteredAddresses result = FilteredAddresses.of(Stream.of("127.0.0.1"), (address) -> false); + assertThatExceptionOfType(FilteredHostException.class) + .isThrownBy(() -> result.toArray(String[]::new).orElseThrow("localhost", mock())) + .withMessage("Filtered host 'localhost'"); + } + + @Test + void getOrElseThrowWhenNotEmptyReturnsResult() { + FilteredAddresses result = FilteredAddresses.of(Stream.of("127.0.0.1"), (address) -> true); + assertThat(result.get().orElseThrow("localhost", mock())).isEqualTo("127.0.0.1"); + } + + @Test + void getOrElseThrowWhenEmptyThrowsException() { + FilteredAddresses result = FilteredAddresses.of(Stream.of("127.0.0.1"), (address) -> false); + assertThatExceptionOfType(FilteredHostException.class) + .isThrownBy(() -> result.get().orElseThrow("localhost", mock())) + .withMessage("Filtered host 'localhost'"); + } + +} diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/FilteredHostExceptionTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/FilteredHostExceptionTests.java new file mode 100644 index 00000000000..500ef011e01 --- /dev/null +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/FilteredHostExceptionTests.java @@ -0,0 +1,39 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link FilteredHostException}. + * + * @author Phillip Webb + */ +class FilteredHostExceptionTests { + + @Test + void create() { + InetAddressFilter matcher = (address) -> false; + FilteredHostException exception = new FilteredHostException("localhost", matcher); + assertThat(exception).hasMessage("Filtered host 'localhost'"); + assertThat(exception.getHost()).isEqualTo("localhost"); + assertThat(exception.getFilter()).isSameAs(matcher); + } + +} diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/HttpClientSettingsTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/HttpClientSettingsTests.java index 58d55443466..e26ed8fc039 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/HttpClientSettingsTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/HttpClientSettingsTests.java @@ -43,16 +43,29 @@ class HttpClientSettingsTests { assertThat(settings.connectTimeout()).isNull(); assertThat(settings.readTimeout()).isNull(); assertThat(settings.sslBundle()).isNull(); + assertThat(settings.inetAddressFilter()).isNull(); } @Test void createWithNulls() { + HttpClientSettings settings = new HttpClientSettings(null, null, null, null, null, null); + assertThat(settings.cookieHandling()).isNull(); + assertThat(settings.redirects()).isNull(); + assertThat(settings.connectTimeout()).isNull(); + assertThat(settings.readTimeout()).isNull(); + assertThat(settings.sslBundle()).isNull(); + assertThat(settings.inetAddressFilter()).isNull(); + } + + @Test + void createWithNullsWhenSpringBoot41() { HttpClientSettings settings = new HttpClientSettings(null, null, null, null, null); assertThat(settings.cookieHandling()).isNull(); assertThat(settings.redirects()).isNull(); assertThat(settings.connectTimeout()).isNull(); assertThat(settings.readTimeout()).isNull(); assertThat(settings.sslBundle()).isNull(); + assertThat(settings.inetAddressFilter()).isNull(); } @Test @@ -62,6 +75,7 @@ class HttpClientSettingsTests { assertThat(settings.connectTimeout()).isEqualTo(ONE_SECOND); assertThat(settings.readTimeout()).isNull(); assertThat(settings.sslBundle()).isNull(); + assertThat(settings.inetAddressFilter()).isNull(); } @Test @@ -71,6 +85,7 @@ class HttpClientSettingsTests { assertThat(settings.connectTimeout()).isNull(); assertThat(settings.readTimeout()).isEqualTo(ONE_SECOND); assertThat(settings.sslBundle()).isNull(); + assertThat(settings.inetAddressFilter()).isNull(); } @Test @@ -81,6 +96,7 @@ class HttpClientSettingsTests { assertThat(settings.connectTimeout()).isNull(); assertThat(settings.readTimeout()).isNull(); assertThat(settings.sslBundle()).isSameAs(sslBundle); + assertThat(settings.inetAddressFilter()).isNull(); } @Test @@ -91,6 +107,7 @@ class HttpClientSettingsTests { assertThat(settings.connectTimeout()).isNull(); assertThat(settings.readTimeout()).isNull(); assertThat(settings.sslBundle()).isNull(); + assertThat(settings.inetAddressFilter()).isNull(); } @Test @@ -100,19 +117,33 @@ class HttpClientSettingsTests { assertThat(settings.connectTimeout()).isNull(); assertThat(settings.readTimeout()).isNull(); assertThat(settings.sslBundle()).isNull(); + assertThat(settings.inetAddressFilter()).isNull(); + } + + @Test + void withInetAddressMatcherReturnsInstanceWithUpdatedInetAddressMatcher() { + InetAddressFilter inetAddressMatcher = mock(); + HttpClientSettings settings = HttpClientSettings.defaults().withInetAddressFilter(inetAddressMatcher); + assertThat(settings.redirects()).isNull(); + assertThat(settings.connectTimeout()).isNull(); + assertThat(settings.readTimeout()).isNull(); + assertThat(settings.sslBundle()).isNull(); + assertThat(settings.inetAddressFilter()).isEqualTo(inetAddressMatcher); } @Test void orElseReturnsNewInstanceWithUpdatedValues() { - SslBundle sslBundle = mock(SslBundle.class); - HttpClientSettings settings = new HttpClientSettings(null, null, ONE_SECOND, null, null) + SslBundle sslBundle = mock(); + InetAddressFilter inetAddressMatcher = mock(); + HttpClientSettings settings = new HttpClientSettings(null, null, ONE_SECOND, null, null, null) .orElse(new HttpClientSettings(HttpCookieHandling.ENABLE, HttpRedirects.FOLLOW_WHEN_POSSIBLE, TWO_SECONDS, - TWO_SECONDS, sslBundle)); + TWO_SECONDS, sslBundle, inetAddressMatcher)); assertThat(settings.cookieHandling()).isEqualTo(HttpCookieHandling.ENABLE); assertThat(settings.redirects()).isEqualTo(HttpRedirects.FOLLOW_WHEN_POSSIBLE); assertThat(settings.connectTimeout()).isEqualTo(ONE_SECOND); assertThat(settings.readTimeout()).isEqualTo(TWO_SECONDS); assertThat(settings.sslBundle()).isEqualTo(sslBundle); + assertThat(settings.inetAddressFilter()).isEqualTo(inetAddressMatcher); } @Test @@ -123,6 +154,7 @@ class HttpClientSettingsTests { assertThat(settings.connectTimeout()).isNull(); assertThat(settings.readTimeout()).isNull(); assertThat(settings.sslBundle()).isSameAs(sslBundle); + assertThat(settings.inetAddressFilter()).isNull(); } } diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilderTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilderTests.java index ba2ef777ebf..87720a73790 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilderTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilderTests.java @@ -19,6 +19,7 @@ package org.springframework.boot.http.client; import java.util.ArrayList; import java.util.List; +import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.HttpRoute; import org.apache.hc.client5.http.config.ConnectionConfig; import org.apache.hc.client5.http.config.RequestConfig; @@ -35,10 +36,12 @@ import org.junit.jupiter.params.provider.EnumSource; import org.springframework.boot.http.client.HttpComponentsHttpClientBuilder.TlsSocketStrategyFactory; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.testsupport.classpath.resources.WithPackageResources; +import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Tests for {@link HttpComponentsClientHttpRequestFactoryBuilder} and @@ -105,6 +108,27 @@ class HttpComponentsClientHttpRequestFactoryBuilderTests customizer.assertCalled(); } + @Test + void withDnsResolver() { + DnsResolver dnsResolver = mock(); + ClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.httpComponents() + .withDnsResolver(dnsResolver) + .build(); + assertThat(factory).extracting("httpClient.connManager.connectionOperator.dnsResolver").isSameAs(dnsResolver); + } + + @Test + void withDnsResolverWhenHasInetAddressMatcher() { + DnsResolver dnsResolver = mock(); + ClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.httpComponents() + .withDnsResolver(dnsResolver) + .build(HttpClientSettings.defaults().withInetAddressFilter(InetAddressFilter.externalAddresses())); + assertThat(factory).extracting("httpClient.connManager.connectionOperator.dnsResolver") + .matches((resolver) -> resolver.getClass().getName().contains("HttpComponentsFiltered")); + assertThat(factory).extracting("httpClient.connManager.connectionOperator.dnsResolver.delegate") + .isSameAs(dnsResolver); + } + @Test void defaultCookieHandling() { HttpComponentsClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.httpComponents() diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/HttpComponentsFilteredDnsResolverTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/HttpComponentsFilteredDnsResolverTests.java new file mode 100644 index 00000000000..f4fe182205f --- /dev/null +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/HttpComponentsFilteredDnsResolverTests.java @@ -0,0 +1,114 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.List; + +import org.apache.hc.client5.http.DnsResolver; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link HttpComponentsFilteredDnsResolver}. + * + * @author Phillip Webb + */ +class HttpComponentsFilteredDnsResolverTests { + + @Test + void resolveHostWhenMatchedOnlyElement() throws Exception { + DnsResolver delegate = mock(); + InetAddress localhost = InetAddress.getLocalHost(); + given(delegate.resolve("localhost")).willReturn(new InetAddress[] { localhost }); + HttpComponentsFilteredDnsResolver dnsResolver = new HttpComponentsFilteredDnsResolver(delegate, + InetAddressFilter.internalAddresses()); + assertThat(dnsResolver.resolve("localhost")).containsExactly(localhost); + } + + @Test + void resolveHostWhenMatchedOneOfManyElements() throws Exception { + DnsResolver delegate = mock(); + InetAddress localhost = InetAddress.getLocalHost(); + InetAddress remote = InetAddress.getByName("8.8.8.8"); + given(delegate.resolve("localhost")).willReturn(new InetAddress[] { localhost, remote }); + HttpComponentsFilteredDnsResolver dnsResolver = new HttpComponentsFilteredDnsResolver(delegate, + InetAddressFilter.internalAddresses()); + assertThat(dnsResolver.resolve("localhost")).containsExactly(localhost); + } + + @Test + void resolveHostWhenMatchedNoElements() throws UnknownHostException { + DnsResolver delegate = mock(); + InetAddress localhost = InetAddress.getLocalHost(); + InetAddress remote = InetAddress.getByName("8.8.8.8"); + given(delegate.resolve("localhost")).willReturn(new InetAddress[] { localhost, remote }); + HttpComponentsFilteredDnsResolver dnsResolver = new HttpComponentsFilteredDnsResolver(delegate, + InetAddressFilter.externalAddresses().andNot("8.8.8.8")); + assertThatExceptionOfType(FilteredHostException.class).isThrownBy(() -> dnsResolver.resolve("localhost")); + } + + @Test + void resolveHostAndPortWhenMatchedOnlyElement() throws Exception { + DnsResolver delegate = mock(); + InetAddress localhost = InetAddress.getLocalHost(); + given(delegate.resolve("localhost", 8080)).willReturn(List.of(new InetSocketAddress(localhost, 8080))); + HttpComponentsFilteredDnsResolver dnsResolver = new HttpComponentsFilteredDnsResolver(delegate, + InetAddressFilter.internalAddresses()); + assertThat(dnsResolver.resolve("localhost", 8080)).containsExactly(new InetSocketAddress(localhost, 8080)); + } + + @Test + void resolveHostAndPortWhenMatchedOneOfManyElements() throws Exception { + DnsResolver delegate = mock(); + InetAddress localhost = InetAddress.getLocalHost(); + InetAddress remote = InetAddress.getByName("8.8.8.8"); + given(delegate.resolve("localhost", 8080)) + .willReturn(List.of(new InetSocketAddress(localhost, 8080), new InetSocketAddress(remote, 8080))); + HttpComponentsFilteredDnsResolver dnsResolver = new HttpComponentsFilteredDnsResolver(delegate, + InetAddressFilter.internalAddresses()); + assertThat(dnsResolver.resolve("localhost", 8080)).containsExactly(new InetSocketAddress(localhost, 8080)); + } + + @Test + void resolveHostAndPortWhenMatchedNoElements() throws UnknownHostException { + DnsResolver delegate = mock(); + InetAddress localhost = InetAddress.getLocalHost(); + InetAddress remote = InetAddress.getByName("8.8.8.8"); + given(delegate.resolve("localhost", 8080)) + .willReturn(List.of(new InetSocketAddress(localhost, 8080), new InetSocketAddress(remote, 8080))); + HttpComponentsFilteredDnsResolver dnsResolver = new HttpComponentsFilteredDnsResolver(delegate, + InetAddressFilter.externalAddresses().andNot("8.8.8.8")); + assertThatExceptionOfType(FilteredHostException.class).isThrownBy(() -> dnsResolver.resolve("localhost", 8080)); + } + + @Test + void resolveCanonicalHostnameDelegates() throws Exception { + DnsResolver delegate = mock(); + given(delegate.resolveCanonicalHostname("spring")).willReturn("boot"); + HttpComponentsFilteredDnsResolver dnsResolver = new HttpComponentsFilteredDnsResolver(delegate, + InetAddressFilter.internalAddresses()); + assertThat(dnsResolver.resolveCanonicalHostname("spring")).isEqualTo("boot"); + } + +} diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/InetAddressFilterAssert.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/InetAddressFilterAssert.java new file mode 100644 index 00000000000..4ea213af145 --- /dev/null +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/InetAddressFilterAssert.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; + +import org.assertj.core.api.AbstractObjectAssert; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Assertions for an {@link InetAddressFilter}. + * + * @author Phillip Webb + */ +class InetAddressFilterAssert extends AbstractObjectAssert { + + InetAddressFilterAssert(InetAddressFilter actual) { + super(actual, InetAddressFilterAssert.class); + } + + InetAddressFilterAssert matches(String address) { + return matches(asInetAddress(address)); + } + + InetAddressFilterAssert matches(InetAddress address) { + isNotNull(); + assertThat(this.actual.matches(address)).as("Matches address %s", address).isTrue(); + return this; + } + + InetAddressFilterAssert matches(InetSocketAddress address) { + isNotNull(); + assertThat(this.actual.matches(address)).as("Matches socket address %s", address).isTrue(); + return this; + } + + InetAddressFilterAssert doesNotMatch(String address) { + return doesNotMatch(asInetAddress(address)); + } + + InetAddressFilterAssert doesNotMatch(InetAddress address) { + isNotNull(); + assertThat(this.actual.matches(address)).as("Does not match address %s", address).isFalse(); + return this; + } + + InetAddressFilterAssert doesNotMatch(InetSocketAddress address) { + isNotNull(); + assertThat(this.actual.matches(address)).as("Does not match socket address %s", address).isFalse(); + return this; + } + + private InetAddress asInetAddress(String address) { + try { + return InetAddress.getByName(address); + } + catch (UnknownHostException ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/InetAddressFilterTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/InetAddressFilterTests.java new file mode 100644 index 00000000000..3eb8d408913 --- /dev/null +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/InetAddressFilterTests.java @@ -0,0 +1,723 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.List; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.security.util.matcher.InetAddressMatcher; +import org.springframework.security.util.matcher.InetAddressMatchers; + +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link InetAddressFilter} and {@link InternalInetAddressFilter}. + * + * @author Rob Winch + * @author Phillip Webb + */ +class InetAddressFilterTests { + + private static InetAddressFilterAssert assertThat(InetAddressFilter filter) { + return new InetAddressFilterAssert(filter); + } + + @Nested + class MatchesSocketAddressTests { + + @Test + void whenIpv4() { + InetAddressFilter filter = InetAddressFilter.of("192.168.1.1"); + assertThat(filter).matches(new InetSocketAddress("192.168.1.1", 8080)); + assertThat(filter).doesNotMatch(new InetSocketAddress("192.168.1.2", 8080)); + } + + @Test + void whenIpv6() { + InetAddressFilter filter = InetAddressFilter.of("fe80:0:0:0:21f:5bff:fe33:bd68"); + assertThat(filter).matches(new InetSocketAddress("fe80::21f:5bff:fe33:bd68", 8080)); + assertThat(filter).doesNotMatch(new InetSocketAddress("fe90::21f:5bff:fe33:bd68", 8080)); + } + + @Test + @SuppressWarnings("NullAway") // Test null check + void whenNull() { + InetAddressFilter filter = (address) -> address != null; + assertThatIllegalArgumentException().isThrownBy(() -> filter.matches((InetSocketAddress) null)) + .withMessage("'address' must not be null"); + } + + @Test + void whenLambda() { + InetAddressFilter filter = (address) -> address.getHostAddress().startsWith("192.168"); + assertThat(filter).matches(new InetSocketAddress("192.168.1.1", 8080)); + assertThat(filter).matches(new InetSocketAddress("192.168.100.200", 8080)); + assertThat(filter).doesNotMatch(new InetSocketAddress("10.0.0.1", 8080)); + } + + } + + @Nested + class AndTests { + + @Test + void stringsWhenEmpty() { + InetAddressFilter originalFilter = (address) -> true; + InetAddressFilter filter = originalFilter.and(new String[] {}); + assertThat(filter).isSameAs(originalFilter); + } + + @Test + void stringsWhenSingle() { + InetAddressFilter originalFilter = InetAddressFilter.of("192.168.1.1/24"); + InetAddressFilter filter = originalFilter.and("192.168.1.1"); + assertThat(originalFilter).matches("192.168.1.1"); + assertThat(originalFilter).matches("192.168.1.2"); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).doesNotMatch("192.168.1.2"); + } + + @Test + void stringsWhenMultiple() { + InetAddressFilter originalFilter = InetAddressFilter.of("192.168.1.1/16"); + InetAddressFilter filter = originalFilter.and("192.168.1.1/24", "192.168.1.1"); + assertThat(originalFilter).matches("192.168.1.1"); + assertThat(originalFilter).matches("192.168.1.2"); + assertThat(originalFilter).matches("192.168.2.1"); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).doesNotMatch("192.168.1.2"); + assertThat(filter).doesNotMatch("192.168.2.1"); + } + + @Test + void filters() { + InetAddressFilter startsWithTen = (address) -> address.getHostAddress().startsWith("10."); + InetAddressFilter endsWithOne = (address) -> address.getHostAddress().endsWith(".1"); + InetAddressFilter filter = startsWithTen.and(endsWithOne); + assertThat(filter).matches("10.0.0.1"); + assertThat(filter).doesNotMatch("10.0.0.2"); + assertThat(filter).doesNotMatch("192.168.1.1"); + } + + @Test + void collection() { + InetAddressFilter originalfilter = InetAddressFilter.of("192.168.1.1/24"); + InetAddressFilter filter = originalfilter.and(List.of(InetAddressFilter.of("192.168.1.1"))); + assertThat(originalfilter).matches("192.168.1.1"); + assertThat(originalfilter).matches("192.168.1.2"); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).doesNotMatch("192.168.1.2"); + } + + } + + @Nested + class AndNotTests { + + @Test + void stringsWhenEmpty() { + InetAddressFilter originalFilter = (address) -> true; + InetAddressFilter filter = originalFilter.andNot(new String[] {}); + assertThat(filter).isSameAs(originalFilter); + } + + @Test + void stringsWhenSingle() { + InetAddressFilter originalFilter = InetAddressFilter.of("192.168.1.1/24"); + InetAddressFilter filter = originalFilter.andNot("192.168.1.1"); + assertThat(originalFilter).matches("192.168.1.1"); + assertThat(originalFilter).matches("192.168.1.2"); + assertThat(filter).doesNotMatch("192.168.1.1"); + assertThat(filter).matches("192.168.1.2"); + } + + @Test + void stringsWhenMultiple() { + InetAddressFilter originalFilter = InetAddressFilter.of("192.168.1.1/24"); + InetAddressFilter filter = originalFilter.andNot("192.168.1.1", "192.168.1.2"); + assertThat(originalFilter).matches("192.168.1.1"); + assertThat(originalFilter).matches("192.168.1.2"); + assertThat(originalFilter).matches("192.168.1.3"); + assertThat(filter).doesNotMatch("192.168.1.1"); + assertThat(filter).doesNotMatch("192.168.1.2"); + assertThat(filter).matches("192.168.1.3"); + } + + @Test + void filters() { + InetAddressFilter startsWithTen = (address) -> address.getHostAddress().startsWith("10."); + InetAddressFilter endsWithOne = (address) -> address.getHostAddress().endsWith(".1"); + InetAddressFilter filter = startsWithTen.andNot(endsWithOne); + assertThat(filter).doesNotMatch("10.0.0.1"); + assertThat(filter).matches("10.0.0.2"); + assertThat(filter).doesNotMatch("192.168.1.1"); + } + + @Test + void collection() { + InetAddressFilter originalFilter = InetAddressFilter.of("192.168.1.1/24"); + InetAddressFilter filter = originalFilter.andNot(List.of(InetAddressFilter.of("192.168.1.1"))); + assertThat(originalFilter).matches("192.168.1.1"); + assertThat(originalFilter).matches("192.168.1.2"); + assertThat(filter).doesNotMatch("192.168.1.1"); + assertThat(filter).matches("192.168.1.2"); + } + + } + + @Nested + class OrTests { + + @Test + void stringsWhenEmpty() { + InetAddressFilter originalFilter = (address) -> true; + InetAddressFilter filter = originalFilter.or(new String[] {}); + assertThat(filter).isSameAs(originalFilter); + } + + @Test + void stringsWhenSingle() { + InetAddressFilter originalFilter = InetAddressFilter.of("192.168.1.1"); + InetAddressFilter filter = originalFilter.or("192.168.1.2"); + assertThat(originalFilter).matches("192.168.1.1"); + assertThat(originalFilter).doesNotMatch("192.168.1.2"); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).matches("192.168.1.2"); + } + + @Test + void stringsWhenMultiple() { + InetAddressFilter originalFilter = InetAddressFilter.of("192.168.1.1"); + InetAddressFilter filter = originalFilter.or("192.168.1.2", "192.168.1.3"); + assertThat(originalFilter).matches("192.168.1.1"); + assertThat(originalFilter).doesNotMatch("192.168.1.2"); + assertThat(originalFilter).doesNotMatch("192.168.1.3"); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).matches("192.168.1.2"); + assertThat(filter).matches("192.168.1.3"); + } + + @Test + void filter() { + InetAddressFilter originalFilter = InetAddressFilter.of("192.168.1.1"); + InetAddressFilter filter = originalFilter.or(InetAddressFilter.of("192.168.1.2")); + assertThat(originalFilter).matches("192.168.1.1"); + assertThat(originalFilter).doesNotMatch("192.168.1.2"); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).matches("192.168.1.2"); + } + + @Test + void collection() { + InetAddressFilter originalFilter = InetAddressFilter.of("192.168.1.1"); + InetAddressFilter filter = originalFilter.or(List.of(InetAddressFilter.of("192.168.1.2"))); + assertThat(originalFilter).matches("192.168.1.1"); + assertThat(originalFilter).doesNotMatch("192.168.1.2"); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).matches("192.168.1.2"); + } + + } + + @Nested + class NegateTests { + + @Test + void negate() { + InetAddressFilter originalFilter = InetAddressFilter.of("192.168.1.1"); + InetAddressFilter filter = originalFilter.negate(); + assertThat(originalFilter).matches("192.168.1.1"); + assertThat(originalFilter).doesNotMatch("192.168.1.2"); + assertThat(filter).doesNotMatch("192.168.1.1"); + assertThat(filter).matches("192.168.1.2"); + } + + } + + @Nested + class ExternalAddressesTests { + + @Test + @SuppressWarnings("NullAway") // Test null check + void nullInetAddress() { + InetAddressFilter filter = InetAddressFilter.externalAddresses(); + assertThatIllegalArgumentException().isThrownBy(() -> filter.matches((InetAddress) null)) + .withMessage("'address' must not be null"); + } + + @Test + void ipv4Public() { + InetAddressFilter filter = InetAddressFilter.externalAddresses(); + assertThat(filter).matches("8.8.8.8"); + assertThat(filter).matches("1.1.1.1"); + } + + @Test + void ipv6Public() { + InetAddressFilter filter = InetAddressFilter.externalAddresses(); + assertThat(filter).matches("2001:4860:4860::8888"); + } + + @Test + void ipv4Private() { + InetAddressFilter filter = InetAddressFilter.externalAddresses(); + assertThat(filter).doesNotMatch("192.168.1.1"); + assertThat(filter).doesNotMatch("10.0.0.1"); + assertThat(filter).doesNotMatch("172.16.0.1"); + } + + @Test + void ipv4Loopback() { + InetAddressFilter filter = InetAddressFilter.externalAddresses(); + assertThat(filter).doesNotMatch("127.0.0.1"); + assertThat(filter).doesNotMatch("127.1.1.1"); + } + + @Test + void ipv4LinkLocal() { + InetAddressFilter filter = InetAddressFilter.externalAddresses(); + assertThat(filter).doesNotMatch("169.254.0.0"); + assertThat(filter).doesNotMatch("169.254.169.254"); + assertThat(filter).doesNotMatch("169.254.255.255"); + } + + @Test + void ipv6Loopback() { + InetAddressFilter filter = InetAddressFilter.externalAddresses(); + assertThat(filter).doesNotMatch("::1"); + assertThat(filter).doesNotMatch("0000::1"); + } + + @Test + void ipv6UniqueLocal() { + InetAddressFilter filter = InetAddressFilter.externalAddresses(); + assertThat(filter).doesNotMatch("fc00::1"); + assertThat(filter).doesNotMatch("fd00::1"); + } + + @Test + void ipv4NonRoutable() { + InetAddressFilter filter = InetAddressFilter.externalAddresses(); + assertThat(filter).doesNotMatch("0.0.0.0"); + } + + @Test + void ipv6NonRoutable() { + InetAddressFilter filter = InetAddressFilter.externalAddresses(); + assertThat(filter).doesNotMatch("0000:0000:0000:0000:0000:0000:0000:0000"); + assertThat(filter).doesNotMatch("::"); + } + + } + + @Nested + class InternalAddresses { + + @Test + @SuppressWarnings("NullAway") // Test null check + void nullInetAddress() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThatIllegalArgumentException().isThrownBy(() -> filter.matches((InetAddress) null)) + .withMessage("'address' must not be null"); + } + + @Test + void ipv4Loopback() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).matches("127.0.0.1"); + assertThat(filter).matches("127.1.1.1"); + assertThat(filter).matches("127.0.0.255"); + } + + @Test + void ipv6Loopback() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).matches("::1"); + assertThat(filter).matches("0000::1"); + } + + @Test + void ipv4PrivateClass10() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).matches("10.0.0.1"); + assertThat(filter).matches("10.255.255.255"); + } + + @Test + void ipv4PrivateClass192() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).matches("192.168.0.1"); + assertThat(filter).matches("192.168.255.255"); + } + + @Test + void ipv4LinkLocal() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).matches("169.254.0.0"); + assertThat(filter).matches("169.254.169.254"); + assertThat(filter).matches("169.254.255.255"); + } + + @Test + void ipv4PrivateClass172() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).matches("172.16.0.1"); + assertThat(filter).matches("172.16.255.255"); + assertThat(filter).matches("172.17.1.1"); + assertThat(filter).matches("172.31.255.255"); + } + + @Test + void ipv4MappedIpv6Internal() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).matches("::ffff:127.0.0.1"); + assertThat(filter).matches("::ffff:192.168.1.1"); + assertThat(filter).matches("::ffff:169.254.169.254"); + assertThat(filter).matches("::ffff:10.0.0.1"); + } + + @Test + void ipv6UniqueLocal() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).matches("fc00::1"); + assertThat(filter).matches("fd00::1"); + } + + @Test + void ipv6TranslationWithInternalIpv4() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).matches("64:ff9b::10.0.0.1"); + assertThat(filter).matches("64:ff9b::127.0.0.1"); + assertThat(filter).matches("64:ff9b::192.168.1.1"); + assertThat(filter).matches("64:ff9b::172.16.0.1"); + } + + @Test + void ipv6TranslationWithIpv4StartsWith192ButNot168() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).doesNotMatch("64:ff9b::192.0.2.1"); + assertThat(filter).doesNotMatch("64:ff9b::192.167.1.1"); + } + + @Test + void ipv6TranslationWithIpv4StartsWith172And16() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).matches("64:ff9b::172.16.0.1"); + assertThat(filter).matches("64:ff9b::172.16.255.255"); + } + + @Test + @ValueSource(strings = {}) + void ipv6TranslationWithExternalIpv4() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).doesNotMatch("64:ff9b::8.8.8.8"); + assertThat(filter).doesNotMatch("64:ff9b::1.1.1.1"); + } + + @Test + void ppv6NonTranslationPrefixByte0() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).doesNotMatch("65:ff9b::10.0.0.1"); + } + + @Test + void ipv6NonTranslationPrefixByte1() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).doesNotMatch("64:fe9b::10.0.0.1"); + } + + @Test + void ipv6NonTranslationPrefixByte2() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).doesNotMatch("64:ff9a::10.0.0.1"); + } + + @Test + void ipv6NonTranslationPrefixByte3() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).doesNotMatch("64:ff9c::10.0.0.1"); + } + + @Test + void ipv4Public() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).doesNotMatch("8.8.8.8"); + assertThat(filter).doesNotMatch("1.1.1.1"); + } + + @Test + void ipv4StartsWith192ButNot168() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).doesNotMatch("192.0.2.1"); + assertThat(filter).doesNotMatch("192.167.1.1"); + assertThat(filter).doesNotMatch("192.169.1.1"); + } + + @Test + void ipv4StartsWith172ButNotPrivate16To31() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).doesNotMatch("172.15.1.1"); + assertThat(filter).doesNotMatch("172.32.1.1"); + } + + @Test + void ipv6Public() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).doesNotMatch("2001:4860:4860::8888"); + } + + @Test + void ipv4NonRoutable() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).doesNotMatch("0.0.0.0"); + } + + @Test + void ipv6NonRoutable() { + InetAddressFilter filter = InetAddressFilter.internalAddresses(); + assertThat(filter).doesNotMatch("0000:0000:0000:0000:0000:0000:0000:0000"); + assertThat(filter).doesNotMatch("::"); + } + + } + + @Nested + class Routable { + + @Test + void nonRoutable() { + InetAddressFilter filter = InetAddressFilter.routable(); + assertThat(filter).doesNotMatch("0.0.0.0"); + assertThat(filter).doesNotMatch("0000:0000:0000:0000:0000:0000:0000:0000"); + assertThat(filter).doesNotMatch("::"); + + } + + @Test + void routable() { + InetAddressFilter filter = InetAddressFilter.routable(); + assertThat(filter).matches("0.0.0.1"); + assertThat(filter).matches("0000:0000:0000:0000:0000:0000:0000:0001"); + } + + } + + @Nested + class NotTests { + + @Test + void stringsWhenEmpty() { + InetAddressFilter filter = InetAddressFilter.not(new String[] {}); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).matches("8.8.8.8"); + } + + @Test + void stringsWhenSingle() { + InetAddressFilter filter = InetAddressFilter.not("192.168.1.1"); + assertThat(filter).doesNotMatch("192.168.1.1"); + assertThat(filter).matches("192.168.1.2"); + } + + @Test + void stringsWhenMultiple() { + InetAddressFilter filter = InetAddressFilter.not("192.168.1.1", "10.0.0.1"); + assertThat(filter).doesNotMatch("192.168.1.1"); + assertThat(filter).doesNotMatch("10.0.0.1"); + assertThat(filter).matches("8.8.8.8"); + } + + @Test + void stringsWhenCidr() { + InetAddressFilter filter = InetAddressFilter.not("192.168.1.0/24"); + assertThat(filter).matches("192.168.2.1"); + assertThat(filter).doesNotMatch("192.168.1.1"); + assertThat(filter).doesNotMatch("192.168.1.255"); + } + + @Test + void filters() { + InetAddressFilter filter = InetAddressFilter.not(InetAddressFilter.of("192.168.1.1")); + assertThat(filter).doesNotMatch("192.168.1.1"); + assertThat(filter).matches("192.168.1.2"); + } + + @Test + void collection() { + InetAddressFilter filter = InetAddressFilter.not(List.of(InetAddressFilter.of("192.168.1.1"))); + assertThat(filter).doesNotMatch("192.168.1.1"); + assertThat(filter).matches("192.168.1.2"); + } + + } + + @Nested + class OfTests { + + @Test + void stringsWhenEmpty() { + InetAddressFilter filter = InetAddressFilter.of(new String[] {}); + assertThat(filter).doesNotMatch("192.168.1.1"); + assertThat(filter).doesNotMatch("8.8.8.8"); + } + + @Test + void stringsWhenSingle() { + InetAddressFilter filter = InetAddressFilter.of("192.168.1.1"); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).doesNotMatch("192.168.1.2"); + } + + @Test + void stringsWhenMultiple() { + InetAddressFilter filter = InetAddressFilter.of("192.168.1.1", "10.0.0.1"); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).matches("10.0.0.1"); + assertThat(filter).doesNotMatch("8.8.8.8"); + } + + @Test + void stringsWhenCidr() { + InetAddressFilter filter = InetAddressFilter.of("192.168.1.0/24"); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).matches("192.168.1.255"); + assertThat(filter).doesNotMatch("192.168.2.1"); + } + + @Test + void filter() { + InetAddressFilter originalfilter = (address) -> address.getHostAddress().startsWith("10."); + InetAddressFilter filter = InetAddressFilter.of(originalfilter); + assertThat(filter).matches("10.0.0.1"); + assertThat(filter).doesNotMatch("192.168.1.1"); + } + + @Test + void collection() { + InetAddressFilter originalfilter = (address) -> address.getHostAddress().startsWith("10."); + InetAddressFilter filter = InetAddressFilter.of(List.of(originalfilter)); + assertThat(filter).matches("10.0.0.1"); + assertThat(filter).doesNotMatch("192.168.1.1"); + } + + } + + @Nested + class CompositeTests { + + @Test + void ofAndNot() { + InetAddressFilter filter = InetAddressFilter.of("192.168.1.0/24").andNot("192.168.1.100"); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).doesNotMatch("192.168.1.100"); + assertThat(filter).doesNotMatch("192.168.2.1"); + } + + @Test + void ofOr() { + InetAddressFilter filter = InetAddressFilter.of("192.168.1.100").or("192.168.1.101"); + assertThat(filter).matches("192.168.1.100"); + assertThat(filter).matches("192.168.1.101"); + assertThat(filter).doesNotMatch("192.168.1.102"); + } + + @Test + void ofAnd() { + InetAddressFilter filter = InetAddressFilter.of("192.168.1.0/24") + .and((address) -> address.getHostAddress().endsWith(".1")); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).doesNotMatch("192.168.1.2"); + } + + @Test + void ofInternalAddressOrAndNot() { + InetAddressFilter filter = InetAddressFilter.internalAddresses() + .or("8.8.8.8", "8.8.4.4") + .andNot("192.168.2.0/24"); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).matches("8.8.8.8"); + assertThat(filter).matches("8.8.4.4"); + assertThat(filter).doesNotMatch("192.168.2.1"); + } + + } + + @Nested + class AllTests { + + @Test + void all() { + InetAddressFilter filter = InetAddressFilter.all(); + assertThat(filter).matches("192.168.1.1"); + assertThat(filter).matches("8.8.8.8"); + } + + @Test + @SuppressWarnings("NullAway") // Test null check + void allWhenNull() { + InetAddressFilter filter = InetAddressFilter.all(); + assertThatIllegalArgumentException().isThrownBy(() -> filter.matches((InetAddress) null)) + .withMessage("'address' must not be null"); + } + + } + + @Nested + class NoneTests { + + @Test + void none() { + InetAddressFilter filter = InetAddressFilter.none(); + assertThat(filter).doesNotMatch("192.168.1.1"); + assertThat(filter).doesNotMatch("8.8.8.8"); + } + + @Test + @SuppressWarnings("NullAway") // Test null check + void noneWhenNull() { + InetAddressFilter filter = InetAddressFilter.none(); + assertThatIllegalArgumentException().isThrownBy(() -> filter.matches((InetAddress) null)) + .withMessage("'address' must not be null"); + } + + } + + @Nested + class AdaptTests { + + @Test + void adaptsSpringSecurity() { + InetAddressMatcher securityMatcher = InetAddressMatchers.matchInternal().build(); + InetAddressFilter filter = InetAddressFilter.adapt(securityMatcher::matches); + assertThat(filter).matches("127.0.0.1"); + assertThat(filter).doesNotMatch("8.8.8.8"); + assertThatFilterDoesNotMatchNull(filter); + } + + @SuppressWarnings("NullAway") // Test null check + private void assertThatFilterDoesNotMatchNull(InetAddressFilter filter) { + assertThat(filter).doesNotMatch((InetAddress) null); + } + + } + +} diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/IpAddressTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/IpAddressTests.java new file mode 100644 index 00000000000..06681e11190 --- /dev/null +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/IpAddressTests.java @@ -0,0 +1,197 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.InetAddress; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link IpAddress}. + * + * @author Rob Winch + * @author Phillip Webb + */ +class IpAddressTests { + + @Test + @SuppressWarnings("NullAway") // Test null check + void ofWhenAddressIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> IpAddress.of(null)) + .withMessage("'address' must not be empty"); + } + + @Test + void ofWhenAddressIsEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> IpAddress.of("")) + .withMessage("'address' must not be empty"); + } + + @Test + void ofWithMaskWhenAddressIsEmptyThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> IpAddress.of("192.168.1.1/")) + .withMessage("'address' subnet mask must be a number"); + } + + @Test + void ofWhenUnmaskedIpAddress() throws Exception { + IpAddress address = IpAddress.of("192.168.1.1"); + assertThat(address).extracting("address").isEqualTo(InetAddress.getByName("192.168.1.1")); + assertThat(address).extracting("maskBitSize").isEqualTo(-1); + } + + @Test + void ofWhenMaskedIpAddress() throws Exception { + IpAddress address = IpAddress.of("192.168.1.1/24"); + assertThat(address).extracting("address").isEqualTo(InetAddress.getByName("192.168.1.1")); + assertThat(address).extracting("maskBitSize").isEqualTo(24); + } + + @Test + void parseInetAddressWhenIpv4() throws Exception { + InetAddress parsed = IpAddress.parseInetAddress("192.168.1.1"); + assertThat(parsed).isEqualTo(InetAddress.getByName("192.168.1.1")); + } + + @Test + void parseInetAddressWhenIpv6InUrl() { + InetAddress parsed = IpAddress.parseInetAddress("[::1]"); + assertThat(parsed.isLoopbackAddress()).isTrue(); + } + + @Test + void parseInetAddressWhenIpv6Shortcut() { + InetAddress parsed = IpAddress.parseInetAddress("::1"); + assertThat(parsed.isLoopbackAddress()).isTrue(); + } + + @Test + void parseInetAddressWhenLikelyHost() { + String message = "must be an IP address and not a host name"; + assertThatIllegalArgumentException().isThrownBy(() -> IpAddress.parseInetAddress("https://example.com")) + .withMessageContaining(message); + assertThatIllegalArgumentException().isThrownBy(() -> IpAddress.parseInetAddress("192.168.1.2.3")) + .withMessageContaining(message); + assertThatIllegalArgumentException() + .isThrownBy(() -> IpAddress.parseInetAddress("G001:0db8:0000:0000:0000:0000:0000:0000")) + .withMessageContaining(message); + } + + @Test + void parseInetAddressWhenCannotBeParsed() { + assertThatIllegalArgumentException() + .isThrownBy(() -> IpAddress.parseInetAddress("2001:0db8:0000:0000:0000:0000:0000:000G")) + .withMessageContaining("must be parsable to an InetAddress"); + } + + @Test + void ofWithHostnameThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> IpAddress.of("example.com")) + .withMessage("'address' [example.com] must be an IP address and not a host name"); + } + + @Test + void matcherWhenUnmaskedIpv4() { + IpAddress address = IpAddress.of("192.168.1.1"); + assertThatFilter(address).matches("192.168.1.1"); + assertThatFilter(address).doesNotMatch("192.168.1.2"); + } + + @Test + void matcherWhenUnmaskedIpv6() { + IpAddress address = IpAddress.of("fe80::21f:5bff:fe33:bd68"); + assertThatFilter(address).matches("fe80::21f:5bff:fe33:bd68"); + assertThatFilter(address).doesNotMatch("fe80::21f:5bff:fe33:bd69"); + } + + @Test + void matcherWhenMaskedIpv4() { + IpAddress address = IpAddress.of("192.168.1.0/24"); + assertThatFilter(address).doesNotMatch("192.168.2.1"); + assertThatFilter(address).doesNotMatch("192.168.0.255"); + } + + @Test + void matcherWhenMaskedWithZero() { + IpAddress address = IpAddress.of("192.168.1.0/0"); + assertThatFilter(address).matches("192.168.1.1"); + assertThatFilter(address).matches("192.168.1.255"); + assertThatFilter(address).matches("8.8.8.8"); + } + + @Test + void matcherWhenMaskedIpv6() { + IpAddress address = IpAddress.of("2001:db8::/48"); + assertThatFilter(address).matches("2001:db8:0:0:0:0:0:0"); + assertThatFilter(address).matches("2001:db8:0:ffff:ffff:ffff:ffff:ffff"); + assertThatFilter(address).doesNotMatch("2001:db8:1:0:0:0:0:0"); + } + + @Test + void matcherWhenMaskedIpv4OutsideOfByteBoundary() { + IpAddress address = IpAddress.of("192.168.1.0/30"); + assertThatFilter(address).matches("192.168.1.0"); + assertThatFilter(address).matches("192.168.1.1"); + assertThatFilter(address).matches("192.168.1.2"); + assertThatFilter(address).matches("192.168.1.3"); + assertThatFilter(address).doesNotMatch("192.168.1.4"); + } + + @Test + void matcherWhenIpv4DoesNotMatchIpv6() { + assertThatFilter(IpAddress.of("192.168.1.1")).doesNotMatch("fe80::21f:5bff:fe33:bd68"); + assertThatFilter(IpAddress.of("8.8.8.8")).doesNotMatch("0808:0808::"); + } + + @Test + void matcherWhenIpv6DoesNotMatchIpv4() { + assertThatFilter(IpAddress.of("fe80::21f:5bff:fe33:bd68")).doesNotMatch("192.168.1.1"); + assertThatFilter(IpAddress.of("0808:0808::/32")).doesNotMatch("8.8.8.8"); + } + + @Test + @SuppressWarnings("NullAway") // Test null check + void matcherWhenCheckingNullThrowsException() { + IpAddress address = IpAddress.of("192.168.1.1"); + assertThatIllegalArgumentException().isThrownBy(() -> address.filter().matches((InetAddress) null)) + .withMessage("'address' must not be null"); + } + + @Test + void addressesInIpRangeMatch() { + for (int i = 0; i < 255; i++) { + assertThatFilter(IpAddress.of("192.168.1.0/24")).matches("192.168.1." + i); + } + assertThatFilter(IpAddress.of("192.168.1.0/25")).matches("192.168.1.127").doesNotMatch("192.168.1.128"); + assertThatFilter(IpAddress.of("192.168.1.128/25")).matches("192.168.1.255"); + assertThatFilter(IpAddress.of("192.168.1.192/26")).matches("192.168.1.255"); + assertThatFilter(IpAddress.of("192.168.1.224/27")).matches("192.168.1.255"); + assertThatFilter(IpAddress.of("192.168.1.240/27")).matches("192.168.1.255"); + assertThatFilter(IpAddress.of("192.168.1.255/32")).matches("192.168.1.255"); + assertThatFilter(IpAddress.of("202.24.0.0/14")).matches("202.24.199.127"); + assertThatFilter(IpAddress.of("202.24.0.0/14")).matches("202.25.179.135"); + assertThatFilter(IpAddress.of("202.24.0.0/14")).matches("202.26.179.135"); + } + + private static InetAddressFilterAssert assertThatFilter(IpAddress address) { + return new InetAddressFilterAssert(address.filter()); + } + +} diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilderTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilderTests.java index 2f240492cc2..3a183f8e76a 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilderTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilderTests.java @@ -16,6 +16,7 @@ package org.springframework.boot.http.client; +import java.net.ProxySelector; import java.net.http.HttpClient; import java.time.Duration; import java.util.concurrent.Executor; @@ -29,6 +30,7 @@ import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Tests for {@link JdkClientHttpRequestFactoryBuilder} and {@link JdkHttpClientBuilder}. @@ -70,6 +72,30 @@ class JdkClientHttpRequestFactoryBuilderTests customizer.assertCalled(); } + @Test + void withProxySelector() { + ProxySelector proxySelector = mock(); + JdkClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.jdk() + .withProxySelector(proxySelector) + .build(); + HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(factory, "httpClient"); + assertThat(httpClient).isNotNull(); + assertThat(httpClient.proxy()).contains(proxySelector); + } + + @Test + void withProxySelectorWhenHasInetAddressMatcher() { + ProxySelector proxySelector = mock(); + JdkClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.jdk() + .withProxySelector(proxySelector) + .build(HttpClientSettings.defaults().withInetAddressFilter(InetAddressFilter.externalAddresses())); + HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(factory, "httpClient"); + assertThat(httpClient).isNotNull(); + ProxySelector actual = httpClient.proxy().get(); + assertThat(actual).matches((proxy) -> proxy.getClass().getName().contains("JdkFiltered")); + assertThat(actual).extracting("delegate").isEqualTo(proxySelector); + } + @Test void defaultCookieHandling() { JdkClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.jdk() diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JdkFilteredProxySelectorTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JdkFilteredProxySelectorTests.java new file mode 100644 index 00000000000..c177c8de68d --- /dev/null +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JdkFilteredProxySelectorTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.io.IOException; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link JdkFilteredProxySelector}. + * + * @author Phillip Webb + */ +class JdkFilteredProxySelectorTests { + + @Test + void selectWhenMatchesResolvedHost() throws Exception { + URI localhost = new URI("http://localhost"); + ProxySelector delegate = mock(ProxySelector.class); + Proxy proxy = mock(); + given(delegate.select(localhost)).willReturn(List.of(proxy)); + JdkFilteredProxySelector proxySelector = new JdkFilteredProxySelector(delegate, + InetAddressFilter.internalAddresses()); + assertThat(proxySelector.select(localhost)).containsExactly(proxy); + } + + @Test + void selectWhenDoesNotMatchResolvedHost() throws Exception { + URI localhost = new URI("http://localhost"); + ProxySelector delegate = mock(ProxySelector.class); + Proxy proxy = mock(); + given(delegate.select(localhost)).willReturn(List.of(proxy)); + JdkFilteredProxySelector proxySelector = new JdkFilteredProxySelector(delegate, + InetAddressFilter.externalAddresses()); + assertThatExceptionOfType(FilteredHostException.class).isThrownBy(() -> proxySelector.select(localhost)); + } + + @Test + void connectFailDelegates() throws Exception { + URI localhost = new URI("http://localhost"); + ProxySelector delegate = mock(ProxySelector.class); + JdkFilteredProxySelector proxySelector = new JdkFilteredProxySelector(delegate, + InetAddressFilter.externalAddresses()); + SocketAddress address = mock(); + IOException ex = new IOException(); + proxySelector.connectFailed(localhost, address, ex); + then(delegate).should().connectFailed(localhost, address, ex); + } + +} diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilderTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilderTests.java index 66b8636b551..f7ad4c2c7a5 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilderTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilderTests.java @@ -21,6 +21,7 @@ import org.eclipse.jetty.client.HttpClientTransport; import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP; import org.eclipse.jetty.http.HttpCookieStore; import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.SocketAddressResolver; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; @@ -29,6 +30,7 @@ import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Tests for {@link JettyClientHttpRequestFactoryBuilder} and @@ -78,6 +80,26 @@ class JettyClientHttpRequestFactoryBuilderTests .isInstanceOf(TestHttpClientTransport.class); } + @Test + void withSocketAddressResolver() { + SocketAddressResolver socketAddressResolver = mock(); + JettyClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.jetty() + .withSocketAddressResolver(socketAddressResolver) + .build(); + assertThat(factory).extracting("httpClient.resolver").isSameAs(socketAddressResolver); + } + + @Test + void withSocketAddressResolverWhenHasInetAddressMatcher() { + SocketAddressResolver socketAddressResolver = mock(); + JettyClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.jetty() + .withSocketAddressResolver(socketAddressResolver) + .build(HttpClientSettings.defaults().withInetAddressFilter(InetAddressFilter.externalAddresses())); + assertThat(factory).extracting("httpClient.resolver") + .matches((resolver) -> resolver.getClass().getName().contains("JettyFiltered")); + assertThat(factory).extracting("httpClient.resolver.delegate").isSameAs(socketAddressResolver); + } + @Test void defaultCookieHandling() { JettyClientHttpRequestFactory factory = ClientHttpRequestFactoryBuilder.jetty() diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JettyFilteredSocketAddressResolverTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JettyFilteredSocketAddressResolverTests.java new file mode 100644 index 00000000000..473df5c38c1 --- /dev/null +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/JettyFilteredSocketAddressResolverTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.List; + +import org.eclipse.jetty.util.Promise; +import org.eclipse.jetty.util.SocketAddressResolver; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link JettyFilteredSocketAddressResolver}. + * + * @author Phillip Webb + */ +class JettyFilteredSocketAddressResolverTests { + + @Test + void resolveWhenMatchedOnlyElement() { + InetSocketAddress localhost = inetSocketAddress("localhost", 8080); + SocketAddressResolver delegate = (host, port, context, promise) -> promise.succeeded(List.of(localhost)); + JettyFilteredSocketAddressResolver resolver = new JettyFilteredSocketAddressResolver(delegate, + InetAddressFilter.internalAddresses()); + Promise> promise = mock(); + resolver.resolve("localhost", 8080, Collections.emptyMap(), promise); + then(promise).should().succeeded(List.of(localhost)); + } + + @Test + void resolveWhenMatchedOneOfManyElements() { + InetSocketAddress localhost = inetSocketAddress("localhost", 8080); + InetSocketAddress remote = inetSocketAddress("8.8.8.8", 8080); + SocketAddressResolver delegate = (host, port, context, promise) -> promise + .succeeded(List.of(localhost, remote)); + JettyFilteredSocketAddressResolver resolver = new JettyFilteredSocketAddressResolver(delegate, + InetAddressFilter.internalAddresses()); + Promise> promise = mock(); + resolver.resolve("localhost", 8080, Collections.emptyMap(), promise); + then(promise).should().succeeded(List.of(localhost)); + } + + @Test + void resolveWhenMatchedNoElements() { + InetSocketAddress localhost = inetSocketAddress("localhost", 8080); + InetSocketAddress remote = inetSocketAddress("8.8.8.8", 8080); + SocketAddressResolver delegate = (host, port, context, promise) -> promise + .succeeded(List.of(localhost, remote)); + JettyFilteredSocketAddressResolver resolver = new JettyFilteredSocketAddressResolver(delegate, + InetAddressFilter.externalAddresses().andNot("8.8.8.8")); + Promise> promise = mock(); + resolver.resolve("localhost", 8080, Collections.emptyMap(), promise); + ArgumentCaptor failure = ArgumentCaptor.captor(); + then(promise).should().failed(failure.capture()); + assertThat(failure.getValue()).isInstanceOf(FilteredHostException.class); + } + + @Test + void resolveWhenDelegateFails() { + Throwable ex = new RuntimeException(); + SocketAddressResolver delegate = (host, port, context, promise) -> promise.failed(ex); + JettyFilteredSocketAddressResolver resolver = new JettyFilteredSocketAddressResolver(delegate, + InetAddressFilter.externalAddresses()); + Promise> promise = mock(); + resolver.resolve("localhost", 8080, Collections.emptyMap(), promise); + then(promise).should().failed(ex); + } + + private InetSocketAddress inetSocketAddress(String host, int port) { + try { + return new InetSocketAddress(InetAddress.getByName(host), port); + } + catch (UnknownHostException ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/ReactorFilteredResolvedAddressSelectorTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/ReactorFilteredResolvedAddressSelectorTests.java new file mode 100644 index 00000000000..514b63d1179 --- /dev/null +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/ReactorFilteredResolvedAddressSelectorTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; +import java.util.List; + +import org.junit.jupiter.api.Test; +import reactor.netty.http.client.HttpClientConfig; +import reactor.netty.transport.ClientTransport.ResolvedAddressSelector; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ReactorFilteredResolvedAddressSelector}. + * + * @author Phillip Webb + */ +class ReactorFilteredResolvedAddressSelectorTests { + + @Test + void applyWhenMatchedOnlyElement() { + InetSocketAddress localhost = inetSocketAddress("localhost", 8080); + ResolvedAddressSelector delegate = (config, resolvedAddresses) -> resolvedAddresses; + HttpClientConfig config = mock(); + ReactorFilteredResolvedAddressSelector addressSelector = new ReactorFilteredResolvedAddressSelector<>( + delegate, InetAddressFilter.internalAddresses()); + assertThat(addressSelector.apply(config, List.of(localhost))).isEqualTo(List.of(localhost)); + } + + @Test + void applyWhenMatchedOneOfManyElements() { + InetSocketAddress localhost = inetSocketAddress("localhost", 8080); + InetSocketAddress remote = inetSocketAddress("8.8.8.8", 8080); + ResolvedAddressSelector delegate = (config, resolvedAddresses) -> resolvedAddresses; + HttpClientConfig config = mock(); + ReactorFilteredResolvedAddressSelector addressSelector = new ReactorFilteredResolvedAddressSelector<>( + delegate, InetAddressFilter.internalAddresses()); + assertThat(addressSelector.apply(config, List.of(localhost, remote))).isEqualTo(List.of(localhost)); + } + + @Test + void applyWhenMatchedNoElements() { + InetSocketAddress localhost = inetSocketAddress("localhost", 8080); + InetSocketAddress remote = inetSocketAddress("8.8.8.8", 8080); + ResolvedAddressSelector delegate = (config, resolvedAddresses) -> resolvedAddresses; + HttpClientConfig config = mock(); + ReactorFilteredResolvedAddressSelector addressSelector = new ReactorFilteredResolvedAddressSelector<>( + delegate, InetAddressFilter.externalAddresses().andNot("8.8.8.8")); + assertThatExceptionOfType(FilteredHostException.class) + .isThrownBy(() -> addressSelector.apply(config, List.of(localhost, remote))) + .withMessage("Filtered host '[127.0.0.1, 8.8.8.8]'"); + + } + + private InetSocketAddress inetSocketAddress(String host, int port) { + try { + return new InetSocketAddress(InetAddress.getByName(host), port); + } + catch (UnknownHostException ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/ReflectiveComponentsClientHttpRequestFactoryBuilderTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/ReflectiveComponentsClientHttpRequestFactoryBuilderTests.java index f328d0af04d..a18cfa63919 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/ReflectiveComponentsClientHttpRequestFactoryBuilderTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/ReflectiveComponentsClientHttpRequestFactoryBuilderTests.java @@ -46,6 +46,15 @@ class ReflectiveComponentsClientHttpRequestFactoryBuilderTests super(ClientHttpRequestFactory.class, ClientHttpRequestFactoryBuilder.of(JettyClientHttpRequestFactory::new)); } + @Test + @Override + void filteredInetAddress() throws Exception { + assertThatIllegalStateException() + .isThrownBy(() -> getBuilder() + .build(HttpClientSettings.defaults().withInetAddressFilter(InetAddressFilter.externalAddresses()))) + .withMessage("Unable to set InetAddress filter using reflection"); + } + @Override void connectWithSslBundle(String httpMethod) throws Exception { HttpClientSettings settings = HttpClientSettings.ofSslBundle(sslBundle()); diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/SimpleClientHttpRequestFactoryBuilderTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/SimpleClientHttpRequestFactoryBuilderTests.java index f23d1f91fcc..79c5b1e03ab 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/SimpleClientHttpRequestFactoryBuilderTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/SimpleClientHttpRequestFactoryBuilderTests.java @@ -26,7 +26,6 @@ import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** @@ -83,9 +82,18 @@ class SimpleClientHttpRequestFactoryBuilderTests super.redirectDontFollow(httpMethod); } + @Test + @Override + void filteredInetAddress() throws Exception { + assertThatIllegalStateException() + .isThrownBy(() -> getBuilder() + .build(HttpClientSettings.defaults().withInetAddressFilter(InetAddressFilter.externalAddresses()))) + .withMessage("Simple HTTP request factory does not support InetAddress filtering"); + } + @Test void throwsWhenCookieHandlingEnabled() { - assertThatIllegalArgumentException().isThrownBy(() -> ClientHttpRequestFactoryBuilder.simple() + assertThatIllegalStateException().isThrownBy(() -> ClientHttpRequestFactoryBuilder.simple() .build(HttpClientSettings.defaults().withCookieHandling(HttpCookieHandling.ENABLE))); } diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfigurationTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfigurationTests.java index 9bc5844f677..9f003368896 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfigurationTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/autoconfigure/HttpClientAutoConfigurationTests.java @@ -24,11 +24,13 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.http.client.HttpClientSettings; import org.springframework.boot.http.client.HttpRedirects; +import org.springframework.boot.http.client.InetAddressFilter; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Tests for {@link HttpClientAutoConfiguration}. @@ -62,6 +64,14 @@ class HttpClientAutoConfigurationTests { .isEqualTo(new HttpClientSettings(null, null, Duration.ofSeconds(1), Duration.ofSeconds(2), null))); } + @Test + void injectsInetAddressMatcher() { + InetAddressFilter matcher = mock(); + this.contextRunner.withBean(InetAddressFilter.class, () -> matcher) + .run((context) -> assertThat(context.getBean(HttpClientSettings.class).inetAddressFilter()) + .isEqualTo(matcher)); + } + @Configuration(proxyBeanMethods = false) static class TestHttpClientConfiguration { diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/AbstractClientHttpConnectorBuilderTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/AbstractClientHttpConnectorBuilderTests.java index 0ebdfb443e1..f0c4097a289 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/AbstractClientHttpConnectorBuilderTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/AbstractClientHttpConnectorBuilderTests.java @@ -34,8 +34,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.boot.http.client.FilteredHostException; import org.springframework.boot.http.client.HttpClientSettings; import org.springframework.boot.http.client.HttpRedirects; +import org.springframework.boot.http.client.InetAddressFilter; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundleKey; import org.springframework.boot.ssl.SslOptions; @@ -55,6 +57,7 @@ import org.springframework.web.reactive.function.client.ExchangeFunctions; import org.springframework.web.reactive.function.client.WebClientRequestException; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatException; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; /** @@ -192,6 +195,27 @@ abstract class AbstractClientHttpConnectorBuilderTests context.addServlet("test", TestServlet.class).addMapping("/")); + try { + webServer.start(); + int port = webServer.getPort(); + URI uri = new URI("http://localhost:%s".formatted(port) + "/redirect"); + ClientHttpConnector connector = this.builder + .build(HttpClientSettings.defaults().withInetAddressFilter(InetAddressFilter.externalAddresses())); + ClientRequest request = createRequest("GET", uri); + assertThatException().isThrownBy(() -> getResponse(connector, request)) + .matches((ex) -> ex instanceof FilteredHostException || ex.getCause() instanceof FilteredHostException); + + } + finally { + webServer.stop(); + } + } + private ClientRequest createRequest(String httpMethod, URI uri) { return createRequest(HttpMethod.valueOf(httpMethod), uri); } diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilderTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilderTests.java index 0b5a7857478..6797eb8a5df 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilderTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilderTests.java @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.List; import java.util.function.Function; +import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.HttpRoute; import org.apache.hc.client5.http.async.HttpAsyncClient; import org.apache.hc.client5.http.config.ConnectionConfig; @@ -33,12 +34,14 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.http.client.HttpClientSettings; import org.springframework.boot.http.client.HttpComponentsHttpAsyncClientBuilder; +import org.springframework.boot.http.client.InetAddressFilter; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.testsupport.classpath.resources.WithPackageResources; import org.springframework.http.client.reactive.HttpComponentsClientHttpConnector; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Tests for {@link HttpComponentsClientHttpConnectorBuilder} and @@ -104,6 +107,28 @@ class HttpComponentsClientHttpConnectorBuilderTests customizer.assertCalled(); } + @Test + void withDnsResolver() { + DnsResolver dnsResolver = mock(); + HttpComponentsClientHttpConnector connector = ClientHttpConnectorBuilder.httpComponents() + .withDnsResolver(dnsResolver) + .build(); + assertThat(connector).extracting("client.manager.connectionOperator.sessionRequester.dnsResolver") + .isSameAs(dnsResolver); + } + + @Test + void withDnsResolverWhenHasInetAddressMatcher() { + DnsResolver dnsResolver = mock(); + HttpComponentsClientHttpConnector connector = ClientHttpConnectorBuilder.httpComponents() + .withDnsResolver(dnsResolver) + .build(HttpClientSettings.defaults().withInetAddressFilter(InetAddressFilter.externalAddresses())); + assertThat(connector).extracting("client.manager.connectionOperator.sessionRequester.dnsResolver") + .matches((resolver) -> resolver.getClass().getName().contains("HttpComponentsFiltered")); + assertThat(connector).extracting("client.manager.connectionOperator.sessionRequester.dnsResolver.delegate") + .isSameAs(dnsResolver); + } + @Override protected long connectTimeout(HttpComponentsClientHttpConnector connector) { return getConnectorConfig(connector).getConnectTimeout().toMilliseconds(); diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilderTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilderTests.java index 4af086153a5..54681bb376d 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilderTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilderTests.java @@ -16,18 +16,22 @@ package org.springframework.boot.http.client.reactive; +import java.net.ProxySelector; import java.net.http.HttpClient; import java.time.Duration; import java.util.concurrent.Executor; import org.junit.jupiter.api.Test; +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.http.client.InetAddressFilter; import org.springframework.boot.http.client.JdkHttpClientBuilder; import org.springframework.core.task.SimpleAsyncTaskExecutor; import org.springframework.http.client.reactive.JdkClientHttpConnector; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Tests for {@link JdkClientHttpConnectorBuilder} and {@link JdkHttpClientBuilder}. @@ -68,6 +72,29 @@ class JdkClientHttpConnectorBuilderTests extends AbstractClientHttpConnectorBuil customizer.assertCalled(); } + @Test + void withProxySelector() { + ProxySelector proxySelector = mock(); + JdkClientHttpConnector connector = ClientHttpConnectorBuilder.jdk().withProxySelector(proxySelector).build(); + HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient"); + assertThat(httpClient).isNotNull(); + assertThat(httpClient.proxy()).contains(proxySelector); + } + + @Test + void withProxySelectorWhenHasInetAddressMatcher() { + ProxySelector proxySelector = mock(); + JdkClientHttpConnector connector = ClientHttpConnectorBuilder.jdk() + .withProxySelector(proxySelector) + .build(HttpClientSettings.defaults().withInetAddressFilter(InetAddressFilter.externalAddresses())); + HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient"); + assertThat(httpClient).isNotNull(); + assertThat(httpClient.proxy()).isNotNull(); + ProxySelector actual = httpClient.proxy().get(); + assertThat(actual).matches((proxy) -> proxy.getClass().getName().contains("JdkFiltered")); + assertThat(actual).extracting("delegate").isEqualTo(proxySelector); + } + @Override protected long connectTimeout(JdkClientHttpConnector connector) { HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient"); diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilderTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilderTests.java index ef4ae1e6709..892cb3e98c3 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilderTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilderTests.java @@ -16,19 +16,21 @@ package org.springframework.boot.http.client.reactive; -import java.time.Duration; - import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.HttpClientTransport; import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP; import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.SocketAddressResolver; import org.junit.jupiter.api.Test; +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.http.client.InetAddressFilter; import org.springframework.boot.http.client.JettyHttpClientBuilder; import org.springframework.http.client.reactive.JettyClientHttpConnector; import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; /** * Tests for {@link JettyClientHttpConnectorBuilder} and {@link JettyHttpClientBuilder}. @@ -76,6 +78,26 @@ class JettyClientHttpConnectorBuilderTests extends AbstractClientHttpConnectorBu .isInstanceOf(TestHttpClientTransport.class); } + @Test + void withSocketAddressResolver() { + SocketAddressResolver socketAddressResolver = mock(); + JettyClientHttpConnector connector = ClientHttpConnectorBuilder.jetty() + .withSocketAddressResolver(socketAddressResolver) + .build(); + assertThat(connector).extracting("httpClient.resolver").isSameAs(socketAddressResolver); + } + + @Test + void withSocketAddressResolverWhenHasInetAddressMatcher() { + SocketAddressResolver socketAddressResolver = mock(); + JettyClientHttpConnector connector = ClientHttpConnectorBuilder.jetty() + .withSocketAddressResolver(socketAddressResolver) + .build(HttpClientSettings.defaults().withInetAddressFilter(InetAddressFilter.externalAddresses())); + assertThat(connector).extracting("httpClient.resolver") + .matches((resolver) -> resolver.getClass().getName().contains("JettyFiltered")); + assertThat(connector).extracting("httpClient.resolver.delegate").isSameAs(socketAddressResolver); + } + @Override protected long connectTimeout(JettyClientHttpConnector connector) { HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient"); @@ -87,9 +109,10 @@ class JettyClientHttpConnectorBuilderTests extends AbstractClientHttpConnectorBu protected long readTimeout(JettyClientHttpConnector connector) { HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient"); assertThat(httpClient).isNotNull(); - Object field = ReflectionTestUtils.getField(httpClient, "readTimeout"); - assertThat(field).isNotNull(); - return ((Duration) field).toMillis(); + HttpClientSettings settings = (HttpClientSettings) ReflectionTestUtils.getField(httpClient, "settings"); + assertThat(settings).isNotNull(); + assertThat(settings.readTimeout()).isNotNull(); + return settings.readTimeout().toMillis(); } static class TestHttpClientTransport extends HttpClientTransportOverHTTP { diff --git a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilderTests.java b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilderTests.java index f35af8af49a..e48bb4c3ecb 100644 --- a/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilderTests.java +++ b/module/spring-boot-http-client/src/test/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilderTests.java @@ -25,7 +25,11 @@ import java.util.function.UnaryOperator; import io.netty.channel.ChannelOption; import org.junit.jupiter.api.Test; import reactor.netty.http.client.HttpClient; +import reactor.netty.http.client.HttpClientConfig; +import reactor.netty.transport.ClientTransport.ResolvedAddressSelector; +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.http.client.InetAddressFilter; import org.springframework.boot.http.client.ReactorHttpClientBuilder; import org.springframework.http.client.ReactorResourceFactory; import org.springframework.http.client.reactive.ReactorClientHttpConnector; @@ -33,6 +37,7 @@ import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; /** @@ -98,6 +103,28 @@ class ReactorClientHttpConnectorBuilderTests assertThat(called).containsExactly(true); } + @Test + void withResolvedAddressSelector() { + ResolvedAddressSelector resolvedAddressSelector = mock(); + ReactorClientHttpConnector connector = ClientHttpConnectorBuilder.reactor() + .withResolvedAddressSelector(resolvedAddressSelector) + .build(); + assertThat(connector).extracting("httpClient.config.resolvedAddressesSelector") + .isEqualTo(resolvedAddressSelector); + } + + @Test + void withResolvedAddressSelectorWhenHasInetAddressFilter() { + ResolvedAddressSelector resolvedAddressSelector = mock(); + ReactorClientHttpConnector connector = ClientHttpConnectorBuilder.reactor() + .withResolvedAddressSelector(resolvedAddressSelector) + .build(HttpClientSettings.defaults().withInetAddressFilter(InetAddressFilter.externalAddresses())); + assertThat(connector).extracting("httpClient.config.resolvedAddressesSelector") + .matches((selector) -> selector.getClass().getName().contains("ReactorFiltered")); + assertThat(connector).extracting("httpClient.config.resolvedAddressesSelector.delegate") + .isEqualTo(resolvedAddressSelector); + } + @Override protected long connectTimeout(ReactorClientHttpConnector connector) { HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient");