diff --git a/CHANGELOG.md b/CHANGELOG.md
index ebd7bba1f..580fafbc6 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 binary array decoding for nullable element types so `Array(Nullable(Float64))` and similar columns now return boxed arrays such as `Double[]` instead of `Object[]`. This keeps null-supporting arrays aligned with their element type while preserving the existing `Object[]` fallback for Variant/Dynamic/Geometry arrays. (https://github.com/ClickHouse/clickhouse-java/issues/2846)
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 2ecd0bd95..0e8398459 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;
@@ -808,6 +809,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;
+ }
+
/**
* Supplies a pre-built {@link SSLContext}. When set, it is used as is instead of a context built
* from the configured trust/key material (which then cannot be set alongside it). {@link SSLMode}
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 8281654cf..cf3408905 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
@@ -208,6 +208,35 @@ 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).
+ *
+ * The value is parsed into a sanitized list here, in the config-parsing layer (not in transport code):
+ * blank tokens produced by a leading, trailing or doubled comma are dropped and each surviving name is
+ * trimmed, so consumers receive a ready-to-use list. A null, empty or whitespace-only entry would
+ * otherwise be rejected by the SSL socket and break the handshake even alongside valid suites.
+ *
+ * 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) {
+ @Override
+ @SuppressWarnings("unchecked")
+ public Object parseValue(String value) {
+ List suites = (List) super.parseValue(value);
+ if (suites == null) {
+ return null;
+ }
+ return suites.stream()
+ .filter(s -> s != null && !s.trim().isEmpty())
+ .map(String::trim)
+ .collect(Collectors.toList());
+ }
+ },
;
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 1074a0cf8..762d4dd4e 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
@@ -302,8 +302,23 @@ public CloseableHttpClient createHttpClient(boolean initSslContext, Map true);
+ boolean hasSNI = socketSNI != null && !socketSNI.trim().isEmpty();
+ // ssl_cipher_suites is parsed and sanitized in ClientConfigProperties (blank tokens dropped,
+ // names trimmed); an unset or empty list means "no restriction" so the transport defaults apply.
+ 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.
+ // 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);
}
@@ -1082,8 +1097,14 @@ 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) {
+ super(sslContext, null /* supportedProtocols */, 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 bf303ebba..944e294b0 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
@@ -8,6 +8,7 @@
import javax.net.ssl.SSLContext;
import java.lang.reflect.Field;
+import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -90,6 +91,20 @@ 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 testStringSSLContextRejectedBySetOption() {
Assert.expectThrows(ClientMisconfigurationException.class,
@@ -141,6 +156,22 @@ public void testCustomSSLContextAllowsSslMode() throws Exception {
}
}
+ @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 testTrustStoreAndClientCertificateConflictRejectedWithoutCustomContext() {
// Contrast: without a custom SSLContext the trust-store/certificate conflict is still rejected.
@@ -179,6 +210,20 @@ public void testSetSSLContextStoredInConfiguration() throws Exception {
}
}
+ @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);
+ }
+ }
+
@Test
public void testCreateSSLContextIgnoresCustomContext() throws Exception {
SSLContext customContext = SSLContext.getInstance("TLS");
diff --git a/client-v2/src/test/java/com/clickhouse/client/api/ClientConfigPropertiesTest.java b/client-v2/src/test/java/com/clickhouse/client/api/ClientConfigPropertiesTest.java
index f805ee65b..9c23ca522 100644
--- a/client-v2/src/test/java/com/clickhouse/client/api/ClientConfigPropertiesTest.java
+++ b/client-v2/src/test/java/com/clickhouse/client/api/ClientConfigPropertiesTest.java
@@ -2,8 +2,13 @@
import org.testng.Assert;
+import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
import java.util.Map;
@@ -31,4 +36,44 @@ public void testToKeyValuePairs() {
// Assert.assertEquals(map.get("key1"), "value1");
// Assert.assertEquals(map.get("key2"), "value2");
}
+
+ @DataProvider(name = "sslCipherSuites")
+ public static Object[][] sslCipherSuites() {
+ return new Object[][]{
+ // raw ssl_cipher_suites value -> expected parsed list
+ {"TLS_AES_256_GCM_SHA384,TLS_AES_128_GCM_SHA256",
+ Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256")},
+ {"TLS_AES_128_GCM_SHA256", Collections.singletonList("TLS_AES_128_GCM_SHA256")},
+ // each surviving name is trimmed
+ {" TLS_AES_256_GCM_SHA384 , TLS_AES_128_GCM_SHA256 ",
+ Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256")},
+ // blank tokens from a leading, doubled or trailing comma (and whitespace-only) are dropped
+ {",TLS_AES_256_GCM_SHA384,, TLS_AES_128_GCM_SHA256 ,",
+ Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256")},
+ // no usable suite -> empty list (treated downstream as "no restriction")
+ {"", Collections.emptyList()},
+ {" ", Collections.emptyList()},
+ {",, ,", Collections.emptyList()},
+ };
+ }
+
+ @Test(groups = {"unit"}, dataProvider = "sslCipherSuites")
+ public void testSslCipherSuitesParsedAndSanitized(String raw, List expected) {
+ Assert.assertEquals(ClientConfigProperties.SSL_CIPHER_SUITES.parseValue(raw), expected);
+ }
+
+ @Test(groups = {"unit"})
+ public void testSslCipherSuitesUnsetParsesToNull() {
+ Assert.assertNull(ClientConfigProperties.SSL_CIPHER_SUITES.parseValue(null));
+ }
+
+ @Test(groups = {"unit"})
+ public void testParseConfigMapSanitizesSslCipherSuites() {
+ Map raw = new HashMap<>();
+ raw.put(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(),
+ ",TLS_AES_256_GCM_SHA384,, TLS_AES_128_GCM_SHA256 ,");
+ Map parsed = ClientConfigProperties.parseConfigMap(raw);
+ Assert.assertEquals(parsed.get(ClientConfigProperties.SSL_CIPHER_SUITES.getKey()),
+ Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256"));
+ }
}
\ No newline at end of file
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..464795e32 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,233 @@
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");
+ }
+
@Test
public void testExecuteRequestThrowsConnectExceptionOn502() throws Exception {
Map configuration = new HashMap<>();
@@ -75,4 +283,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 4237a80cd..1d3c4b3ad 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`.
- Custom SSL context: `Client.Builder.setSSLContext(SSLContext)` supplies a fully pre-built `javax.net.ssl.SSLContext`. When set, the client uses it as is instead of building one from the trust/key material (trust store, CA certificate, client certificate/key); those options cannot be combined with a custom context and are rejected as invalid configuration. `ssl_mode` then only controls server hostname verification. This is primarily for material assembled in memory that must never be written to disk.
- 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.
@@ -55,7 +56,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.
- Custom SSL context via properties: A fully pre-built `javax.net.ssl.SSLContext` may be passed as a live object in the connection `Properties` under the `ssl_context` key (added with `Properties.put`, since it is not a string). It is forwarded to the underlying `client-v2` transport and used as is; trust/key material properties (e.g. `sslrootcert`, `sslcert`) cannot be combined with it and are rejected, while `ssl_mode` then only controls hostname verification. This supports diskless, in-memory TLS material behind connection pools that only expose `java.util.Properties`.
- 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.
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 c21d9fb2c..71f44a15e 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
@@ -32,6 +32,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.
* Supplying a fully pre-built {@link javax.net.ssl.SSLContext} with
* {@link Client.Builder#setSSLContext(javax.net.ssl.SSLContext)} - useful when the trust/key
* material is assembled entirely in memory (e.g. fetched and decrypted from a secret store) and
@@ -81,6 +84,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);
connectWithCustomSSLContext(endpoint, database, user, password, rootCert);
} else {
log.info("chRootCert is not set - skipping the custom CA certificate examples. "
@@ -100,6 +104,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());
connectWithCustomSSLContext(server.getEndpoint(), database,
SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath());
} catch (Exception e) {
@@ -206,6 +212,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);
+ }
+ }
+
/**
* Connects using a fully pre-built {@link SSLContext} supplied with
* {@link Client.Builder#setSSLContext(SSLContext)}.
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 37586ebfc..9b4800a21 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
@@ -38,6 +38,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.
* Passing a fully pre-built {@link javax.net.ssl.SSLContext} as a live object in the
* connection {@link Properties} under the {@code ssl_context} key - useful when the trust/key
* material is assembled entirely in memory (e.g. fetched and decrypted from a secret store) and
@@ -84,6 +87,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);
connectWithCustomSSLContext(url, user, password, rootCert);
} else {
log.info("chRootCert is not set - skipping the custom CA certificate examples. "
@@ -106,6 +110,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());
connectWithCustomSSLContext(server.getJdbcUrl(),
SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath());
} catch (Exception e) {
@@ -211,6 +217,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"));
+ }
+ }
+ }
+
/**
* Connects by passing a fully pre-built {@link SSLContext} as a live object in the connection
* {@link Properties} under the {@code ssl_context} key.
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 017c2af25..7085e3afb 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
@@ -224,6 +224,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);
+ }
+
@Test
public void testCustomSSLContextAcceptedViaProperties() throws Exception {
SSLContext customContext = SSLContext.getInstance("TLS");