-
Notifications
You must be signed in to change notification settings - Fork 3.1k
Core: Expose HostnameVerificationPolicy in TLSConfigurer #15500
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7502238
6e3cfb8
19fc736
2b92d46
e8b1810
c686919
b5bb63a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,9 +19,9 @@ | |
| package org.apache.iceberg.rest.auth; | ||
|
|
||
| import java.util.Map; | ||
| import javax.annotation.Nullable; | ||
| import javax.net.ssl.HostnameVerifier; | ||
| import javax.net.ssl.SSLContext; | ||
| import org.apache.hc.client5.http.ssl.HttpsSupport; | ||
| import org.apache.hc.core5.ssl.SSLContexts; | ||
|
|
||
| public interface TLSConfigurer { | ||
|
|
@@ -32,8 +32,16 @@ default SSLContext sslContext() { | |
| return SSLContexts.createDefault(); | ||
| } | ||
|
|
||
| /** | ||
| * Returns a custom {@link HostnameVerifier} to use for hostname verification, or {@code null} to | ||
| * use the default JSSE built-in hostname verifier. | ||
| * | ||
| * <p>If a non-null verifier is returned, only the custom verifier is executed and the JSSE | ||
| * built-in hostname verifier won't be executed. | ||
| */ | ||
| @Nullable | ||
| default HostnameVerifier hostnameVerifier() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @RussellSpitzer @adutra this is the method where I suggested adding Javadoc. or you think this is redundant and the intention is obvious from the API already? I also like Russell's suggestion from email of making this method returning null so that we can get rid of the new method above.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @stevenzwu @RussellSpitzer I implemented the suggested approach, PTAL. I want to stress though that returning null instead of The built-in verifier and Apache's default verifier both do roughly the same verifications, but the latter is more strict/complete. They also kick in in different phases and throw different exceptions.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Not really a behavior change btw 1.10 and 1.11 since the original change from Bryant hasn't been released it. And returning null is essentially the same as using the default built-in hostname verifier.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
From what I see, |
||
| return HttpsSupport.getDefaultHostnameVerifier(); | ||
| return null; | ||
| } | ||
|
|
||
| default String[] supportedProtocols() { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,37 +35,48 @@ | |
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import java.io.IOException; | ||
| import java.net.SocketTimeoutException; | ||
| import java.nio.file.Path; | ||
| import java.security.KeyStore; | ||
| import java.security.cert.CertificateException; | ||
| import java.util.Locale; | ||
| import java.util.Map; | ||
| import java.util.Objects; | ||
| import java.util.concurrent.TimeUnit; | ||
| import java.util.function.Consumer; | ||
| import javax.net.ssl.HostnameVerifier; | ||
| import javax.net.ssl.SSLContext; | ||
| import javax.net.ssl.TrustManagerFactory; | ||
| import org.apache.hc.client5.http.auth.AuthScope; | ||
| import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; | ||
| import org.apache.hc.client5.http.config.ConnectionConfig; | ||
| import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; | ||
| import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; | ||
| import org.apache.hc.client5.http.io.HttpClientConnectionManager; | ||
| import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; | ||
| import org.apache.hc.core5.http.HttpHost; | ||
| import org.apache.hc.core5.http.HttpStatus; | ||
| import org.apache.iceberg.IcebergBuild; | ||
| import org.apache.iceberg.catalog.TableIdentifier; | ||
| import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap; | ||
| import org.apache.iceberg.relocated.com.google.common.collect.Sets; | ||
| import org.apache.iceberg.rest.auth.AuthSession; | ||
| import org.apache.iceberg.rest.auth.TLSConfigurer; | ||
| import org.apache.iceberg.rest.responses.ErrorResponse; | ||
| import org.apache.iceberg.rest.responses.ErrorResponseParser; | ||
| import org.junit.jupiter.api.AfterAll; | ||
| import org.junit.jupiter.api.BeforeAll; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.io.TempDir; | ||
| import org.junit.jupiter.params.ParameterizedTest; | ||
| import org.junit.jupiter.params.provider.EnumSource; | ||
| import org.junit.jupiter.params.provider.ValueSource; | ||
| import org.mockserver.configuration.Configuration; | ||
| import org.mockserver.integration.ClientAndServer; | ||
| import org.mockserver.logging.MockServerLogger; | ||
| import org.mockserver.matchers.Times; | ||
| import org.mockserver.model.HttpRequest; | ||
| import org.mockserver.model.HttpResponse; | ||
| import org.mockserver.socket.tls.KeyStoreFactory; | ||
| import org.mockserver.verify.VerificationTimes; | ||
|
|
||
| /** | ||
|
|
@@ -87,6 +98,7 @@ public class TestHTTPClient { | |
| private static RESTClient restClient; | ||
|
|
||
| public static class DefaultTLSConfigurer implements TLSConfigurer { | ||
|
|
||
| public static int count = 0; | ||
|
|
||
| public DefaultTLSConfigurer() { | ||
|
|
@@ -95,6 +107,7 @@ public DefaultTLSConfigurer() { | |
| } | ||
|
|
||
| public static class TLSConfigurerMissingNoArgCtor implements TLSConfigurer { | ||
|
|
||
| TLSConfigurerMissingNoArgCtor(String str) {} | ||
| } | ||
|
|
||
|
|
@@ -395,6 +408,101 @@ public void testLoadTLSConfigurerNotImplementTLSConfigurer() { | |
| .hasMessageContaining("does not implement TLSConfigurer"); | ||
| } | ||
|
|
||
| /** A TLSConfigurer that relies on the default (built-in) JSSE verifier. */ | ||
| public static class BuiltInHostnameVerifierTLSConfigurer implements TLSConfigurer { | ||
|
|
||
| @Override | ||
| public SSLContext sslContext() { | ||
| return mockServerSSLContext(); | ||
| } | ||
| } | ||
|
|
||
| /** A TLSConfigurer that overrides hostnameVerifier() to return a custom verifier. */ | ||
| public static class CustomHostnameVerifierTLSConfigurer implements TLSConfigurer { | ||
|
|
||
| @Override | ||
| public SSLContext sslContext() { | ||
| return mockServerSSLContext(); | ||
| } | ||
|
|
||
| @Override | ||
| public HostnameVerifier hostnameVerifier() { | ||
| return NoopHostnameVerifier.INSTANCE; | ||
| } | ||
| } | ||
|
|
||
| private static SSLContext mockServerSSLContext() { | ||
| try { | ||
| KeyStore keyStore = | ||
| new KeyStoreFactory(Configuration.configuration(), new MockServerLogger()) | ||
| .loadOrCreateKeyStore(); | ||
| TrustManagerFactory tmf = | ||
| TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); | ||
| tmf.init(keyStore); | ||
| SSLContext sslContext = SSLContext.getInstance("TLSv1.2"); | ||
| sslContext.init(null, tmf.getTrustManagers(), null); | ||
| return sslContext; | ||
| } catch (Exception e) { | ||
| throw new RuntimeException("Failed to create SSLContext", e); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void testTLSConfigurerHostnameVerifier(@TempDir Path temp) throws IOException { | ||
|
|
||
| // Start a dedicated MockServer with a certificate that does NOT include | ||
| // 127.0.0.1 or localhost in its SANs. | ||
| Configuration tlsConfig = Configuration.configuration(); | ||
| tlsConfig.proactivelyInitialiseTLS(true); | ||
| tlsConfig.preventCertificateDynamicUpdate(true); | ||
| tlsConfig.sslCertificateDomainName("example.com"); | ||
| tlsConfig.sslSubjectAlternativeNameIps(Sets.newHashSet("1.2.3.4")); | ||
| tlsConfig.sslSubjectAlternativeNameDomains(Sets.newHashSet("example.com")); | ||
| tlsConfig.directoryToSaveDynamicSSLCertificate(temp.toFile().getAbsolutePath()); | ||
|
|
||
| int tlsPort = PORT + 1; | ||
| try (ClientAndServer server = startClientAndServer(tlsConfig, tlsPort)) { | ||
|
|
||
| String path = "tls/hostname-verifier/path"; | ||
| HttpRequest mockRequest = | ||
| request() | ||
| .withPath("/" + path) | ||
| .withMethod(HttpMethod.HEAD.name().toUpperCase(Locale.ROOT)); | ||
| HttpResponse mockResponse = response().withStatusCode(200).withBody("TLS response"); | ||
| server.when(mockRequest).respond(mockResponse); | ||
|
|
||
| // With no custom hostnameVerifier (null), the BUILTIN policy is used automatically, | ||
| // so the JSSE built-in verifier rejects the connection because the SANs don't match | ||
| try (HTTPClient builtInVerifierClient = | ||
stevenzwu marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| HTTPClient.builder( | ||
| Map.of( | ||
| HTTPClient.REST_TLS_CONFIGURER, | ||
| BuiltInHostnameVerifierTLSConfigurer.class.getName())) | ||
| .uri(String.format("https://127.0.0.1:%d", tlsPort)) | ||
| .withAuthSession(AuthSession.EMPTY) | ||
| .build()) { | ||
| assertThatThrownBy(() -> builtInVerifierClient.head(path, Map.of(), (unused) -> {})) | ||
| .rootCause() | ||
| .isInstanceOf(CertificateException.class) | ||
| .hasMessage("No subject alternative names matching IP address 127.0.0.1 found"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Apparently this is wording JDK specific, May come up later if folks are testing on other architectures. But I think we are mostly testing on OpenJDK, Oracle JDK and the other OpenJDK based versions.
|
||
| } | ||
|
|
||
| // With a custom hostnameVerifier (NoopHostnameVerifier), the CLIENT policy is used | ||
| // automatically, so hostname verification is bypassed and the request succeeds | ||
| try (HTTPClient customVerifierClient = | ||
| HTTPClient.builder( | ||
| Map.of( | ||
| HTTPClient.REST_TLS_CONFIGURER, | ||
| CustomHostnameVerifierTLSConfigurer.class.getName())) | ||
| .uri(String.format("https://127.0.0.1:%d", tlsPort)) | ||
| .withAuthSession(AuthSession.EMPTY) | ||
| .build()) { | ||
| assertThatCode(() -> customVerifierClient.head(path, Map.of(), (unused) -> {})) | ||
| .doesNotThrowAnyException(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| public void testSocketTimeout() throws IOException { | ||
| long socketTimeoutMs = 2000L; | ||
|
|
@@ -613,6 +721,7 @@ private static Item doExecuteRequest( | |
| } | ||
|
|
||
| public static class Item implements RESTRequest, RESTResponse { | ||
|
|
||
| private Long id; | ||
| private String data; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,6 +36,7 @@ awaitility = "4.3.0" | |
| awssdk-bom = "2.42.8" | ||
| azuresdk-bom = "1.3.5" | ||
| awssdk-s3accessgrants = "2.4.1" | ||
| bouncycastle = "1.82" | ||
| bson-ver = "4.11.5" | ||
| caffeine = "2.9.3" | ||
| calcite = "1.41.0" | ||
|
|
@@ -111,6 +112,9 @@ awssdk-bom = { module = "software.amazon.awssdk:bom", version.ref = "awssdk-bom" | |
| awssdk-s3accessgrants = { module = "software.amazon.s3.accessgrants:aws-s3-accessgrants-java-plugin", version.ref = "awssdk-s3accessgrants" } | ||
| azuresdk-bom = { module = "com.azure:azure-sdk-bom", version.ref = "azuresdk-bom" } | ||
| bson = { module = "org.mongodb:bson", version.ref = "bson-ver"} | ||
| bouncycastle-bcpkix = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncycastle" } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since this is new dependency, do we have to add the licence of this dependency to one of the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, since it's used only in tests.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for clarifying.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that's good because bouncycastle complicates export rules, or at least due diligence
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I assumes the bouncycastle bcpkix is a transitive dep already pulled in by the mockerserver lib. is the purpose here just to upgrade the version?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Very good question! Short answer: yes. MockServer does bring BouncyCastle transitively — bcprov, bcpkix, and bcutil – but at version 1.72, which is somewhat old. The problem is that other deps (Hadoop) bring bcprov 1.82, and Gradle resolves the conflict by upgrading bcprov to 1.82. But bcpkix and bcutil stay at 1.72, since nothing else declares a higher version. This creates a mismatch: bcpkix 1.72 + bcprov 1.82 = I only upgraded bcpkix here, because that is enough for running the TLS tests. If you prefer, I can also explicitly upgrade bcutil.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it is probably good to just ensure the same version for bouncycastle family of jars. That is the current practice. E.g., there is just one version number for the two jetty jars in the toml file.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are right: I implemented your suggestion. |
||
| bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } | ||
| bouncycastle-bcutil = { module = "org.bouncycastle:bcutil-jdk18on", version.ref = "bouncycastle" } | ||
| caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } | ||
| calcite-core = { module = "org.apache.calcite:calcite-core", version.ref = "calcite" } | ||
| calcite-druid = { module = "org.apache.calcite:calcite-druid", version.ref = "calcite" } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's worth at least adding a comment on why these dependencies are being added, since it's not obvious