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 1/4] 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[][] { From 3588e30d2f70f828b6b0e0017cc503ae7eb58af2 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:25:14 +0000 Subject: [PATCH 2/4] client-v2: satisfy SonarCloud quality gate for cipher-suite selection Clears the two new-code quality-gate conditions introduced by the cipher-suite change on this branch: - Security rating (java:S5527): scope a @SuppressWarnings on the intentional trust-all HostnameVerifier. It is used only for SSLMode.TRUST/VERIFY_CA (the user explicitly opted out of hostname verification) or a custom SNI; STRICT keeps the default verifying behaviour via a null verifier, so secure-by-default verification is preserved. SonarCloud only flagged it because the restructure moved the pre-existing line into new code. - New-code coverage: add CustomSSLConnectionFactory unit tests that assert the configured cipher suites are forwarded to the base socket factory, that the legacy 3-arg constructor applies no restriction, and the existing SNI handling in prepareSocket. This covers the previously-uncovered legacy constructor. Also adds the missing CHANGELOG entry for the feature. Fixes: https://github.com/ClickHouse/clickhouse-java/issues/2882 --- CHANGELOG.md | 7 ++ .../api/internal/HttpAPIClientHelper.java | 5 + .../api/internal/HttpAPIClientHelperTest.java | 98 ++++++++++++++++++- 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef68d17e6..8adf43a2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ [Release Migration Guide](docs/releases/0_11_0.md) +### New Features + +- **[client-v2, jdbc-v2]** Added TLS cipher suite selection. `Client.Builder.setSSLCipherSuites(String...)` (client-v2) + and the comma-separated `ssl_cipher_suites` connection property (client-v2 and jdbc-v2) restrict the cipher suites + enabled on secure connections; when unset, the JVM defaults are used. Cipher-suite selection is independent of the + trust configuration and `ssl_mode`. (https://github.com/ClickHouse/clickhouse-java/issues/2882) + ### Bug Fixes - **[client-v2]** Fixed container query parameters being sent unquoted, so `Client.query(sql, params, settings)` binding 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 23c64ce63..0302dc190 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 @@ -296,6 +296,10 @@ public CloseableHttpClient createHttpClient(boolean initSslContext, Map true : null; sslConnectionSocketFactory = new CustomSSLConnectionFactory(socketSNI, sslContext, hostnameVerifier, enabledCipherSuites); @@ -1074,6 +1078,7 @@ public static class CustomSSLConnectionFactory extends SSLConnectionSocketFactor private final SNIHostName defaultSNI; + // Retained for backward compatibility; delegates with no cipher-suite restriction (JVM defaults). public CustomSSLConnectionFactory(String defaultSNI, SSLContext sslContext, HostnameVerifier hostnameVerifier) { this(defaultSNI, sslContext, hostnameVerifier, null); } diff --git a/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java b/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java index f03e84af6..869f371c3 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java @@ -1,5 +1,93 @@ -package com.clickhouse.client.api.internal; - -public class HttpAPIClientHelperTest { - -} \ No newline at end of file +package com.clickhouse.client.api.internal; + +import com.clickhouse.client.api.internal.HttpAPIClientHelper.CustomSSLConnectionFactory; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.mockito.ArgumentCaptor; +import org.testng.annotations.Test; + +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SNIServerName; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import java.lang.reflect.Field; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; + +public class HttpAPIClientHelperTest { + + /** + * The configured cipher suites must be forwarded to the base {@link SSLConnectionSocketFactory}, which is + * what enables them on each secure connection. This is the core of the cipher-suite feature. + */ + @Test + public void testCipherSuiteConstructorForwardsSuitesToBaseFactory() throws Exception { + String[] suites = {"TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256"}; + CustomSSLConnectionFactory factory = new CustomSSLConnectionFactory( + null, SSLContext.getDefault(), (hostname, session) -> true, suites); + + assertEquals(baseSupportedCipherSuites(factory), suites, + "configured cipher suites must reach the base socket factory that enables them per connection"); + } + + /** + * The three-argument constructor is retained for backward compatibility and must delegate with no cipher + * restriction, so callers that do not configure cipher suites keep the JVM defaults. + */ + @Test + public void testLegacyConstructorAppliesNoCipherRestriction() throws Exception { + CustomSSLConnectionFactory factory = new CustomSSLConnectionFactory( + "legacy.example.com", SSLContext.getDefault(), (hostname, session) -> true); + + assertNull(baseSupportedCipherSuites(factory), + "the legacy constructor must not restrict cipher suites"); + } + + /** + * A configured SNI host is applied to every prepared socket via the standard SSL parameters. + */ + @Test + public void testConfiguredSniAppliedToPreparedSocket() throws Exception { + CustomSSLConnectionFactory factory = new CustomSSLConnectionFactory( + "sni.example.com", SSLContext.getDefault(), (hostname, session) -> true, null); + + SSLSocket socket = mock(SSLSocket.class); + when(socket.getSSLParameters()).thenReturn(new SSLParameters()); + + factory.prepareSocket(socket, null); + + ArgumentCaptor params = ArgumentCaptor.forClass(SSLParameters.class); + verify(socket).setSSLParameters(params.capture()); + List serverNames = params.getValue().getServerNames(); + assertEquals(serverNames.size(), 1, "the configured SNI host must be applied to the socket"); + assertEquals(((SNIHostName) serverNames.get(0)).getAsciiName(), "sni.example.com"); + } + + /** + * A blank SNI is treated as unset: the socket's SSL parameters must be left untouched so the defaults + * (and any cipher suites the base factory already applied) are preserved. + */ + @Test + public void testBlankSniLeavesSocketParametersUntouched() throws Exception { + CustomSSLConnectionFactory factory = new CustomSSLConnectionFactory( + " ", SSLContext.getDefault(), (hostname, session) -> true, null); + + SSLSocket socket = mock(SSLSocket.class); + factory.prepareSocket(socket, null); + + verify(socket, never()).setSSLParameters(any()); + } + + private static String[] baseSupportedCipherSuites(CustomSSLConnectionFactory factory) throws Exception { + Field field = SSLConnectionSocketFactory.class.getDeclaredField("supportedCipherSuites"); + field.setAccessible(true); + return (String[]) field.get(factory); + } +} From d17cf3d8f6f3dbd944fe6d5403954f04e63e9705 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:25:10 +0000 Subject: [PATCH 3/4] client-v2: cover SSL cipher-suite factory selection to satisfy coverage gate Adds focused unit tests for HttpAPIClientHelper.createHttpClient's TLS socket-factory selection, which was the uncovered new code behind the SonarCloud "coverage on new code" quality-gate failure (73% < 80%). The tests drive the real createHttpClient SSL branch (via Mockito mockConstruction) and pin the factory wiring for each mode: STRICT + cipher suites forwards the suites and keeps hostname verification (null verifier); TRUST and VERIFY_CA install the permissive verifier; a custom SNI installs the permissive verifier and passes the SNI through; SNI + cipher suites combine; and the default STRICT path plus an empty cipher list keep the plain verifying SSLConnectionSocketFactory (no restriction). Test-only change; no existing tests modified. New-code coverage for the cipher-suite change is now 100%. --- .../api/internal/HttpAPIClientHelperTest.java | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java b/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java index 869f371c3..8257d0dde 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java @@ -1,8 +1,12 @@ package com.clickhouse.client.api.internal; +import com.clickhouse.client.api.ClientConfigProperties; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.internal.HttpAPIClientHelper.CustomSSLConnectionFactory; +import net.jpountz.lz4.LZ4Factory; import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; import org.mockito.ArgumentCaptor; +import org.mockito.MockedConstruction; import org.testng.annotations.Test; import javax.net.ssl.SNIHostName; @@ -11,14 +15,21 @@ import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSocket; import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertNull; public class HttpAPIClientHelperTest { @@ -85,6 +96,148 @@ public void testBlankSniLeavesSocketParametersUntouched() throws Exception { verify(socket, never()).setSSLParameters(any()); } + /** + * When cipher suites are configured in STRICT mode (no SNI), {@code createHttpClient} must build the + * cipher-aware {@link CustomSSLConnectionFactory}, forward the configured suites to it, and pass a + * {@code null} hostname verifier so the base factory keeps its default (verifying) behaviour - i.e. + * restricting cipher suites must not silently disable hostname verification. + */ + @Test + public void testCreateHttpClientStrictWithCipherSuitesForwardsSuitesAndKeepsHostnameVerification() { + Map config = new HashMap<>(); + config.put(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(), + Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256")); + + List> calls = captureCustomFactoryConstruction(config); + + assertEquals(calls.size(), 1, "STRICT + cipher suites must build the cipher-aware custom factory"); + List args = calls.get(0); // (socketSNI, sslContext, hostnameVerifier, enabledCipherSuites) + assertNull(args.get(2), "STRICT must keep default hostname verification: the verifier passed to the " + + "factory must be null so the base factory verifies hostnames"); + assertEquals((String[]) args.get(3), new String[]{"TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256"}, + "the configured cipher suites must be forwarded to the connection socket factory"); + } + + /** + * TRUST mode opts out of hostname verification, so the factory must receive a permissive (non-null) + * verifier; with no cipher suites configured the factory must keep the JVM defaults (no restriction). + */ + @Test + public void testCreateHttpClientTrustModeInstallsPermissiveVerifierWithoutCipherRestriction() { + Map config = new HashMap<>(); + config.put(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.TRUST); + + List> calls = captureCustomFactoryConstruction(config); + + assertEquals(calls.size(), 1, "TRUST mode must build the custom factory to skip hostname verification"); + List args = calls.get(0); + assertNotNull(args.get(2), "TRUST mode must install a permissive hostname verifier"); + assertNull(args.get(3), "TRUST mode without configured cipher suites must not restrict cipher suites"); + } + + /** + * A custom SNI host will not match the server certificate, so hostname verification is skipped (a + * permissive verifier is installed) and the configured SNI is passed through to the factory. + */ + @Test + public void testCreateHttpClientCustomSniInstallsPermissiveVerifier() { + Map config = new HashMap<>(); + config.put(ClientConfigProperties.SSL_SOCKET_SNI.getKey(), "sni.example.com"); + + List> calls = captureCustomFactoryConstruction(config); + + assertEquals(calls.size(), 1, "a custom SNI must build the custom factory"); + List args = calls.get(0); + assertEquals(args.get(0), "sni.example.com", "the configured SNI must be passed to the factory"); + assertNotNull(args.get(2), "a custom SNI host won't match the certificate, so a permissive verifier " + + "is installed"); + } + + /** + * Contrast case: the default STRICT path with no SNI and no cipher restriction must be unchanged - it + * must NOT build the custom factory, so the plain verifying {@link SSLConnectionSocketFactory} is used. + */ + @Test + public void testCreateHttpClientStrictWithoutSniOrCiphersUsesPlainFactory() { + List> calls = captureCustomFactoryConstruction(new HashMap<>()); + + assertEquals(calls.size(), 0, "default STRICT with no SNI and no cipher suites must keep using the " + + "plain SSLConnectionSocketFactory (unchanged behaviour)"); + } + + /** + * VERIFY_CA validates the certificate chain but skips hostname verification (the connection hostname may + * legitimately differ), so - like TRUST - it must install a permissive verifier. This pins the second + * mode that opts out of hostname verification, distinct from the TRUST path. + */ + @Test + public void testCreateHttpClientVerifyCaModeInstallsPermissiveVerifier() { + Map config = new HashMap<>(); + config.put(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.VERIFY_CA); + + List> calls = captureCustomFactoryConstruction(config); + + assertEquals(calls.size(), 1, "VERIFY_CA must build the custom factory to skip hostname verification"); + List args = calls.get(0); + assertNotNull(args.get(2), "VERIFY_CA must install a permissive hostname verifier"); + assertNull(args.get(3), "VERIFY_CA without configured cipher suites must not restrict cipher suites"); + } + + /** + * SNI and cipher suites are independent concerns and must combine: a custom SNI still installs the + * permissive verifier while the configured cipher suites are forwarded to the same factory - i.e. cipher + * forwarding is not tied to the STRICT/cipher-triggered path. + */ + @Test + public void testCreateHttpClientSniAndCipherSuitesCombine() { + Map config = new HashMap<>(); + config.put(ClientConfigProperties.SSL_SOCKET_SNI.getKey(), "sni.example.com"); + config.put(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(), + Arrays.asList("TLS_AES_256_GCM_SHA384")); + + List> calls = captureCustomFactoryConstruction(config); + + assertEquals(calls.size(), 1, "SNI + cipher suites must build the custom factory"); + List args = calls.get(0); + assertEquals(args.get(0), "sni.example.com", "the configured SNI must be passed to the factory"); + assertNotNull(args.get(2), "a custom SNI installs a permissive verifier even when cipher suites are set"); + assertEquals((String[]) args.get(3), new String[]{"TLS_AES_256_GCM_SHA384"}, + "the configured cipher suites must still be forwarded when SNI is also set"); + } + + /** + * Boundary case: an empty cipher-suite list must be treated as "no restriction" (JVM defaults), exactly + * like an unset value - it must NOT be turned into an empty cipher array, which would enable zero suites + * and make every handshake fail. STRICT with no SNI therefore keeps using the plain factory. + */ + @Test + public void testCreateHttpClientEmptyCipherSuitesTreatedAsNoRestriction() { + Map config = new HashMap<>(); + config.put(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(), Collections.emptyList()); + + List> calls = captureCustomFactoryConstruction(config); + + assertEquals(calls.size(), 0, "an empty cipher-suite list must be treated as no restriction (JVM " + + "defaults), so the plain SSLConnectionSocketFactory is used"); + } + + /** + * Builds an {@link HttpAPIClientHelper} and invokes {@link HttpAPIClientHelper#createHttpClient} with SSL + * enabled while intercepting every {@link CustomSSLConnectionFactory} construction, returning the + * constructor arguments of each construction (empty when the plain factory branch is taken instead). + */ + private static List> captureCustomFactoryConstruction(Map sslConfig) { + HttpAPIClientHelper helper = new HttpAPIClientHelper(new HashMap<>(), null, false, + LZ4Factory.fastestJavaInstance()); + List> constructorArgs = new ArrayList<>(); + try (MockedConstruction mocked = mockConstruction( + CustomSSLConnectionFactory.class, + (mock, context) -> constructorArgs.add(context.arguments()))) { + helper.createHttpClient(true, sslConfig); + } + return constructorArgs; + } + private static String[] baseSupportedCipherSuites(CustomSSLConnectionFactory factory) throws Exception { Field field = SSLConnectionSocketFactory.class.getDeclaredField("supportedCipherSuites"); field.setAccessible(true); From 675b8f5965dc8ccd3a1580cc72d52f0f86a1506b Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:46:52 +0000 Subject: [PATCH 4/4] client-v2: drop blank tokens from ssl_cipher_suites before enabling them A leading, trailing or doubled comma in ssl_cipher_suites (or a raw List option) leaves an empty or whitespace-only token in the parsed list. The value was only checked for emptiness (zero elements), so a blank token reached SSLSocket#setEnabledCipherSuites, which throws IllegalArgumentException ("invalid null or empty string elements") and breaks the TLS handshake even when valid suites are also present. Blank tokens are now dropped and each surviving name is trimmed; a list that reduces to nothing is treated as "no restriction" (JVM defaults), matching the empty/unset behaviour. Addresses the Cursor Bugbot review comment on PR #2919. --- .../api/internal/HttpAPIClientHelper.java | 13 ++++++- .../api/internal/HttpAPIClientHelperTest.java | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) 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 0302dc190..4cfacad33 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 @@ -290,8 +290,17 @@ public CloseableHttpClient createHttpClient(boolean initSslContext, Map cipherSuites = ClientConfigProperties.SSL_CIPHER_SUITES.getOrDefault(configuration); - String[] enabledCipherSuites = cipherSuites == null || cipherSuites.isEmpty() - ? null : cipherSuites.toArray(new String[0]); + // Drop blank tokens (e.g. from a leading, trailing or doubled comma in ssl_cipher_suites) and trim + // each name: a null, empty or whitespace-only entry is rejected by the SSL socket and breaks the + // handshake even when valid suites are also present. + String[] enabledCipherSuites = cipherSuites == null ? null + : cipherSuites.stream() + .filter(s -> s != null && !s.trim().isEmpty()) + .map(String::trim) + .toArray(String[]::new); + if (enabledCipherSuites != null && enabledCipherSuites.length == 0) { + enabledCipherSuites = null; + } 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 diff --git a/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java b/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java index 8257d0dde..2fb0fffe4 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java @@ -221,6 +221,45 @@ public void testCreateHttpClientEmptyCipherSuitesTreatedAsNoRestriction() { + "defaults), so the plain SSLConnectionSocketFactory is used"); } + /** + * Malformed cipher-suite input - blank tokens from a leading, trailing or doubled comma, and + * whitespace-padded names - must be sanitized before reaching the SSL socket: a null, empty or + * whitespace-only entry is rejected by {@code SSLSocket#setEnabledCipherSuites} ("invalid null or empty + * string elements") and breaks the handshake even when valid suites are also present. The blanks must be + * dropped and the surviving names trimmed, preserving order. + */ + @Test + public void testCreateHttpClientDropsBlankAndWhitespaceCipherTokens() { + Map config = new HashMap<>(); + config.put(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(), + Arrays.asList("", "TLS_AES_256_GCM_SHA384", "", " ", " TLS_AES_128_GCM_SHA256 ")); + + List> calls = captureCustomFactoryConstruction(config); + + assertEquals(calls.size(), 1, "configured (non-blank) cipher suites must still build the custom factory"); + List args = calls.get(0); + assertEquals((String[]) args.get(3), + new String[]{"TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256"}, + "blank tokens (leading/doubled comma, whitespace-only) must be dropped and surviving names " + + "trimmed before being forwarded to the SSL socket factory"); + } + + /** + * Boundary case: a cipher-suite list that contains only blank tokens (e.g. from a property that is just + * commas/whitespace) has no usable suite, so - like an empty or unset list - it must be treated as "no + * restriction" (JVM defaults) rather than forwarding an all-blank array that would fail every handshake. + */ + @Test + public void testCreateHttpClientBlankOnlyCipherSuitesTreatedAsNoRestriction() { + Map config = new HashMap<>(); + config.put(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(), Arrays.asList("", " ")); + + List> calls = captureCustomFactoryConstruction(config); + + assertEquals(calls.size(), 0, "a cipher-suite list of only blank tokens must be treated as no " + + "restriction (JVM defaults), so the plain SSLConnectionSocketFactory is used"); + } + /** * Builds an {@link HttpAPIClientHelper} and invokes {@link HttpAPIClientHelper#createHttpClient} with SSL * enabled while intercepting every {@link CustomSSLConnectionFactory} construction, returning the