Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 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
Expand Down
16 changes: 16 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 @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,8 +288,21 @@ public CloseableHttpClient createHttpClient(boolean initSslContext, Map<String,
// 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();
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 @@ -1065,8 +1078,17 @@ 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) {
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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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<String, Object> extractConfiguration(Client client) throws Exception {
Field configField = Client.class.getDeclaredField("configuration");
configField.setAccessible(true);
return (Map<String, Object>) configField.get(client);
}

private static String extractFirstEndpointUri(Client client) throws Exception {
Field endpointsField = Client.class.getDeclaredField("endpoints");
endpointsField.setAccessible(true);
Expand Down
Loading