Skip to content

Commit ad082ad

Browse files
committed
Expose HostnameVerificationPolicy in TLSConfigurer
Apache HttpClient 5.4 introduced a new component: `HostnameVerificationPolicy`, which determines whether hostname verification is done by the JSSE provider (at socket level, during TLS handshake), the HttpClient (after TLS handshake), or both. This change exposes `HostnameVerificationPolicy` in `TLSConfigurer`. This component is particularly useful when attempting to bypass hostname verification, e.g. by using the `NoopHostnameVerifier`. The default policy is set to `BOTH`, which produces the same result as before.
1 parent 64e0211 commit ad082ad

5 files changed

Lines changed: 114 additions & 0 deletions

File tree

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ project(':iceberg-core') {
399399
exclude group: 'junit'
400400
}
401401
testImplementation libs.awaitility
402+
testImplementation libs.bouncycastle.bcpkix
402403
}
403404
}
404405

core/src/main/java/org/apache/iceberg/rest/HTTPClient.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ static HttpClientConnectionManager configureConnectionManager(Map<String, String
416416
tlsConfigurer.supportedProtocols(),
417417
tlsConfigurer.supportedCipherSuites(),
418418
SSLBufferMode.STATIC,
419+
tlsConfigurer.hostnameVerificationPolicy(),
419420
tlsConfigurer.hostnameVerifier()));
420421
}
421422

core/src/main/java/org/apache/iceberg/rest/auth/TLSConfigurer.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.util.Map;
2222
import javax.net.ssl.HostnameVerifier;
2323
import javax.net.ssl.SSLContext;
24+
import org.apache.hc.client5.http.ssl.HostnameVerificationPolicy;
2425
import org.apache.hc.client5.http.ssl.HttpsSupport;
2526
import org.apache.hc.core5.ssl.SSLContexts;
2627

@@ -32,6 +33,10 @@ default SSLContext sslContext() {
3233
return SSLContexts.createDefault();
3334
}
3435

36+
default HostnameVerificationPolicy hostnameVerificationPolicy() {
37+
return HostnameVerificationPolicy.BOTH;
38+
}
39+
3540
default HostnameVerifier hostnameVerifier() {
3641
return HttpsSupport.getDefaultHostnameVerifier();
3742
}

core/src/test/java/org/apache/iceberg/rest/TestHTTPClient.java

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,37 +35,49 @@
3535
import com.fasterxml.jackson.databind.ObjectMapper;
3636
import java.io.IOException;
3737
import java.net.SocketTimeoutException;
38+
import java.nio.file.Path;
39+
import java.security.KeyStore;
40+
import java.security.cert.CertificateException;
3841
import java.util.Locale;
3942
import java.util.Map;
4043
import java.util.Objects;
4144
import java.util.concurrent.TimeUnit;
4245
import java.util.function.Consumer;
46+
import javax.net.ssl.HostnameVerifier;
47+
import javax.net.ssl.SSLContext;
48+
import javax.net.ssl.TrustManagerFactory;
4349
import org.apache.hc.client5.http.auth.AuthScope;
4450
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
4551
import org.apache.hc.client5.http.config.ConnectionConfig;
4652
import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
4753
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
4854
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
55+
import org.apache.hc.client5.http.ssl.HostnameVerificationPolicy;
56+
import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
4957
import org.apache.hc.core5.http.HttpHost;
5058
import org.apache.hc.core5.http.HttpStatus;
5159
import org.apache.iceberg.IcebergBuild;
5260
import org.apache.iceberg.catalog.TableIdentifier;
5361
import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
62+
import org.apache.iceberg.relocated.com.google.common.collect.Sets;
5463
import org.apache.iceberg.rest.auth.AuthSession;
5564
import org.apache.iceberg.rest.auth.TLSConfigurer;
5665
import org.apache.iceberg.rest.responses.ErrorResponse;
5766
import org.apache.iceberg.rest.responses.ErrorResponseParser;
5867
import org.junit.jupiter.api.AfterAll;
5968
import org.junit.jupiter.api.BeforeAll;
6069
import org.junit.jupiter.api.Test;
70+
import org.junit.jupiter.api.io.TempDir;
6171
import org.junit.jupiter.params.ParameterizedTest;
6272
import org.junit.jupiter.params.provider.EnumSource;
6373
import org.junit.jupiter.params.provider.ValueSource;
6474
import org.mockserver.configuration.Configuration;
6575
import org.mockserver.integration.ClientAndServer;
76+
import org.mockserver.logging.MockServerLogger;
6677
import org.mockserver.matchers.Times;
6778
import org.mockserver.model.HttpRequest;
6879
import org.mockserver.model.HttpResponse;
80+
import org.mockserver.socket.tls.KeyStoreFactory;
6981
import org.mockserver.verify.VerificationTimes;
7082

