From 1d53750d391c7a856db582318e41703e62364513 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:17:44 +0000 Subject: [PATCH] Support TLS cipher suite selection in client-v2 and jdbc-v2 Neither client could restrict the negotiated TLS cipher suites (#2882): the SSL configuration exposed trust store, CA cert, client cert/key, SNI and mode, but nothing for cipher suites, so applications could not enforce a stronger or compliance-mandated set. client-v2: - New ClientConfigProperties.SSL_CIPHER_SUITES ("ssl_cipher_suites"), a comma-separated list parsed into a List. - Client.Builder.setSSLCipherSuites(String...) to set it programmatically. - HttpAPIClientHelper applies the configured suites to the SSL socket via the connection socket factory. CustomSSLConnectionFactory gains a constructor that passes supportedCipherSuites to the base SSLConnectionSocketFactory. Existing hostname-verification behavior is unchanged: verification is skipped only for TRUST/VERIFY_CA or when a custom SNI is set, and the default verifier is kept otherwise (so STRICT + cipher suites still verifies the hostname). jdbc-v2 needs no code change: ssl_cipher_suites is forwarded to client-v2 as a regular string property (via URL or Properties). Tests: client-v2 ClientBuilderTest (builder + comma-separated string parse to a list, and an HTTPS client builds with cipher suites configured) and jdbc-v2 JdbcConfigurationTest (ssl_cipher_suites forwarded via Properties and URL). Examples (client-v2 and jdbc SSLExamples) and docs/features.md updated. Fixes: https://github.com/ClickHouse/clickhouse-java/issues/2882 Co-Authored-By: Claude Opus 4.7 --- .../com/clickhouse/client/api/Client.java | 16 ++++++ .../client/api/ClientConfigProperties.java | 7 +++ .../api/internal/HttpAPIClientHelper.java | 23 ++++++-- .../client/api/ClientBuilderTest.java | 53 +++++++++++++++++++ docs/features.md | 3 +- .../examples/client_v2/SSLExamples.java | 38 +++++++++++++ .../clickhouse/examples/jdbc/SSLExamples.java | 39 ++++++++++++++ .../jdbc/internal/JdbcConfigurationTest.java | 18 +++++++ 8 files changed, 193 insertions(+), 4 deletions(-) diff --git a/client-v2/src/main/java/com/clickhouse/client/api/Client.java b/client-v2/src/main/java/com/clickhouse/client/api/Client.java index 3bf94c529..b416e2782 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/Client.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/Client.java @@ -59,6 +59,7 @@ import java.time.ZoneId; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -785,6 +786,21 @@ public Builder setSSLMode(SSLMode sslMode) { return this; } + /** + * Restricts the TLS cipher suites the client may negotiate on secure connections. When set, only + * the listed cipher suites are enabled on the SSL socket (subject to what the JVM and the server + * support); when not set, the JVM defaults are used. Suite names use the standard JSSE names, for + * example {@code TLS_AES_256_GCM_SHA384}. + * + * @param cipherSuites cipher suite names to enable + * @return same instance of the builder + */ + public Builder setSSLCipherSuites(String... cipherSuites) { + this.configuration.put(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(), + ClientConfigProperties.commaSeparated(Arrays.asList(cipherSuites))); + return this; + } + /** * Configure client to use server timezone for date/datetime columns. Default is true. * If this options is selected then server timezone should be set as well. diff --git a/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java b/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java index 9a38232ba..cd20dc3a9 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java @@ -118,6 +118,13 @@ public enum ClientConfigProperties { SSL_MODE("ssl_mode", SSLMode.class, SSLMode.STRICT.name()), + /** + * Comma-separated list of TLS cipher suites the client is allowed to negotiate on secure connections. + * When set, only these cipher suites are enabled on the SSL socket (subject to what the JVM and server + * support); when unset, the JVM defaults are used. + */ + SSL_CIPHER_SUITES("ssl_cipher_suites", List.class), + RETRY_ON_FAILURE("retry", Integer.class, "3"), INPUT_OUTPUT_FORMAT("format", ClickHouseFormat.class), diff --git a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java index ab4b0153c..23c64ce63 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java @@ -288,8 +288,17 @@ public CloseableHttpClient createHttpClient(boolean initSslContext, Map true); + boolean hasSNI = socketSNI != null && !socketSNI.trim().isEmpty(); + List cipherSuites = ClientConfigProperties.SSL_CIPHER_SUITES.getOrDefault(configuration); + String[] enabledCipherSuites = cipherSuites == null || cipherSuites.isEmpty() + ? null : cipherSuites.toArray(new String[0]); + if (hasSNI || trustAllHostnames || enabledCipherSuites != null) { + // Skip hostname verification only for trust-all modes or when a custom SNI is used (the + // connection hostname would not match the certificate); otherwise a null verifier makes the + // factory fall back to the JDK/HttpClient default verifier, keeping STRICT verification. + HostnameVerifier hostnameVerifier = trustAllHostnames || hasSNI ? (hostname, session) -> true : null; + sslConnectionSocketFactory = new CustomSSLConnectionFactory(socketSNI, sslContext, hostnameVerifier, + enabledCipherSuites); } else { sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext); } @@ -1066,7 +1075,15 @@ public static class CustomSSLConnectionFactory extends SSLConnectionSocketFactor private final SNIHostName defaultSNI; public CustomSSLConnectionFactory(String defaultSNI, SSLContext sslContext, HostnameVerifier hostnameVerifier) { - super(sslContext, hostnameVerifier); + this(defaultSNI, sslContext, hostnameVerifier, null); + } + + public CustomSSLConnectionFactory(String defaultSNI, SSLContext sslContext, HostnameVerifier hostnameVerifier, + String[] supportedCipherSuites) { + // supportedProtocols is left as null (JDK defaults are used); supportedCipherSuites, when + // provided, restricts the cipher suites the base factory enables on each socket. A null + // hostnameVerifier makes the base factory fall back to its default verifier. + super(sslContext, null, supportedCipherSuites, hostnameVerifier); this.defaultSNI = defaultSNI == null || defaultSNI.trim().isEmpty() ? null : new SNIHostName(defaultSNI); } diff --git a/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java b/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java index baccdc767..7ae0bad37 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java @@ -6,7 +6,9 @@ import org.testng.annotations.Test; import java.lang.reflect.Field; +import java.util.Arrays; import java.util.List; +import java.util.Map; public class ClientBuilderTest { @@ -86,6 +88,57 @@ public void testSslModeInvalidValueRejected() { .build()); } + @Test + public void testSetSSLCipherSuitesStoredInConfiguration() throws Exception { + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .setSSLCipherSuites("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256") + .build()) { + Assert.assertEquals(extractConfiguration(client).get(ClientConfigProperties.SSL_CIPHER_SUITES.getKey()), + Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256"), + "Cipher suites set via the builder should be stored as a parsed list"); + } + } + + @Test + public void testSSLCipherSuitesViaSetOptionParsedAsList() throws Exception { + // The comma-separated string form is the path used by URL/JDBC properties. + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .setOption(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(), + "TLS_AES_256_GCM_SHA384,TLS_AES_128_GCM_SHA256") + .build()) { + Assert.assertEquals(extractConfiguration(client).get(ClientConfigProperties.SSL_CIPHER_SUITES.getKey()), + Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256"), + "Comma-separated cipher suites should be parsed into a list"); + } + } + + @Test + public void testClientBuildsWithCipherSuitesOverHttps() { + // Exercises the HTTPS connection-socket-factory path with cipher suites configured (STRICT mode, + // no SNI): the client must build without error. + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .setSSLCipherSuites("TLS_AES_256_GCM_SHA384") + .build()) { + Assert.assertNotNull(client); + } + } + + @SuppressWarnings("unchecked") + private static Map extractConfiguration(Client client) throws Exception { + Field configField = Client.class.getDeclaredField("configuration"); + configField.setAccessible(true); + return (Map) configField.get(client); + } + private static String extractFirstEndpointUri(Client client) throws Exception { Field endpointsField = Client.class.getDeclaredField("endpoints"); endpointsField.setAccessible(true); diff --git a/docs/features.md b/docs/features.md index 11a0e4ac4..5969cafcb 100644 --- a/docs/features.md +++ b/docs/features.md @@ -7,6 +7,7 @@ This document lists stable, user-visible behavior in `client-v2` and `jdbc-v2` t - HTTP and HTTPS connectivity: Connects to ClickHouse over HTTP(S), supports endpoint paths, and exposes a basic `ping` health check. - TLS configuration: Supports trust stores, client certificates/keys, SSL certificate authentication, and SNI for HTTPS connections. Trust material (root CA and client certificate/key) can be supplied either as a file path or directly as PEM content. - SSL verification modes: `Client.Builder.setSSLMode(SSLMode)` (or the `ssl_mode` property) controls how strictly the server identity is verified on secure connections: `DISABLED` (SSL not used; plain protocols only), `TRUST` (accept any server certificate and skip hostname verification; a configured trust store or CA certificate is ignored with a warning, while a client certificate/key is still applied for mTLS if configured), `VERIFY_CA` (validate the certificate chain but skip hostname verification), and `STRICT` (full chain and hostname verification, default). +- TLS cipher suite selection: `Client.Builder.setSSLCipherSuites(String...)` (or the comma-separated `ssl_cipher_suites` property) restricts the cipher suites enabled on secure connections. When set, only the listed suites are enabled on the SSL socket (subject to JVM and server support); when unset, the JVM defaults apply. Cipher-suite selection is independent of the trust configuration and `ssl_mode`. - Authentication modes: Supports username/password credentials, ClickHouse auth headers, bearer tokens, and optional HTTP Basic authentication. - Runtime credential updates: Existing `Client` instances can update username/password or bearer-token credentials for subsequent requests without rebuilding the client. - Proxy support: Can send requests through configured HTTP proxies, including proxy credentials. @@ -53,7 +54,7 @@ Compatibility-sensitive traits: - JDBC driver registration: Registers through the standard JDBC service mechanism and is available through `DriverManager`. - JDBC URL parsing: Accepts `jdbc:clickhouse:` and `jdbc:ch:` URLs with host, port, optional HTTP path, optional database, and query parameters. -- SSL URL support: Supports HTTPS connections through URL and property configuration, including default protocol and port handling. The `ssl_mode` property selects the verification strictness (`disabled`, `trust`, `verify_ca`, `strict`); values are case-insensitive and the traditional JDBC value `none` is accepted as an alias for `trust`. Root CA and client certificate/key may be supplied as a file path or as inline PEM content. +- SSL URL support: Supports HTTPS connections through URL and property configuration, including default protocol and port handling. The `ssl_mode` property selects the verification strictness (`disabled`, `trust`, `verify_ca`, `strict`); values are case-insensitive and the traditional JDBC value `none` is accepted as an alias for `trust`. Root CA and client certificate/key may be supplied as a file path or as inline PEM content. The `ssl_cipher_suites` property (a comma-separated list) restricts the negotiated TLS cipher suites and is forwarded to the underlying `client-v2` transport. - Driver and client properties: Separates JDBC-specific properties from passthrough client options used by the underlying `client-v2` transport. - DataSource support: Provides a JDBC `DataSource` implementation backed by the same driver configuration model. - Connection lifecycle: Supports connection close, validity checks, ping-based health checks, and network timeout management. diff --git a/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java b/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java index 520b82a03..d037e61c0 100644 --- a/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java +++ b/examples/client-v2/src/main/java/com/clickhouse/examples/client_v2/SSLExamples.java @@ -26,6 +26,9 @@ *
  • Connecting to a server with a self-signed certificate without any trust material - * {@link SSLMode#TRUST} accepts any server certificate and skips hostname verification. * Use it only for testing or in fully trusted environments.
  • + *
  • Restricting the negotiated TLS cipher suites with + * {@link Client.Builder#setSSLCipherSuites(String...)} - useful to enforce a stronger or + * compliance-mandated set of cipher suites instead of the JVM defaults.
  • * * *

    More SSL examples (mTLS, trust stores, SNI) will be added to this class later.

    @@ -70,6 +73,7 @@ public static void main(String[] args) { if (rootCert != null) { connectWithCustomRootCertificate(endpoint, database, user, password, rootCert); connectWithRootCertificateAsString(endpoint, database, user, password, rootCert); + connectWithCipherSuites(endpoint, database, user, password, rootCert); } else { log.info("chRootCert is not set - skipping the custom CA certificate examples. " + "Pass the path to the CA certificate (PEM) that signed the server certificate to run them."); @@ -88,6 +92,8 @@ public static void main(String[] args) { SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); connectWithRootCertificateAsString(server.getEndpoint(), database, SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); + connectWithCipherSuites(server.getEndpoint(), database, + SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); } catch (Exception e) { log.error("Failed to run the SSL example against a local Docker server", e); } @@ -192,6 +198,38 @@ static void connectWithRootCertificateAsString(String endpoint, String database, } } + /** + * Connects while restricting the TLS cipher suites the client is allowed to negotiate, using + * {@link Client.Builder#setSSLCipherSuites(String...)}. Only the listed suites are enabled on the + * socket (subject to what the JVM and the server support); this is useful to enforce a stronger or + * compliance-mandated set of cipher suites rather than relying on the JVM defaults. + * + *

    The CA certificate is still used to verify the server, and hostname verification stays enabled - + * cipher-suite selection is independent of the trust configuration and the SSL mode. The suites below + * cover TLS 1.3 and TLS 1.2; keep at least one suite the server actually supports, or the handshake + * fails.

    + */ + static void connectWithCipherSuites(String endpoint, String database, String user, String password, + String rootCert) { + log.info("Connecting to {} with a restricted set of TLS cipher suites", endpoint); + try (Client client = new Client.Builder() + .addEndpoint(endpoint) + .setUsername(user) + .setPassword(password) + .setDefaultDatabase(database) + .setRootCertificate(rootCert) + // Restrict negotiation to these cipher suites (TLS 1.3 and TLS 1.2). + .setSSLCipherSuites("TLS_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384") + .build()) { + + List rows = client.queryAll("SELECT currentUser() AS user, version() AS version"); + log.info("Connected securely (restricted cipher suites) as '{}' to ClickHouse {}", + rows.get(0).getString("user"), rows.get(0).getString("version")); + } catch (Exception e) { + log.error("Secure connection with restricted cipher suites failed", e); + } + } + private static String trimToNull(String value) { if (value == null) { return null; diff --git a/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java b/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java index f6a1cef1f..54c1758bb 100644 --- a/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java +++ b/examples/jdbc/src/main/java/com/clickhouse/examples/jdbc/SSLExamples.java @@ -31,6 +31,9 @@ * the {@code ssl_mode=trust} connection property accepts any server certificate and skips * hostname verification ({@code ssl_mode=none} is accepted as an alias). Use it only for * testing or in fully trusted environments. + *
  • Restricting the negotiated TLS cipher suites with the {@code ssl_cipher_suites} connection + * property (a comma-separated list) - useful to enforce a stronger or compliance-mandated set of + * cipher suites instead of the JVM defaults.
  • * * *

    More SSL examples (mTLS, trust stores, SNI) will be added to this class later.

    @@ -72,6 +75,7 @@ public static void main(String[] args) { if (rootCert != null) { connectWithCustomRootCertificate(url, user, password, rootCert); connectWithRootCertificateAsString(url, user, password, rootCert); + connectWithCipherSuites(url, user, password, rootCert); } else { log.info("chRootCert is not set - skipping the custom CA certificate examples. " + "Pass the path to the CA certificate (PEM) that signed the server certificate to run them."); @@ -93,6 +97,8 @@ public static void main(String[] args) { SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); connectWithRootCertificateAsString(server.getJdbcUrl(), SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); + connectWithCipherSuites(server.getJdbcUrl(), + SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); } catch (Exception e) { log.error("Failed to run the SSL example against a local Docker server", e); Runtime.getRuntime().exit(-1); @@ -196,6 +202,39 @@ static void connectWithRootCertificateAsString(String url, String user, String p } } + /** + * Connects while restricting the TLS cipher suites the driver is allowed to negotiate, using the + * {@code ssl_cipher_suites} connection property (a comma-separated list). Only the listed suites are + * enabled on the socket (subject to what the JVM and the server support); this is useful to enforce a + * stronger or compliance-mandated set of cipher suites rather than relying on the JVM defaults. + * + *

    The CA certificate is still used to verify the server and hostname verification stays enabled - + * cipher-suite selection is independent of the trust configuration and {@code ssl_mode}. Keep at least + * one suite the server actually supports, or the handshake fails.

    + */ + static void connectWithCipherSuites(String url, String user, String password, String rootCert) + throws SQLException { + log.info("Connecting to {} with a restricted set of TLS cipher suites", url); + + Properties properties = new Properties(); + properties.setProperty(ClientConfigProperties.USER.getKey(), user); // user + properties.setProperty(ClientConfigProperties.PASSWORD.getKey(), password); // password + properties.setProperty("ssl", "true"); // enable TLS even if the URL has no https scheme + properties.setProperty(ClientConfigProperties.CA_CERTIFICATE.getKey(), rootCert); // sslrootcert + // Restrict negotiation to these cipher suites (TLS 1.3 and TLS 1.2), comma-separated. + properties.setProperty(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(), + "TLS_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"); // ssl_cipher_suites + + try (Connection connection = DriverManager.getConnection(url, properties); + Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT currentUser() AS user, version() AS version")) { + if (rs.next()) { + log.info("Connected securely (restricted cipher suites) as '{}' to ClickHouse {}", + rs.getString("user"), rs.getString("version")); + } + } + } + private static String trimToNull(String value) { if (value == null) { return null; diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcConfigurationTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcConfigurationTest.java index e328bc398..a53e711f7 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcConfigurationTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/internal/JdbcConfigurationTest.java @@ -221,6 +221,24 @@ public void testSSLModeInvalidValue() { () -> new JdbcConfiguration("jdbc:clickhouse://localhost:8123/?ssl_mode=insecure", new Properties())); } + @Test + public void testSSLCipherSuitesForwardedToClientProperties() throws Exception { + String cipherSuites = "TLS_AES_256_GCM_SHA384,TLS_AES_128_GCM_SHA256"; + + // passed via Properties + Properties properties = new Properties(); + properties.setProperty(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(), cipherSuites); + JdbcConfiguration configuration = new JdbcConfiguration("jdbc:clickhouse://localhost:8123/", properties); + assertEquals(configuration.getClientProperties().get(ClientConfigProperties.SSL_CIPHER_SUITES.getKey()), + cipherSuites); + + // passed as a URL parameter + configuration = new JdbcConfiguration( + "jdbc:clickhouse://localhost:8123/?ssl_cipher_suites=" + cipherSuites, new Properties()); + assertEquals(configuration.getClientProperties().get(ClientConfigProperties.SSL_CIPHER_SUITES.getKey()), + cipherSuites); + } + @DataProvider(name = "typeMappingsPropertyKey") public Object[][] typeMappingsPropertyKey() { return new Object[][] {