diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7da98542..11484d6df 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 transport 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 POJO insert error classification so transport write failures such as java.net.SocketException:
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 86431872b..16797d058 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
@@ -60,6 +60,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,22 @@ 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 transport defaults are used (Apache HttpClient enables the JVM's
+ * default suites minus those it considers weak). 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..292ce2e7f 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
@@ -204,6 +204,17 @@ public Object parseValue(String value) {
* See ClickHouse Docs
*/
CUSTOM_SETTINGS_PREFIX("custom_settings_prefix", String.class, "custom_"),
+
+ /**
+ * 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 transport defaults are used (Apache HttpClient enables the JVM's default
+ * suites minus those it considers weak).
+ *
+ * Appended at the end of the enum on purpose: adding a constant in the middle would shift the ordinal
+ * of every following constant (see {@code docs/changes_checklist.md}).
+ */
+ SSL_CIPHER_SUITES("ssl_cipher_suites", List.class),
;
private static final Logger LOG = LoggerFactory.getLogger(ClientConfigProperties.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 ebefee9f3..8df0d1f52 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,30 @@ public CloseableHttpClient createHttpClient(boolean initSslContext, Map true);
+ boolean hasSNI = socketSNI != null && !socketSNI.trim().isEmpty();
+ List cipherSuites = ClientConfigProperties.SSL_CIPHER_SUITES.getOrDefault(configuration);
+ // 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
+ // factory fall back to the JDK/HttpClient default verifier, keeping STRICT verification.
+ // java:S5527 - the permissive verifier is applied only for SSLMode.TRUST/VERIFY_CA (where the
+ // user has explicitly opted out of hostname verification) or a custom SNI; STRICT keeps the
+ // default verifying behaviour, so secure-by-default hostname verification is preserved.
+ @SuppressWarnings("java:S5527")
+ HostnameVerifier hostnameVerifier = trustAllHostnames || hasSNI ? (hostname, session) -> true : null;
+ sslConnectionSocketFactory = new CustomSSLConnectionFactory(socketSNI, sslContext, hostnameVerifier,
+ enabledCipherSuites);
} else {
sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext);
}
@@ -1068,8 +1090,17 @@ public static class CustomSSLConnectionFactory extends SSLConnectionSocketFactor
private final SNIHostName defaultSNI;
+ // Retained for backward compatibility; delegates with no cipher-suite restriction (transport defaults).
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 (transport defaults apply); 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/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 90f3be551..aefdd0b56 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,25 +1,272 @@
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 com.clickhouse.client.api.transport.Endpoint;
import com.clickhouse.client.api.transport.HttpEndpoint;
import net.jpountz.lz4.LZ4Factory;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.HttpEntity;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedConstruction;
import org.testng.Assert;
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.net.ConnectException;
+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 {
+ /**
+ * 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 transport 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());
+ }
+
+ /**
+ * 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 transport 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" (transport 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 "
+ + "(transport 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" (transport 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 (transport defaults), so the plain SSLConnectionSocketFactory is used");
+ }
+
@Test
public void testExecuteRequestThrowsConnectExceptionOn502() throws Exception {
Map configuration = new HashMap<>();
@@ -75,4 +322,27 @@ public void testExecuteRequestThrowsConnectExceptionOn503() throws Exception {
Assert.fail("Expected ConnectException to be thrown, but got: " + e.getClass().getName(), e);
}
}
-}
\ No newline at end of file
+
+ /**
+ * 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);
+ return (String[]) field.get(factory);
+ }
+}
diff --git a/docs/features.md b/docs/features.md
index 11a0e4ac4..d8aa35721 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 transport defaults apply (Apache HttpClient enables the JVM's default suites minus those it considers weak). 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..266490757 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 transport 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 transport 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..ae56d6007 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 transport 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 transport 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[][] {