Skip to content
Merged
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions client-v2/src/main/java/com/clickhouse/client/api/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,35 @@ public Object parseValue(String value) {
* See <a href="https://clickhouse.com/docs/operations/settings/query-level#custom_settings">ClickHouse Docs</a>
*/
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).
* <p>
* 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.
* <p>
* 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<String> suites = (List<String>) 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@
return phccm;
}

public CloseableHttpClient createHttpClient(boolean initSslContext, Map<String, Object> configuration) {

Check warning on line 280 in client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

A "Brain Method" was detected. Refactor it to reduce at least one of the following metrics: LOC from 81 to 64, Complexity from 25 to 14, Nesting Level from 3 to 2, Number of Variables from 28 to 6.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ9HuUVmAixZi3ET3ZHa&open=AZ9HuUVmAixZi3ET3ZHa&pullRequest=2919
// Top Level builders
HttpClientBuilder clientBuilder = HttpClientBuilder.create();
// An application-supplied SSLContext is used as is; otherwise one is built from the configured
Expand All @@ -302,8 +302,23 @@
// Trust and VerifyCa skip hostname verification. The same applies when a custom SNI is
// set because the connection hostname will not match the certificate.
boolean trustAllHostnames = sslMode == SSLMode.TRUST || sslMode == SSLMode.VERIFY_CA;
if (socketSNI != null && !socketSNI.trim().isEmpty() || trustAllHostnames) {
sslConnectionSocketFactory = new CustomSSLConnectionFactory(socketSNI, sslContext, (hostname, session) -> 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<String> 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);
}
Expand Down Expand Up @@ -1082,8 +1097,14 @@

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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;


Expand Down Expand Up @@ -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<String> 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<String, String> raw = new HashMap<>();
raw.put(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(),
",TLS_AES_256_GCM_SHA384,, TLS_AES_128_GCM_SHA256 ,");
Map<String, Object> 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"));
}
}
Loading
Loading