From ea5174f23e9921d724f3610c1a7565d5bc5be00b Mon Sep 17 00:00:00 2001 From: Fred Park Date: Thu, 15 Oct 2026 16:18:40 -0700 Subject: [PATCH 1/2] GH-1319: [JDBC] Add hostnameOverride connection property for TLS certificate verification and SNI --- docs/source/flight_sql_jdbc_driver.rst | 10 + .../driver/jdbc/ArrowFlightConnection.java | 1 + .../client/ArrowFlightSqlClientHandler.java | 39 ++++ .../ArrowFlightConnectionConfigImpl.java | 10 + .../ConnectionTlsHostnameOverrideTest.java | 210 ++++++++++++++++++ ...rrowFlightSqlClientHandlerBuilderTest.java | 74 ++++++ .../ArrowFlightConnectionConfigImplTest.java | 7 + 7 files changed, 351 insertions(+) create mode 100644 flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/ConnectionTlsHostnameOverrideTest.java diff --git a/docs/source/flight_sql_jdbc_driver.rst b/docs/source/flight_sql_jdbc_driver.rst index 6d40434a22..835e875186 100644 --- a/docs/source/flight_sql_jdbc_driver.rst +++ b/docs/source/flight_sql_jdbc_driver.rst @@ -118,6 +118,16 @@ case-sensitive. The supported parameters are: - Path to PEM-encoded root certificates for TLS - use this as an alternative to ``trustStore`` + * - hostnameOverride + - null + - When TLS is enabled, the hostname to verify the server certificate + against and to send for SNI, instead of **HOSTNAME**. Use this when + connecting through a tunnel, a private endpoint, or an IP address + that does not match the server certificate. Endpoints returned by + the server are only verified against this hostname if they are on + the same host as the connection. Ignored when ``useEncryption`` is + false. + * - clientCertificate - null - Path to PEM-encoded client mTLS certificate when the Flight diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/ArrowFlightConnection.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/ArrowFlightConnection.java index 623c2b81be..a5c4531767 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/ArrowFlightConnection.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/ArrowFlightConnection.java @@ -119,6 +119,7 @@ private static ArrowFlightSqlClientHandler createNewClientHandler( .withBufferAllocator(allocator) .withEncryption(config.useEncryption()) .withDisableCertificateVerification(config.getDisableCertificateVerification()) + .withHostnameOverride(config.getHostnameOverride()) .withToken(config.getToken()) .withCallOptions(config.toCallOption()) .withRetainCookies(config.retainCookies()) diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandler.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandler.java index 719cc38a2b..c70c281785 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandler.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandler.java @@ -188,6 +188,7 @@ public List getStreams(final FlightInfo flightInfo) .withHost(endpointUri.getHost()) .withPort(endpointUri.getPort()) .withEncryption(endpointUri.getScheme().equals(LocationSchemes.GRPC_TLS)) + .withHostnameOverride(builder.getHostnameOverrideForEndpoint(endpointUri)) .withClientCache(flightClientCache) .withConnectTimeout(builder.connectTimeout); @@ -677,6 +678,8 @@ public static final class Builder { @VisibleForTesting boolean disableCertificateVerification; + @VisibleForTesting @Nullable String hostnameOverride; + @VisibleForTesting boolean useSystemTrustStore = true; @VisibleForTesting String tlsRootCertificatesPath; @@ -732,6 +735,7 @@ public Builder() {} this.token = original.token; this.useEncryption = original.useEncryption; this.disableCertificateVerification = original.disableCertificateVerification; + this.hostnameOverride = original.hostnameOverride; this.useSystemTrustStore = original.useSystemTrustStore; this.tlsRootCertificatesPath = original.tlsRootCertificatesPath; this.clientCertificatePath = original.clientCertificatePath; @@ -840,6 +844,38 @@ public Builder withDisableCertificateVerification( return this; } + /** + * Sets the hostname to use for TLS certificate verification and SNI instead of the host this + * handler connects to. Ignored when encryption is disabled. + * + * @param hostnameOverride the hostname to verify the server certificate against, or null to use + * the host. + * @return this instance. + */ + public Builder withHostnameOverride(@Nullable final String hostnameOverride) { + this.hostnameOverride = hostnameOverride; + return this; + } + + /** + * Gets the hostname override to use when connecting to an endpoint location. The override is + * only valid for the host it was configured for, so endpoints on any other host are verified + * against their own hostname. + * + * @param endpointUri the endpoint location. + * @return the hostname override if the endpoint is on the original host, otherwise null. + */ + @VisibleForTesting + @Nullable String getHostnameOverrideForEndpoint(final URI endpointUri) { + final String endpointHost = endpointUri.getHost(); + if (hostnameOverride == null + || endpointHost == null + || !endpointHost.equalsIgnoreCase(host)) { + return null; + } + return hostnameOverride; + } + /** * Sets whether to use the certificates from the operating system. * @@ -1065,6 +1101,9 @@ public ArrowFlightSqlClientHandler build() throws SQLException { buildTimeMiddlewareFactories.forEach(clientBuilder::intercept); if (useEncryption) { clientBuilder.useTls(); + if (hostnameOverride != null) { + clientBuilder.overrideHostname(hostnameOverride); + } } Location location = getLocation(); clientBuilder.location(location); diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/utils/ArrowFlightConnectionConfigImpl.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/utils/ArrowFlightConnectionConfigImpl.java index d0ba74dbcc..93d029d5c0 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/utils/ArrowFlightConnectionConfigImpl.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/utils/ArrowFlightConnectionConfigImpl.java @@ -112,6 +112,15 @@ public String getTlsRootCertificatesPath() { return ArrowFlightConnectionProperty.TLS_ROOT_CERTS.getString(properties); } + /** + * Gets the hostname to use for TLS certificate verification and SNI instead of the host. + * + * @return the hostname override, or null if not set. + */ + public String getHostnameOverride() { + return ArrowFlightConnectionProperty.HOSTNAME_OVERRIDE.getString(properties); + } + public String getClientCertificatePath() { return ArrowFlightConnectionProperty.CLIENT_CERTIFICATE.getString(properties); } @@ -258,6 +267,7 @@ public enum ArrowFlightConnectionProperty implements ConnectionProperty { TRUST_STORE_PASSWORD("trustStorePassword", null, Type.STRING, false), USE_SYSTEM_TRUST_STORE("useSystemTrustStore", true, Type.BOOLEAN, false), TLS_ROOT_CERTS("tlsRootCerts", null, Type.STRING, false), + HOSTNAME_OVERRIDE("hostnameOverride", null, Type.STRING, false), CLIENT_CERTIFICATE("clientCertificate", null, Type.STRING, false), CLIENT_KEY("clientKey", null, Type.STRING, false), THREAD_POOL_SIZE("threadPoolSize", 1, Type.NUMBER, false), diff --git a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/ConnectionTlsHostnameOverrideTest.java b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/ConnectionTlsHostnameOverrideTest.java new file mode 100644 index 0000000000..d64f2f0ba2 --- /dev/null +++ b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/ConnectionTlsHostnameOverrideTest.java @@ -0,0 +1,210 @@ +/* + * 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.arrow.driver.jdbc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Arrays; +import java.util.Properties; +import org.apache.arrow.driver.jdbc.authentication.UserPasswordAuthentication; +import org.apache.arrow.driver.jdbc.utils.ArrowFlightConnectionConfigImpl.ArrowFlightConnectionProperty; +import org.apache.arrow.driver.jdbc.utils.FlightSqlTestCertificates; +import org.apache.arrow.driver.jdbc.utils.MockFlightSqlProducer; +import org.apache.arrow.driver.jdbc.utils.PartitionedFlightSqlProducer; +import org.apache.arrow.flight.FlightEndpoint; +import org.apache.arrow.flight.FlightServer; +import org.apache.arrow.flight.Location; +import org.apache.arrow.flight.Ticket; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.Schema; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Tests the hostnameOverride connection property. The test certificate is issued for {@code + * localhost} and has no IP address in its subject alternative names, so connecting by IP address + * only passes verification when the hostname override is used. + */ +public class ConnectionTlsHostnameOverrideTest { + + private static final String IP_ADDRESS = "127.0.0.1"; + private static final String CERTIFICATE_HOSTNAME = "localhost"; + private static final String USER = "user1"; + private static final String PASSWORD = "pass1"; + + @RegisterExtension public static final FlightServerTestExtension FLIGHT_SERVER_TEST_EXTENSION; + private static final FlightSqlTestCertificates.CertKeyPair certKey; + private static final String tlsRootCertsPath; + + static { + certKey = FlightSqlTestCertificates.exampleTlsCerts().get(0); + tlsRootCertsPath = certKey.cert.getPath(); + + FLIGHT_SERVER_TEST_EXTENSION = + new FlightServerTestExtension.Builder() + .authentication(new UserPasswordAuthentication.Builder().user(USER, PASSWORD).build()) + .useEncryption(certKey.cert, certKey.key) + .producer(new MockFlightSqlProducer()) + .build(); + } + + @BeforeAll + public static void registerDriver() throws SQLException { + DriverManager.registerDriver(new ArrowFlightJdbcDriver()); + } + + private static String tlsUrl(final String host, final int port) throws Exception { + return String.format( + "jdbc:arrow-flight-sql://%s:%d?useEncryption=true&useSystemTrustStore=false&%s=%s", + host, + port, + ArrowFlightConnectionProperty.TLS_ROOT_CERTS.camelName(), + URLEncoder.encode(tlsRootCertsPath, "UTF-8")); + } + + private static String serverUrl(final String host) throws Exception { + return tlsUrl(host, FLIGHT_SERVER_TEST_EXTENSION.getPort()) + + String.format("&user=%s&password=%s", USER, PASSWORD); + } + + @Test + public void testConnectionByIpAddressFailsWithoutHostnameOverride() { + assertThrows( + SQLException.class, + () -> { + try (Connection connection = DriverManager.getConnection(serverUrl(IP_ADDRESS))) { + connection.isValid(0); + } + }); + } + + @Test + public void testConnectionByIpAddressSucceedsWithHostnameOverrideInUrl() throws Exception { + try (Connection connection = + DriverManager.getConnection( + serverUrl(IP_ADDRESS) + "&hostnameOverride=" + CERTIFICATE_HOSTNAME)) { + assertTrue(connection.isValid(0)); + } + } + + @Test + public void testConnectionByIpAddressSucceedsWithHostnameOverrideInProperties() throws Exception { + final Properties properties = new Properties(); + properties.setProperty( + ArrowFlightConnectionProperty.HOSTNAME_OVERRIDE.camelName(), CERTIFICATE_HOSTNAME); + + try (Connection connection = DriverManager.getConnection(serverUrl(IP_ADDRESS), properties)) { + assertTrue(connection.isValid(0)); + } + } + + @Test + public void testServerCertificateIsVerifiedAgainstHostnameOverride() { + // The connection host matches the certificate, but verification uses the override. + assertThrows( + SQLException.class, + () -> { + try (Connection connection = + DriverManager.getConnection( + serverUrl(CERTIFICATE_HOSTNAME) + "&hostnameOverride=other.example.com")) { + connection.isValid(0); + } + }); + } + + @Test + public void testHostnameOverrideIsIgnoredWithoutEncryption() throws Exception { + try (BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE); + FlightServer server = + FlightServer.builder( + allocator, + Location.forGrpcInsecure(CERTIFICATE_HOSTNAME, 0), + new MockFlightSqlProducer()) + .build() + .start(); + Connection connection = + DriverManager.getConnection( + String.format( + "jdbc:arrow-flight-sql://%s:%d?useEncryption=false&hostnameOverride=%s", + IP_ADDRESS, server.getPort(), "other.example.com"))) { + assertTrue(connection.isValid(0)); + } + } + + @Test + public void testHostnameOverrideIsAppliedToEndpointOnConnectionHost() throws Exception { + final Schema schema = + new Schema( + Arrays.asList(Field.nullablePrimitive("int_column", new ArrowType.Int(32, true)))); + final Ticket ticket = new Ticket("partition".getBytes(StandardCharsets.UTF_8)); + + try (BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE); + VectorSchemaRoot partition = VectorSchemaRoot.create(schema, allocator); + FlightServer dataServer = + FlightServer.builder( + allocator, + Location.forGrpcTls(CERTIFICATE_HOSTNAME, 0), + new PartitionedFlightSqlProducer.DataOnlyFlightSqlProducer(ticket, partition)) + .useTls(certKey.cert, certKey.key) + .build() + .start()) { + partition.setRowCount(1); + ((IntVector) partition.getVector(0)).set(0, 42); + + // The endpoint is on the same IP address as the connection, so it must be verified against + // the hostname override as well. + final FlightEndpoint endpoint = + new FlightEndpoint(ticket, Location.forGrpcTls(IP_ADDRESS, dataServer.getPort())); + + try (PartitionedFlightSqlProducer rootProducer = + new PartitionedFlightSqlProducer(schema, endpoint); + FlightServer rootServer = + FlightServer.builder( + allocator, Location.forGrpcTls(CERTIFICATE_HOSTNAME, 0), rootProducer) + .useTls(certKey.cert, certKey.key) + .build() + .start(); + Connection connection = + DriverManager.getConnection( + tlsUrl(IP_ADDRESS, rootServer.getPort()) + + "&hostnameOverride=" + + CERTIFICATE_HOSTNAME); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT partitioned_data")) { + assertTrue(resultSet.next()); + assertEquals(42, resultSet.getInt(1)); + assertFalse(resultSet.next()); + } + } + } +} diff --git a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandlerBuilderTest.java b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandlerBuilderTest.java index a60a71f23d..6fb1088c56 100644 --- a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandlerBuilderTest.java +++ b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandlerBuilderTest.java @@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.net.URI; import java.util.Optional; import org.apache.arrow.driver.jdbc.FlightServerTestExtension; import org.apache.arrow.driver.jdbc.utils.CoreMockedSqlProducers; @@ -137,6 +138,7 @@ public void testDefaults() { assertNull(builder.password); assertTrue(builder.useEncryption); assertFalse(builder.disableCertificateVerification); + assertNull(builder.hostnameOverride); assertNull(builder.trustStorePath); assertNull(builder.trustStorePassword); assertTrue(builder.useSystemTrustStore); @@ -152,6 +154,78 @@ public void testDefaults() { assertNull(builder.driverVersion); } + @Test + public void testHostnameOverrideIsCopied() { + final ArrowFlightSqlClientHandler.Builder rootBuilder = + new ArrowFlightSqlClientHandler.Builder().withHostnameOverride("flight.example.com"); + + final ArrowFlightSqlClientHandler.Builder copiedBuilder = + new ArrowFlightSqlClientHandler.Builder(rootBuilder); + + assertEquals("flight.example.com", copiedBuilder.hostnameOverride); + } + + @Test + public void testHostnameOverrideForEndpointOnSameHost() { + final ArrowFlightSqlClientHandler.Builder builder = + new ArrowFlightSqlClientHandler.Builder() + .withHost("127.0.0.1") + .withHostnameOverride("flight.example.com"); + + assertEquals( + "flight.example.com", + builder.getHostnameOverrideForEndpoint(URI.create("grpc+tls://127.0.0.1:1234"))); + // The port is not part of the match. + assertEquals( + "flight.example.com", + builder.getHostnameOverrideForEndpoint(URI.create("grpc+tls://127.0.0.1:5678"))); + } + + @Test + public void testHostnameOverrideForEndpointMatchesHostCaseInsensitively() { + final ArrowFlightSqlClientHandler.Builder builder = + new ArrowFlightSqlClientHandler.Builder() + .withHost("Tunnel.Local") + .withHostnameOverride("flight.example.com"); + + assertEquals( + "flight.example.com", + builder.getHostnameOverrideForEndpoint(URI.create("grpc+tls://tunnel.local:1234"))); + } + + @Test + public void testHostnameOverrideForEndpointOnIpv6Host() { + // Both the JDBC URL and endpoint locations are parsed with URI#getHost, which keeps brackets. + final ArrowFlightSqlClientHandler.Builder builder = + new ArrowFlightSqlClientHandler.Builder() + .withHost(URI.create("jdbc://[::1]:1234").getHost()) + .withHostnameOverride("flight.example.com"); + + assertEquals( + "flight.example.com", + builder.getHostnameOverrideForEndpoint(URI.create("grpc+tls://[::1]:1234"))); + } + + @Test + public void testHostnameOverrideForEndpointOnDifferentHostIsCleared() { + final ArrowFlightSqlClientHandler.Builder builder = + new ArrowFlightSqlClientHandler.Builder() + .withHost("127.0.0.1") + .withHostnameOverride("flight.example.com"); + + assertNull(builder.getHostnameOverrideForEndpoint(URI.create("grpc+tls://10.0.0.1:1234"))); + assertNull( + builder.getHostnameOverrideForEndpoint(URI.create("grpc+tls://other.example.com:1234"))); + } + + @Test + public void testHostnameOverrideForEndpointWithoutOverride() { + final ArrowFlightSqlClientHandler.Builder builder = + new ArrowFlightSqlClientHandler.Builder().withHost("127.0.0.1"); + + assertNull(builder.getHostnameOverrideForEndpoint(URI.create("grpc+tls://127.0.0.1:1234"))); + } + @Test public void testCatalog() { ArrowFlightSqlClientHandler.Builder rootBuilder = new ArrowFlightSqlClientHandler.Builder(); diff --git a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/utils/ArrowFlightConnectionConfigImplTest.java b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/utils/ArrowFlightConnectionConfigImplTest.java index ecce7708c0..909e7cd39a 100644 --- a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/utils/ArrowFlightConnectionConfigImplTest.java +++ b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/utils/ArrowFlightConnectionConfigImplTest.java @@ -20,6 +20,7 @@ import static org.apache.arrow.driver.jdbc.utils.ArrowFlightConnectionConfigImpl.ArrowFlightConnectionProperty.CATALOG; import static org.apache.arrow.driver.jdbc.utils.ArrowFlightConnectionConfigImpl.ArrowFlightConnectionProperty.CONNECT_TIMEOUT_MILLIS; import static org.apache.arrow.driver.jdbc.utils.ArrowFlightConnectionConfigImpl.ArrowFlightConnectionProperty.HOST; +import static org.apache.arrow.driver.jdbc.utils.ArrowFlightConnectionConfigImpl.ArrowFlightConnectionProperty.HOSTNAME_OVERRIDE; import static org.apache.arrow.driver.jdbc.utils.ArrowFlightConnectionConfigImpl.ArrowFlightConnectionProperty.PASSWORD; import static org.apache.arrow.driver.jdbc.utils.ArrowFlightConnectionConfigImpl.ArrowFlightConnectionProperty.PORT; import static org.apache.arrow.driver.jdbc.utils.ArrowFlightConnectionConfigImpl.ArrowFlightConnectionProperty.THREAD_POOL_SIZE; @@ -106,6 +107,12 @@ public static Stream provideParameters() { useEncryption, (Function) ArrowFlightConnectionConfigImpl::useEncryption), + Arguments.of( + HOSTNAME_OVERRIDE, + "flight.example.com", + "flight.example.com", + (Function) + ArrowFlightConnectionConfigImpl::getHostnameOverride), Arguments.of( THREAD_POOL_SIZE, threadPoolSize, From c707cd6b07e8311da337f370e2db6be550e25ad8 Mon Sep 17 00:00:00 2001 From: Fred Park Date: Fri, 16 Oct 2026 10:00:56 -0700 Subject: [PATCH 2/2] GH-1319: [JDBC] Treat blank hostnameOverride as unset and trim it Normalize hostnameOverride in the builder so an empty or blank value (e.g. from a templated URL) falls back to the connection host instead of failing with an invalid authority. Also skip the override for endpoints when encryption is disabled, and document that it is sent as the gRPC :authority. --- docs/source/flight_sql_jdbc_driver.rst | 3 +- .../client/ArrowFlightSqlClientHandler.java | 23 ++-- .../ConnectionTlsHostnameOverrideTest.java | 122 +++++++++++------- ...rrowFlightSqlClientHandlerBuilderTest.java | 27 ++++ 4 files changed, 118 insertions(+), 57 deletions(-) diff --git a/docs/source/flight_sql_jdbc_driver.rst b/docs/source/flight_sql_jdbc_driver.rst index 835e875186..f45f902630 100644 --- a/docs/source/flight_sql_jdbc_driver.rst +++ b/docs/source/flight_sql_jdbc_driver.rst @@ -121,7 +121,8 @@ case-sensitive. The supported parameters are: * - hostnameOverride - null - When TLS is enabled, the hostname to verify the server certificate - against and to send for SNI, instead of **HOSTNAME**. Use this when + against and to send for SNI and as the gRPC ``:authority`` header, + instead of **HOSTNAME**. Use this when connecting through a tunnel, a private endpoint, or an IP address that does not match the server certificate. Endpoints returned by the server are only verified against this hostname if they are on diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandler.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandler.java index c70c281785..3a58fc1fbb 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandler.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandler.java @@ -846,21 +846,24 @@ public Builder withDisableCertificateVerification( /** * Sets the hostname to use for TLS certificate verification and SNI instead of the host this - * handler connects to. Ignored when encryption is disabled. + * handler connects to. It is also sent as the gRPC authority ({@code :authority} header). + * Ignored when encryption is disabled. * - * @param hostnameOverride the hostname to verify the server certificate against, or null to use - * the host. + * @param hostnameOverride the hostname to verify the server certificate against, or null or + * blank to use the host. * @return this instance. */ public Builder withHostnameOverride(@Nullable final String hostnameOverride) { - this.hostnameOverride = hostnameOverride; + final String trimmed = hostnameOverride == null ? null : hostnameOverride.trim(); + this.hostnameOverride = trimmed == null || trimmed.isEmpty() ? null : trimmed; return this; } /** * Gets the hostname override to use when connecting to an endpoint location. The override is * only valid for the host it was configured for, so endpoints on any other host are verified - * against their own hostname. + * against their own hostname. Like the override itself, it is ignored when the original + * connection does not use encryption. * * @param endpointUri the endpoint location. * @return the hostname override if the endpoint is on the original host, otherwise null. @@ -868,7 +871,8 @@ public Builder withHostnameOverride(@Nullable final String hostnameOverride) { @VisibleForTesting @Nullable String getHostnameOverrideForEndpoint(final URI endpointUri) { final String endpointHost = endpointUri.getHost(); - if (hostnameOverride == null + if (!useEncryption + || hostnameOverride == null || endpointHost == null || !endpointHost.equalsIgnoreCase(host)) { return null; @@ -1101,14 +1105,15 @@ public ArrowFlightSqlClientHandler build() throws SQLException { buildTimeMiddlewareFactories.forEach(clientBuilder::intercept); if (useEncryption) { clientBuilder.useTls(); - if (hostnameOverride != null) { - clientBuilder.overrideHostname(hostnameOverride); - } } Location location = getLocation(); clientBuilder.location(location); if (useEncryption) { + if (hostnameOverride != null) { + clientBuilder.overrideHostname(hostnameOverride); + } + if (disableCertificateVerification) { clientBuilder.verifyServer(false); } else { diff --git a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/ConnectionTlsHostnameOverrideTest.java b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/ConnectionTlsHostnameOverrideTest.java index d64f2f0ba2..ca2afa5dbd 100644 --- a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/ConnectionTlsHostnameOverrideTest.java +++ b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/ConnectionTlsHostnameOverrideTest.java @@ -20,15 +20,19 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.security.cert.CertificateException; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.util.ArrayDeque; import java.util.Arrays; +import java.util.Deque; import java.util.Properties; import org.apache.arrow.driver.jdbc.authentication.UserPasswordAuthentication; import org.apache.arrow.driver.jdbc.utils.ArrowFlightConnectionConfigImpl.ArrowFlightConnectionProperty; @@ -46,41 +50,52 @@ import org.apache.arrow.vector.types.pojo.ArrowType; import org.apache.arrow.vector.types.pojo.Field; import org.apache.arrow.vector.types.pojo.Schema; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; /** * Tests the hostnameOverride connection property. The test certificate is issued for {@code * localhost} and has no IP address in its subject alternative names, so connecting by IP address - * only passes verification when the hostname override is used. + * only passes verification when the hostname override is used. Servers bind to the IP address + * explicitly so the tests don't depend on how {@code localhost} resolves. */ public class ConnectionTlsHostnameOverrideTest { private static final String IP_ADDRESS = "127.0.0.1"; private static final String CERTIFICATE_HOSTNAME = "localhost"; + // Connecting with credentials performs a handshake, so the TLS connection is made eagerly. private static final String USER = "user1"; private static final String PASSWORD = "pass1"; - @RegisterExtension public static final FlightServerTestExtension FLIGHT_SERVER_TEST_EXTENSION; - private static final FlightSqlTestCertificates.CertKeyPair certKey; - private static final String tlsRootCertsPath; + private static final FlightSqlTestCertificates.CertKeyPair certKey = + FlightSqlTestCertificates.exampleTlsCerts().get(0); + private static final String tlsRootCertsPath = certKey.cert.getPath(); - static { - certKey = FlightSqlTestCertificates.exampleTlsCerts().get(0); - tlsRootCertsPath = certKey.cert.getPath(); - - FLIGHT_SERVER_TEST_EXTENSION = - new FlightServerTestExtension.Builder() - .authentication(new UserPasswordAuthentication.Builder().user(USER, PASSWORD).build()) - .useEncryption(certKey.cert, certKey.key) - .producer(new MockFlightSqlProducer()) - .build(); - } + private static BufferAllocator allocator; + private static FlightServer tlsServer; @BeforeAll - public static void registerDriver() throws SQLException { + public static void setUp() throws Exception { DriverManager.registerDriver(new ArrowFlightJdbcDriver()); + allocator = new RootAllocator(Long.MAX_VALUE); + tlsServer = + FlightServer.builder( + allocator, Location.forGrpcTls(IP_ADDRESS, 0), new MockFlightSqlProducer()) + .headerAuthenticator( + new UserPasswordAuthentication.Builder() + .user(USER, PASSWORD) + .build() + .authenticate()) + .useTls(certKey.cert, certKey.key) + .build() + .start(); + } + + @AfterAll + public static void tearDown() throws Exception { + tlsServer.close(); + allocator.close(); } private static String tlsUrl(final String host, final int port) throws Exception { @@ -93,19 +108,38 @@ private static String tlsUrl(final String host, final int port) throws Exception } private static String serverUrl(final String host) throws Exception { - return tlsUrl(host, FLIGHT_SERVER_TEST_EXTENSION.getPort()) + return tlsUrl(host, tlsServer.getPort()) + String.format("&user=%s&password=%s", USER, PASSWORD); } + /** Asserts that connecting fails because the server certificate was rejected. */ + private static void assertCertificateRejected(final String url) { + final SQLException exception = + assertThrows( + SQLException.class, + () -> { + try (Connection connection = DriverManager.getConnection(url)) { + connection.isValid(0); + } + }); + final Deque pending = new ArrayDeque<>(); + pending.add(exception); + while (!pending.isEmpty()) { + final Throwable throwable = pending.pop(); + if (throwable instanceof CertificateException) { + return; + } + if (throwable.getCause() != null) { + pending.add(throwable.getCause()); + } + pending.addAll(Arrays.asList(throwable.getSuppressed())); + } + fail("Expected the server certificate to be rejected", exception); + } + @Test - public void testConnectionByIpAddressFailsWithoutHostnameOverride() { - assertThrows( - SQLException.class, - () -> { - try (Connection connection = DriverManager.getConnection(serverUrl(IP_ADDRESS))) { - connection.isValid(0); - } - }); + public void testConnectionByIpAddressFailsWithoutHostnameOverride() throws Exception { + assertCertificateRejected(serverUrl(IP_ADDRESS)); } @Test @@ -129,27 +163,23 @@ public void testConnectionByIpAddressSucceedsWithHostnameOverrideInProperties() } @Test - public void testServerCertificateIsVerifiedAgainstHostnameOverride() { - // The connection host matches the certificate, but verification uses the override. - assertThrows( - SQLException.class, - () -> { - try (Connection connection = - DriverManager.getConnection( - serverUrl(CERTIFICATE_HOSTNAME) + "&hostnameOverride=other.example.com")) { - connection.isValid(0); - } - }); + public void testServerCertificateIsVerifiedAgainstHostnameOverride() throws Exception { + // Same connection as the passing override tests, but the override doesn't match the + // certificate, so verification must use the override's value. + assertCertificateRejected(serverUrl(IP_ADDRESS) + "&hostnameOverride=other.example.com"); + } + + @Test + public void testBlankHostnameOverrideIsIgnored() throws Exception { + // A blank value behaves as if the property were unset rather than being sent to gRPC. + assertCertificateRejected(serverUrl(IP_ADDRESS) + "&hostnameOverride="); } @Test public void testHostnameOverrideIsIgnoredWithoutEncryption() throws Exception { - try (BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE); - FlightServer server = + try (FlightServer server = FlightServer.builder( - allocator, - Location.forGrpcInsecure(CERTIFICATE_HOSTNAME, 0), - new MockFlightSqlProducer()) + allocator, Location.forGrpcInsecure(IP_ADDRESS, 0), new MockFlightSqlProducer()) .build() .start(); Connection connection = @@ -168,12 +198,11 @@ public void testHostnameOverrideIsAppliedToEndpointOnConnectionHost() throws Exc Arrays.asList(Field.nullablePrimitive("int_column", new ArrowType.Int(32, true)))); final Ticket ticket = new Ticket("partition".getBytes(StandardCharsets.UTF_8)); - try (BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE); - VectorSchemaRoot partition = VectorSchemaRoot.create(schema, allocator); + try (VectorSchemaRoot partition = VectorSchemaRoot.create(schema, allocator); FlightServer dataServer = FlightServer.builder( allocator, - Location.forGrpcTls(CERTIFICATE_HOSTNAME, 0), + Location.forGrpcTls(IP_ADDRESS, 0), new PartitionedFlightSqlProducer.DataOnlyFlightSqlProducer(ticket, partition)) .useTls(certKey.cert, certKey.key) .build() @@ -189,8 +218,7 @@ public void testHostnameOverrideIsAppliedToEndpointOnConnectionHost() throws Exc try (PartitionedFlightSqlProducer rootProducer = new PartitionedFlightSqlProducer(schema, endpoint); FlightServer rootServer = - FlightServer.builder( - allocator, Location.forGrpcTls(CERTIFICATE_HOSTNAME, 0), rootProducer) + FlightServer.builder(allocator, Location.forGrpcTls(IP_ADDRESS, 0), rootProducer) .useTls(certKey.cert, certKey.key) .build() .start(); diff --git a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandlerBuilderTest.java b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandlerBuilderTest.java index 6fb1088c56..15b62e9e23 100644 --- a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandlerBuilderTest.java +++ b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/client/ArrowFlightSqlClientHandlerBuilderTest.java @@ -218,6 +218,33 @@ public void testHostnameOverrideForEndpointOnDifferentHostIsCleared() { builder.getHostnameOverrideForEndpoint(URI.create("grpc+tls://other.example.com:1234"))); } + @Test + public void testHostnameOverrideForEndpointWithoutEncryption() { + final ArrowFlightSqlClientHandler.Builder builder = + new ArrowFlightSqlClientHandler.Builder() + .withHost("127.0.0.1") + .withEncryption(false) + .withHostnameOverride("flight.example.com"); + + assertNull(builder.getHostnameOverrideForEndpoint(URI.create("grpc+tls://127.0.0.1:1234"))); + } + + @Test + public void testBlankHostnameOverrideIsUnset() { + assertNull(new ArrowFlightSqlClientHandler.Builder().withHostnameOverride("").hostnameOverride); + assertNull( + new ArrowFlightSqlClientHandler.Builder().withHostnameOverride(" ").hostnameOverride); + } + + @Test + public void testHostnameOverrideIsTrimmed() { + assertEquals( + "flight.example.com", + new ArrowFlightSqlClientHandler.Builder() + .withHostnameOverride(" flight.example.com ") + .hostnameOverride); + } + @Test public void testHostnameOverrideForEndpointWithoutOverride() { final ArrowFlightSqlClientHandler.Builder builder =