7183
/**
@@ -395,6 +407,99 @@ public void testLoadTLSConfigurerNotImplementTLSConfigurer() {
395407
.hasMessageContaining("does not implement TLSConfigurer");
396408
}
397409

410+
public static class LaxTLSConfigurer implements TLSConfigurer {
411+
412+
private static HostnameVerificationPolicy policy = HostnameVerificationPolicy.BUILTIN;
413+
414+
static void setPolicy(HostnameVerificationPolicy policy) {
415+
LaxTLSConfigurer.policy = policy;
416+
}
417+
418+
@Override
419+
public SSLContext sslContext() {
420+
// Create an SSLContext that trusts MockServer's CA certificate but still performs hostname
421+
// verification during SSL handshake
422+
try {
423+
KeyStore keyStore =
424+
new KeyStoreFactory(Configuration.configuration(), new MockServerLogger())
425+
.loadOrCreateKeyStore();
426+
TrustManagerFactory tmf =
427+
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
428+
tmf.init(keyStore);
429+
SSLContext sslContext = SSLContext.getInstance("TLSv1.2");
430+
sslContext.init(null, tmf.getTrustManagers(), null);
431+
return sslContext;
432+
} catch (Exception e) {
433+
throw new RuntimeException("Failed to create SSLContext", e);
434+
}
435+
}
436+
437+
@Override
438+
public HostnameVerificationPolicy hostnameVerificationPolicy() {
439+
return policy;
440+
}
441+
442+
@Override
443+
public HostnameVerifier hostnameVerifier() {
444+
// Disable hostname verification at HttpClient level (post SSL handshake)
445+
return NoopHostnameVerifier.INSTANCE;
446+
}
447+
}
448+
449+
@ParameterizedTest
450+
@EnumSource(HostnameVerificationPolicy.class)
451+
public void testTLSConfigurerHostnameVerificationPolicy(HostnameVerificationPolicy policy, @TempDir Path temp)
452+
throws IOException {
453+
454+
// Start a dedicated MockServer with a certificate that does NOT include 127.0.0.1 or localhost
455+
// in its SANs. Hostname verification must be disabled for this test to pass.
456+
Configuration tlsConfig = Configuration.configuration();
457+
tlsConfig.proactivelyInitialiseTLS(true);
458+
tlsConfig.preventCertificateDynamicUpdate(true);
459+
tlsConfig.sslCertificateDomainName("example.com");
460+
tlsConfig.sslSubjectAlternativeNameIps(Sets.newHashSet("1.2.3.4"));
461+
tlsConfig.sslSubjectAlternativeNameDomains(Sets.newHashSet("example.com"));
462+
tlsConfig.directoryToSaveDynamicSSLCertificate(temp.toFile().getAbsolutePath());
463+
464+
int tlsPort = PORT + 1;
465+
try (ClientAndServer server = startClientAndServer(tlsConfig, tlsPort)) {
466+
467+
String path = "tls/path";
468+
HttpRequest mockRequest =
469+
request()
470+
.withPath("/" + path)
471+
.withMethod(HttpMethod.HEAD.name().toUpperCase(Locale.ROOT));
472+
HttpResponse mockResponse = response().withStatusCode(200).withBody("TLS response");
473+
server.when(mockRequest).respond(mockResponse);
474+
475+
LaxTLSConfigurer.setPolicy(policy);
476+
477+
Map<String, String> properties =
478+
Map.of(HTTPClient.REST_TLS_CONFIGURER, LaxTLSConfigurer.class.getName());
479+
480+
try (HTTPClient client =
481+
HTTPClient.builder(properties)
482+
.uri(String.format("https://127.0.0.1:%d", tlsPort))
483+
.withAuthSession(AuthSession.EMPTY)
484+
.build()) {
485+
486+
if (policy == HostnameVerificationPolicy.CLIENT) {
487+
// With hostname verification performed by the HttpClient *only*,
488+
// the request should succeed
489+
assertThatCode(() -> client.head(path, Map.of(), (unused) -> {}))
490+
.doesNotThrowAnyException();
491+
} else {
492+
// With hostname verification performed by the JSSE provider,
493+
// the request should fail with a CertificateException
494+
assertThatThrownBy(() -> client.head(path, Map.of(), (unused) -> {}))
495+
.rootCause()
496+
.isInstanceOf(CertificateException.class)
497+
.hasMessage("No subject alternative names matching IP address 127.0.0.1 found");
498+
}
499+
}
500+
}
501+
}
502+
398503
@Test
399504
public void testSocketTimeout() throws IOException {
400505
long socketTimeoutMs = 2000L;

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ awaitility = "4.3.0"
3636
awssdk-bom = "2.42.4"
3737
azuresdk-bom = "1.3.4"
3838
awssdk-s3accessgrants = "2.4.1"
39+
bouncycastle = "1.83"
3940
bson-ver = "4.11.5"
4041
caffeine = "2.9.3"
4142
calcite = "1.41.0"
@@ -111,6 +112,7 @@ awssdk-bom = { module = "software.amazon.awssdk:bom", version.ref = "awssdk-bom"
111112
awssdk-s3accessgrants = { module = "software.amazon.s3.accessgrants:aws-s3-accessgrants-java-plugin", version.ref = "awssdk-s3accessgrants" }
112113
azuresdk-bom = { module = "com.azure:azure-sdk-bom", version.ref = "azuresdk-bom" }
113114
bson = { module = "org.mongodb:bson", version.ref = "bson-ver"}
115+
bouncycastle-bcpkix = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncycastle" }
114116
caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" }
115117
calcite-core = { module = "org.apache.calcite:calcite-core", version.ref = "calcite" }
116118
calcite-druid = { module = "org.apache.calcite:calcite-druid", version.ref = "calcite" }

0 commit comments

Comments
 (0)