Skip to content

Commit b09551d

Browse files
authored
Merge pull request #2919 from ClickHouse/polyglot/client-v2-ssl-cipher-suites
Support TLS cipher suite selection in client-v2 and jdbc-v2
2 parents 565c59b + 579e32e commit b09551d

11 files changed

Lines changed: 496 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
[Release Migration Guide](docs/releases/0_11_0.md)
44

5+
### New Features
6+
7+
- **[client-v2, jdbc-v2]** Added TLS cipher suite selection. `Client.Builder.setSSLCipherSuites(String...)` (client-v2)
8+
and the comma-separated `ssl_cipher_suites` connection property (client-v2 and jdbc-v2) restrict the cipher suites
9+
enabled on secure connections; when unset, the transport defaults are used. Cipher-suite selection is independent of the
10+
trust configuration and `ssl_mode`. (https://github.com/ClickHouse/clickhouse-java/issues/2882)
11+
512
### Bug Fixes
613

714
- **[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)

client-v2/src/main/java/com/clickhouse/client/api/Client.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
import java.time.ZoneId;
6161
import java.time.temporal.ChronoUnit;
6262
import java.util.ArrayList;
63+
import java.util.Arrays;
6364
import java.util.Collection;
6465
import java.util.Collections;
6566
import java.util.HashMap;
@@ -808,6 +809,22 @@ public Builder setSSLMode(SSLMode sslMode) {
808809
return this;
809810
}
810811

812+
/**
813+
* Restricts the TLS cipher suites the client may negotiate on secure connections. When set, only
814+
* the listed cipher suites are enabled on the SSL socket (subject to what the JVM and the server
815+
* support); when not set, the transport defaults are used (Apache HttpClient enables the JVM's
816+
* default suites minus those it considers weak). Suite names use the standard JSSE names, for
817+
* example {@code TLS_AES_256_GCM_SHA384}.
818+
*
819+
* @param cipherSuites cipher suite names to enable
820+
* @return same instance of the builder
821+
*/
822+
public Builder setSSLCipherSuites(String... cipherSuites) {
823+
this.configuration.put(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(),
824+
ClientConfigProperties.commaSeparated(Arrays.asList(cipherSuites)));
825+
return this;
826+
}
827+
811828
/**
812829
* Supplies a pre-built {@link SSLContext}. When set, it is used as is instead of a context built
813830
* from the configured trust/key material (which then cannot be set alongside it). {@link SSLMode}

client-v2/src/main/java/com/clickhouse/client/api/ClientConfigProperties.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,35 @@ public Object parseValue(String value) {
208208
* See <a href="https://clickhouse.com/docs/operations/settings/query-level#custom_settings">ClickHouse Docs</a>
209209
*/
210210
CUSTOM_SETTINGS_PREFIX("custom_settings_prefix", String.class, "custom_"),
211+
212+
/**
213+
* Comma-separated list of TLS cipher suites the client is allowed to negotiate on secure connections.
214+
* When set, only these cipher suites are enabled on the SSL socket (subject to what the JVM and server
215+
* support); when unset, the transport defaults are used (Apache HttpClient enables the JVM's default
216+
* suites minus those it considers weak).
217+
* <p>
218+
* The value is parsed into a sanitized list here, in the config-parsing layer (not in transport code):
219+
* blank tokens produced by a leading, trailing or doubled comma are dropped and each surviving name is
220+
* trimmed, so consumers receive a ready-to-use list. A null, empty or whitespace-only entry would
221+
* otherwise be rejected by the SSL socket and break the handshake even alongside valid suites.
222+
* <p>
223+
* Appended at the end of the enum on purpose: adding a constant in the middle would shift the ordinal
224+
* of every following constant (see {@code docs/changes_checklist.md}).
225+
*/
226+
SSL_CIPHER_SUITES("ssl_cipher_suites", List.class) {
227+
@Override
228+
@SuppressWarnings("unchecked")
229+
public Object parseValue(String value) {
230+
List<String> suites = (List<String>) super.parseValue(value);
231+
if (suites == null) {
232+
return null;
233+
}
234+
return suites.stream()
235+
.filter(s -> s != null && !s.trim().isEmpty())
236+
.map(String::trim)
237+
.collect(Collectors.toList());
238+
}
239+
},
211240
;
212241

213242
private static final Logger LOG = LoggerFactory.getLogger(ClientConfigProperties.class);

client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,8 +302,23 @@ public CloseableHttpClient createHttpClient(boolean initSslContext, Map<String,
302302
// Trust and VerifyCa skip hostname verification. The same applies when a custom SNI is
303303
// set because the connection hostname will not match the certificate.
304304
boolean trustAllHostnames = sslMode == SSLMode.TRUST || sslMode == SSLMode.VERIFY_CA;
305-
if (socketSNI != null && !socketSNI.trim().isEmpty() || trustAllHostnames) {
306-
sslConnectionSocketFactory = new CustomSSLConnectionFactory(socketSNI, sslContext, (hostname, session) -> true);
305+
boolean hasSNI = socketSNI != null && !socketSNI.trim().isEmpty();
306+
// ssl_cipher_suites is parsed and sanitized in ClientConfigProperties (blank tokens dropped,
307+
// names trimmed); an unset or empty list means "no restriction" so the transport defaults apply.
308+
List<String> cipherSuites = ClientConfigProperties.SSL_CIPHER_SUITES.getOrDefault(configuration);
309+
String[] enabledCipherSuites = cipherSuites == null || cipherSuites.isEmpty() ? null
310+
: cipherSuites.toArray(new String[0]);
311+
if (hasSNI || trustAllHostnames || enabledCipherSuites != null) {
312+
// Skip hostname verification only for trust-all modes or when a custom SNI is used (the
313+
// connection hostname would not match the certificate); otherwise a null verifier makes the
314+
// factory fall back to the JDK/HttpClient default verifier, keeping STRICT verification.
315+
// java:S5527 - the permissive verifier is applied only for SSLMode.TRUST/VERIFY_CA (where the
316+
// user has explicitly opted out of hostname verification) or a custom SNI; STRICT keeps the
317+
// default verifying behaviour, so secure-by-default hostname verification is preserved.
318+
@SuppressWarnings("java:S5527")
319+
HostnameVerifier hostnameVerifier = trustAllHostnames || hasSNI ? (hostname, session) -> true : null;
320+
sslConnectionSocketFactory = new CustomSSLConnectionFactory(socketSNI, sslContext, hostnameVerifier,
321+
enabledCipherSuites);
307322
} else {
308323
sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext);
309324
}
@@ -1082,8 +1097,14 @@ public static class CustomSSLConnectionFactory extends SSLConnectionSocketFactor
10821097

10831098
private final SNIHostName defaultSNI;
10841099

1100+
// Retained for backward compatibility; delegates with no cipher-suite restriction (transport defaults).
10851101
public CustomSSLConnectionFactory(String defaultSNI, SSLContext sslContext, HostnameVerifier hostnameVerifier) {
1086-
super(sslContext, hostnameVerifier);
1102+
this(defaultSNI, sslContext, hostnameVerifier, null);
1103+
}
1104+
1105+
public CustomSSLConnectionFactory(String defaultSNI, SSLContext sslContext, HostnameVerifier hostnameVerifier,
1106+
String[] supportedCipherSuites) {
1107+
super(sslContext, null /* supportedProtocols */, supportedCipherSuites, hostnameVerifier);
10871108
this.defaultSNI = defaultSNI == null || defaultSNI.trim().isEmpty() ? null : new SNIHostName(defaultSNI);
10881109
}
10891110

client-v2/src/test/java/com/clickhouse/client/api/ClientBuilderTest.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import javax.net.ssl.SSLContext;
1010
import java.lang.reflect.Field;
11+
import java.util.Arrays;
1112
import java.util.HashMap;
1213
import java.util.List;
1314
import java.util.Map;
@@ -90,6 +91,20 @@ public void testSslModeInvalidValueRejected() {
9091
.build());
9192
}
9293

94+
@Test
95+
public void testSetSSLCipherSuitesStoredInConfiguration() throws Exception {
96+
try (Client client = new Client.Builder()
97+
.addEndpoint("https://localhost:8443")
98+
.setUsername("default")
99+
.setPassword("")
100+
.setSSLCipherSuites("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256")
101+
.build()) {
102+
Assert.assertEquals(extractConfiguration(client).get(ClientConfigProperties.SSL_CIPHER_SUITES.getKey()),
103+
Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256"),
104+
"Cipher suites set via the builder should be stored as a parsed list");
105+
}
106+
}
107+
93108
@Test
94109
public void testStringSSLContextRejectedBySetOption() {
95110
Assert.expectThrows(ClientMisconfigurationException.class,
@@ -141,6 +156,22 @@ public void testCustomSSLContextAllowsSslMode() throws Exception {
141156
}
142157
}
143158

159+
@Test
160+
public void testSSLCipherSuitesViaSetOptionParsedAsList() throws Exception {
161+
// The comma-separated string form is the path used by URL/JDBC properties.
162+
try (Client client = new Client.Builder()
163+
.addEndpoint("https://localhost:8443")
164+
.setUsername("default")
165+
.setPassword("")
166+
.setOption(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(),
167+
"TLS_AES_256_GCM_SHA384,TLS_AES_128_GCM_SHA256")
168+
.build()) {
169+
Assert.assertEquals(extractConfiguration(client).get(ClientConfigProperties.SSL_CIPHER_SUITES.getKey()),
170+
Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256"),
171+
"Comma-separated cipher suites should be parsed into a list");
172+
}
173+
}
174+
144175
@Test
145176
public void testTrustStoreAndClientCertificateConflictRejectedWithoutCustomContext() {
146177
// Contrast: without a custom SSLContext the trust-store/certificate conflict is still rejected.
@@ -179,6 +210,20 @@ public void testSetSSLContextStoredInConfiguration() throws Exception {
179210
}
180211
}
181212

213+
@Test
214+
public void testClientBuildsWithCipherSuitesOverHttps() {
215+
// Exercises the HTTPS connection-socket-factory path with cipher suites configured (STRICT mode,
216+
// no SNI): the client must build without error.
217+
try (Client client = new Client.Builder()
218+
.addEndpoint("https://localhost:8443")
219+
.setUsername("default")
220+
.setPassword("")
221+
.setSSLCipherSuites("TLS_AES_256_GCM_SHA384")
222+
.build()) {
223+
Assert.assertNotNull(client);
224+
}
225+
}
226+
182227
@Test
183228
public void testCreateSSLContextIgnoresCustomContext() throws Exception {
184229
SSLContext customContext = SSLContext.getInstance("TLS");

client-v2/src/test/java/com/clickhouse/client/api/ClientConfigPropertiesTest.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33

44
import org.testng.Assert;
5+
import org.testng.annotations.DataProvider;
56
import org.testng.annotations.Test;
67

8+
import java.util.Arrays;
9+
import java.util.Collections;
10+
import java.util.HashMap;
11+
import java.util.List;
712
import java.util.Map;
813

914

@@ -31,4 +36,44 @@ public void testToKeyValuePairs() {
3136
// Assert.assertEquals(map.get("key1"), "value1");
3237
// Assert.assertEquals(map.get("key2"), "value2");
3338
}
39+
40+
@DataProvider(name = "sslCipherSuites")
41+
public static Object[][] sslCipherSuites() {
42+
return new Object[][]{
43+
// raw ssl_cipher_suites value -> expected parsed list
44+
{"TLS_AES_256_GCM_SHA384,TLS_AES_128_GCM_SHA256",
45+
Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256")},
46+
{"TLS_AES_128_GCM_SHA256", Collections.singletonList("TLS_AES_128_GCM_SHA256")},
47+
// each surviving name is trimmed
48+
{" TLS_AES_256_GCM_SHA384 , TLS_AES_128_GCM_SHA256 ",
49+
Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256")},
50+
// blank tokens from a leading, doubled or trailing comma (and whitespace-only) are dropped
51+
{",TLS_AES_256_GCM_SHA384,, TLS_AES_128_GCM_SHA256 ,",
52+
Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256")},
53+
// no usable suite -> empty list (treated downstream as "no restriction")
54+
{"", Collections.emptyList()},
55+
{" ", Collections.emptyList()},
56+
{",, ,", Collections.emptyList()},
57+
};
58+
}
59+
60+
@Test(groups = {"unit"}, dataProvider = "sslCipherSuites")
61+
public void testSslCipherSuitesParsedAndSanitized(String raw, List<String> expected) {
62+
Assert.assertEquals(ClientConfigProperties.SSL_CIPHER_SUITES.parseValue(raw), expected);
63+
}
64+
65+
@Test(groups = {"unit"})
66+
public void testSslCipherSuitesUnsetParsesToNull() {
67+
Assert.assertNull(ClientConfigProperties.SSL_CIPHER_SUITES.parseValue(null));
68+
}
69+
70+
@Test(groups = {"unit"})
71+
public void testParseConfigMapSanitizesSslCipherSuites() {
72+
Map<String, String> raw = new HashMap<>();
73+
raw.put(ClientConfigProperties.SSL_CIPHER_SUITES.getKey(),
74+
",TLS_AES_256_GCM_SHA384,, TLS_AES_128_GCM_SHA256 ,");
75+
Map<String, Object> parsed = ClientConfigProperties.parseConfigMap(raw);
76+
Assert.assertEquals(parsed.get(ClientConfigProperties.SSL_CIPHER_SUITES.getKey()),
77+
Arrays.asList("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256"));
78+
}
3479
}

0 commit comments

Comments
 (0)