From 4c535cbc328e63e73e7d2c438033ed58f6bd92e4 Mon Sep 17 00:00:00 2001 From: Andreas Maechler Date: Thu, 3 Sep 2026 13:41:17 -0600 Subject: [PATCH] [CALCITE-7796] Avatica: hostname_verification=NONE has no effect with Apache HttpClient 5.6+ With hostname_verification=NONE the remote driver is supposed to skip TLS hostname verification. On Apache HttpClient 5.6 or newer it does not: connections to a server whose certificate does not name the URL host fail during the handshake with "No subject alternative DNS name matching found". The driver creates its DefaultClientTlsStrategy with a HostnameVerifier but without a HostnameVerificationPolicy and relies on the default. Up to HttpClient 5.5 that default was CLIENT, so the NoopHostnameVerifier was the only check. Since 5.6 a strategy with a verifier defaults to BOTH (and one without a verifier to BUILTIN); either policy enables the JSSE endpoint identification during the handshake, and the verifier has no influence on that check. Set the policy explicitly: CLIENT with NoopHostnameVerifier for NONE, and BOTH with the default verifier for STRICT, which matches the current HttpClient default. The three-argument constructor exists since HttpClient 5.4, so the fix also compiles against the current dependency. The new SslHostnameVerificationTest passes on HttpClient 5.5 with and without the fix; it only fails on 5.6 or newer without it. --- .../remote/CommonsHttpClientPoolCache.java | 19 ++-- .../apache/calcite/avatica/HttpBaseTest.java | 34 ++++--- .../avatica/SslHostnameVerificationTest.java | 97 +++++++++++++++++++ 3 files changed, 129 insertions(+), 21 deletions(-) create mode 100644 server/src/test/java/org/apache/calcite/avatica/SslHostnameVerificationTest.java diff --git a/core/src/main/java/org/apache/calcite/avatica/remote/CommonsHttpClientPoolCache.java b/core/src/main/java/org/apache/calcite/avatica/remote/CommonsHttpClientPoolCache.java index 5611e54a79..215db105fd 100644 --- a/core/src/main/java/org/apache/calcite/avatica/remote/CommonsHttpClientPoolCache.java +++ b/core/src/main/java/org/apache/calcite/avatica/remote/CommonsHttpClientPoolCache.java @@ -21,6 +21,7 @@ import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; +import org.apache.hc.client5.http.ssl.HostnameVerificationPolicy; import org.apache.hc.client5.http.ssl.HttpsSupport; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.client5.http.ssl.TlsSocketStrategy; @@ -34,7 +35,6 @@ import java.nio.file.Files; import java.nio.file.attribute.BasicFileAttributes; import java.util.concurrent.ConcurrentHashMap; -import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLContext; /** @@ -82,8 +82,7 @@ private static PoolingHttpClientConnectionManager setupPool(ConnectionConfig con private static TlsSocketStrategy createTlsSocketStrategy(ConnectionConfig config) { try { - return new DefaultClientTlsStrategy(getSSLContext(config), - getHostnameVerifier(config.hostnameVerification())); + return createTlsSocketStrategy(getSSLContext(config), config.hostnameVerification()); } catch (Exception e) { LOG.error("HTTPS TlsSocketStrategy configuration failed"); throw new RuntimeException(e); @@ -120,15 +119,16 @@ private static void loadTrustStore(SSLContextBuilder sslContextBuilder, Connecti } /** - * Creates the {@code HostnameVerifier} given the provided {@code verification}. + * Creates the {@code TlsSocketStrategy} given the provided {@code verification}. * + * @param sslContext The SSL context to use for the connections. * @param verification The intended hostname verification action. - * @return A verifier for the request verification. + * @return A strategy performing the requested verification. * @throws IllegalArgumentException if the provided verification cannot be * handled. */ @SuppressWarnings("deprecation") - private static HostnameVerifier getHostnameVerifier( + private static TlsSocketStrategy createTlsSocketStrategy(SSLContext sslContext, org.apache.calcite.avatica.remote. HostnameVerificationConfigurable.HostnameVerification verification) { // Normally, the configuration logic would give us a default of STRICT if it was @@ -139,9 +139,12 @@ private static HostnameVerifier getHostnameVerifier( } switch (verification) { case STRICT: - return HttpsSupport.getDefaultHostnameVerifier(); + return new DefaultClientTlsStrategy(sslContext, HostnameVerificationPolicy.BOTH, + HttpsSupport.getDefaultHostnameVerifier()); case NONE: - return NoopHostnameVerifier.INSTANCE; + // CLIENT leaves verification to the supplied verifier (which does nothing) + return new DefaultClientTlsStrategy(sslContext, HostnameVerificationPolicy.CLIENT, + NoopHostnameVerifier.INSTANCE); default: throw new IllegalArgumentException("Unhandled HostnameVerification: " + verification.name()); } diff --git a/server/src/test/java/org/apache/calcite/avatica/HttpBaseTest.java b/server/src/test/java/org/apache/calcite/avatica/HttpBaseTest.java index 1bee35632e..58c0246544 100644 --- a/server/src/test/java/org/apache/calcite/avatica/HttpBaseTest.java +++ b/server/src/test/java/org/apache/calcite/avatica/HttpBaseTest.java @@ -28,6 +28,8 @@ import org.bouncycastle.asn1.x500.style.RFC4519Style; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; import org.bouncycastle.asn1.x509.KeyUsage; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; @@ -86,15 +88,8 @@ public abstract class HttpBaseTest { public static void setupClass() throws SQLException { // Create a self-signed cert - if (KEYSTORE.isFile()) { - assertTrue("Failed to delete keystore: " + KEYSTORE, KEYSTORE.delete()); - } - new CertTool().createSelfSignedCert(KEYSTORE, "avatica", KEYSTORE_PASSWORD); - - if (EMPTY_PW_KEYSTORE.isFile()) { - assertTrue("Failed to delete keystore: " + EMPTY_PW_KEYSTORE, EMPTY_PW_KEYSTORE.delete()); - } - new CertTool().createSelfSignedCert(EMPTY_PW_KEYSTORE, "avatica", KEYSTORE_EMPTY_PASSWORD); + createSelfSignedKeyStore(KEYSTORE, "localhost", KEYSTORE_PASSWORD); + createSelfSignedKeyStore(EMPTY_PW_KEYSTORE, "localhost", KEYSTORE_EMPTY_PASSWORD); // Create a LocalService around HSQLDB JdbcMeta jdbcMeta; @@ -103,6 +98,17 @@ public static void setupClass() throws SQLException { localService = new LocalService(jdbcMeta); } + /** + * Creates a keystore holding a self-signed certificate for {@code hostname}. + */ + protected static void createSelfSignedKeyStore(File keystore, String hostname, + String keystorePassword) { + if (keystore.isFile()) { + assertTrue("Failed to delete keystore: " + keystore, keystore.delete()); + } + new CertTool().createSelfSignedCert(keystore, "avatica", keystorePassword, hostname); + } + @AfterClass public static void stopServers() throws KrbException { for (HttpServer server : SERVERS_TO_STOP) { server.stop(); @@ -131,7 +137,7 @@ private static class CertTool { } private void createSelfSignedCert(File targetKeystore, String keyName, - String keystorePassword) { + String keystorePassword, String hostname) { if (targetKeystore.exists()) { throw new RuntimeException("Keystore already exists: " + targetKeystore); } @@ -139,7 +145,7 @@ private void createSelfSignedCert(File targetKeystore, String keyName, try { KeyPair kp = generateKeyPair(); - X509Certificate cert = generateCert(keyName, kp, true, kp.getPublic(), + X509Certificate cert = generateCert(hostname, kp, true, kp.getPublic(), kp.getPrivate()); char[] password = keystorePassword.toCharArray(); @@ -161,7 +167,7 @@ private KeyPair generateKeyPair() throws NoSuchAlgorithmException { return gen.generateKeyPair(); } - private X509Certificate generateCert(String keyName, KeyPair kp, boolean isCertAuthority, + private X509Certificate generateCert(String hostname, KeyPair kp, boolean isCertAuthority, PublicKey signerPublicKey, PrivateKey signerPrivateKey) throws IOException, OperatorCreationException, CertificateException, NoSuchAlgorithmException { @@ -171,7 +177,7 @@ private X509Certificate generateCert(String keyName, KeyPair kp, boolean isCertA BigInteger serialNumber = BigInteger.valueOf(startDate.getTimeInMillis()); X500Name issuer = new X500Name( - IETFUtils.rDNsFromString("cn=localhost", RFC4519Style.INSTANCE)); + IETFUtils.rDNsFromString("cn=" + hostname, RFC4519Style.INSTANCE)); JcaX509v3CertificateBuilder certGen = new JcaX509v3CertificateBuilder(issuer, serialNumber, startDate.getTime(), endDate.getTime(), issuer, kp.getPublic()); JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils(); @@ -179,6 +185,8 @@ private X509Certificate generateCert(String keyName, KeyPair kp, boolean isCertA extensionUtils.createSubjectKeyIdentifier(kp.getPublic())); certGen.addExtension(Extension.basicConstraints, false, new BasicConstraints(isCertAuthority)); + certGen.addExtension(Extension.subjectAlternativeName, false, + new GeneralNames(new GeneralName(GeneralName.dNSName, hostname))); certGen.addExtension(Extension.authorityKeyIdentifier, false, extensionUtils.createAuthorityKeyIdentifier(signerPublicKey)); if (isCertAuthority) { diff --git a/server/src/test/java/org/apache/calcite/avatica/SslHostnameVerificationTest.java b/server/src/test/java/org/apache/calcite/avatica/SslHostnameVerificationTest.java new file mode 100644 index 0000000000..0ee1a9f6de --- /dev/null +++ b/server/src/test/java/org/apache/calcite/avatica/SslHostnameVerificationTest.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 + * + * http://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.apache.calcite.avatica; + +import org.apache.calcite.avatica.remote.Driver.Serialization; +import org.apache.calcite.avatica.server.HttpServer; + +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.File; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import javax.net.ssl.SSLException; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.junit.Assume.assumeFalse; + +/** + * Test case for the {@code hostname_verification} connection property of the remote driver. + */ +public class SslHostnameVerificationTest extends HttpBaseTest { + + private static final File MISMATCHED_KEYSTORE = + new File(TARGET_DIR, "avatica-test-mismatched-hostname.jks"); + + private static String url; + + public SslHostnameVerificationTest() { + super(url); + } + + @BeforeClass + public static void setup() throws Exception { + // Skip TLS testing on IBM Java, see SslDriverTest + assumeFalse( + "Skip TLS testing on IBM Java due eclipse/jetty.project#2807", + System.getProperty("java.vendor").contains("IBM") + ); + + setupClass(); + // The certificate is issued to a different hostname than the one we connect to + createSelfSignedKeyStore(MISMATCHED_KEYSTORE, "not-localhost.test", KEYSTORE_PASSWORD); + + HttpServer httpServer = new HttpServer.Builder() + .withPort(0) + .withTLS(MISMATCHED_KEYSTORE, KEYSTORE_PASSWORD, MISMATCHED_KEYSTORE, KEYSTORE_PASSWORD) + .withHandler(localService, Serialization.PROTOBUF) + .build(); + httpServer.start(); + SERVERS_TO_STOP.add(httpServer); + + url = "jdbc:avatica:remote:url=https://localhost:" + httpServer.getPort() + + ";serialization=PROTOBUF;truststore=" + MISMATCHED_KEYSTORE.getAbsolutePath() + + ";truststore_password=" + KEYSTORE_PASSWORD; + LOG.info("JDBC URL {}", url); + } + + @Test + public void testHostnameVerificationNone() throws Exception { + String urlWithNone = jdbcUrl + ";hostname_verification=NONE"; + try (Connection conn = DriverManager.getConnection(urlWithNone); + Statement stmt = conn.createStatement()) { + // Just make sure that the https connection is established + assertTrue(stmt.execute("SELECT * FROM EMP")); + } + } + + @Test + public void testHostnameVerificationStrict() throws Exception { + String urlWithStrict = jdbcUrl + ";hostname_verification=STRICT"; + try (Connection conn = DriverManager.getConnection(urlWithStrict); + Statement stmt = conn.createStatement()) { + stmt.execute("SELECT * FROM EMP"); + fail("Exception should have been thrown for the url: " + urlWithStrict); + } catch (Exception e) { + assertTrue("Expected an SSLException as cause of: " + e, + e.getCause() instanceof SSLException); + } + } +}