From 0b5776dc15bfdb9cb7f5208fd2948980a88ab230 Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Wed, 10 Jun 2026 21:58:43 -0700 Subject: [PATCH 1/6] Added ssl_mode to client-v2 and jdbc-v2 --- .../ClickHouseDefaultSslContextProvider.java | 59 ++++++--- .../com/clickhouse/client/api/Client.java | 28 +++++ .../client/api/ClientConfigProperties.java | 3 + .../api/internal/HttpAPIClientHelper.java | 25 +++- .../clickhouse/client/HttpTransportTests.java | 118 ++++++++++++++++++ .../jdbc/internal/JdbcConfiguration.java | 20 ++- .../com/clickhouse/jdbc/ConnectionTest.java | 72 +++++++++++ .../jdbc/internal/JdbcConfigurationTest.java | 51 ++++++++ 8 files changed, 353 insertions(+), 23 deletions(-) diff --git a/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java b/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java index cf114cb26..9b0a2a12b 100644 --- a/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java +++ b/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java @@ -154,14 +154,38 @@ public SSLContext getJavaSslContext(ClickHouseConfig config) throws SSLException } public SSLContext getSslContextFromCerts(String clientCert, String clientKey, String sslRootCert) throws SSLException { - return getSslContextImpl(ClickHouseSslMode.STRICT, - clientCert, clientKey, sslRootCert, null, null, KeyStore.getDefaultType()); + return getSslContextFromCerts(ClickHouseSslMode.STRICT, clientCert, clientKey, sslRootCert); + } + + /** + * Creates an SSL context from certificates with an explicit SSL mode. + * With {@link ClickHouseSslMode#NONE} the server certificate is not validated, while client + * certificate and key are still used (if provided) so that mTLS keeps working. + * + * @param sslMode ssl mode + * @param clientCert client certificate for mTLS, file path or PEM content; may be null + * @param clientKey client private key for mTLS, file path or PEM content; may be null + * @param sslRootCert CA certificate to validate the server certificate, file path or PEM content; may be null + * @return SSL context + * @throws SSLException when the context cannot be created + */ + public SSLContext getSslContextFromCerts(ClickHouseSslMode sslMode, String clientCert, String clientKey, + String sslRootCert) throws SSLException { + return getSslContextImpl(sslMode, clientCert, clientKey, sslRootCert, null, null, KeyStore.getDefaultType()); } public SSLContext getSslContextFromKeyStore(String truststorePath, String truststorePassword, String keyStoreType) throws SSLException { return getSslContextImpl(ClickHouseSslMode.STRICT, null, null, null, truststorePath, truststorePassword, keyStoreType); } + private KeyManager[] getKeyManagers(String clientCert, String clientKey) + throws NoSuchAlgorithmException, InvalidKeySpecException, IOException, CertificateException, + KeyStoreException, UnrecoverableKeyException { + KeyManagerFactory factory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + factory.init(getKeyStore(clientCert, clientKey), null); + return factory.getKeyManagers(); + } + private SSLContext getSslContextImpl(ClickHouseSslMode sslMode, String clientCert, String clientKey, String sslRootCert, String truststorePath, String truststorePassword, String keyStoreType) throws SSLException { SSLContext ctx; try { @@ -172,34 +196,29 @@ private SSLContext getSslContextImpl(ClickHouseSslMode sslMode, String clientCer if (sslMode == ClickHouseSslMode.NONE) { tms = new TrustManager[]{new NonValidatingTrustManager()}; - kms = new KeyManager[0]; + // client certificate and key are independent from server verification - keep mTLS working + kms = clientCert != null && !clientCert.isEmpty() ? getKeyManagers(clientCert, clientKey) + : new KeyManager[0]; sr = new SecureRandom(); } else if (sslMode == ClickHouseSslMode.STRICT) { - if (truststorePath != null && !truststorePath.isEmpty()) { + if (clientCert != null && !clientCert.isEmpty()) { + kms = getKeyManagers(clientCert, clientKey); + } + if (truststorePath != null && !truststorePath.isEmpty()) { try (InputStream in = ClickHouseUtils.getFileInputStream(truststorePath)) { KeyStore myTrustStore = KeyStore.getInstance(keyStoreType); - myTrustStore.load(in, truststorePassword.toCharArray()); + myTrustStore.load(in, truststorePassword == null ? null : truststorePassword.toCharArray()); TrustManagerFactory factory = TrustManagerFactory .getInstance(TrustManagerFactory.getDefaultAlgorithm()); factory.init(myTrustStore); tms = factory.getTrustManagers(); - - } - } else { - if (clientCert != null && !clientCert.isEmpty()) { - KeyManagerFactory factory = KeyManagerFactory - .getInstance(KeyManagerFactory.getDefaultAlgorithm()); - factory.init(getKeyStore(clientCert, clientKey), null); - kms = factory.getKeyManagers(); - } - - if (sslRootCert != null && !sslRootCert.isEmpty()) { - TrustManagerFactory factory = TrustManagerFactory - .getInstance(TrustManagerFactory.getDefaultAlgorithm()); - factory.init(getKeyStore(sslRootCert, null)); - tms = factory.getTrustManagers(); } + } else if (sslRootCert != null && !sslRootCert.isEmpty()) { + TrustManagerFactory factory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + factory.init(getKeyStore(sslRootCert, null)); + tms = factory.getTrustManagers(); } sr = new SecureRandom(); 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 58d94da9b..2df5ced77 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 @@ -12,6 +12,7 @@ import com.clickhouse.client.api.data_formats.internal.ProcessParser; import com.clickhouse.client.api.enums.Protocol; import com.clickhouse.client.api.enums.ProxyType; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.http.ClickHouseHttpProto; import com.clickhouse.client.api.insert.InsertResponse; import com.clickhouse.client.api.insert.InsertSettings; @@ -755,6 +756,33 @@ public Builder setClientKey(String path) { return this; } + /** + * Defines how strictly the client verifies a server identity on secure connections. + * + *

Supported modes:

+ * + * + *

The mode applies only when a secure protocol is in use - for the HTTP transport that + * means an {@code https://} endpoint. Setting any mode does not make the client use + * encryption on a plain HTTP endpoint: the endpoint scheme always decides whether the + * connection is encrypted.

+ * + * @param sslMode ssl mode + * @return same instance of the builder + */ + public Builder setSSLMode(SSLMode sslMode) { + this.configuration.put(ClientConfigProperties.SSL_MODE.getKey(), sslMode.name()); + 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 e548a90f9..68b86b694 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 @@ -1,6 +1,7 @@ package com.clickhouse.client.api; import com.clickhouse.client.api.data_formats.internal.AbstractBinaryFormatReader; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.internal.ClickHouseLZ4OutputStream; import com.clickhouse.data.ClickHouseDataType; import com.clickhouse.data.ClickHouseFormat; @@ -115,6 +116,8 @@ public enum ClientConfigProperties { SSL_CERTIFICATE("sslcert", String.class), + SSL_MODE("ssl_mode", SSLMode.class, SSLMode.Strict.name()), + RETRY_ON_FAILURE("retry", Integer.class, "3"), INPUT_OUTPUT_FORMAT("format", ClickHouseFormat.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 5ae4730b7..67122b36b 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 @@ -11,6 +11,8 @@ import com.clickhouse.client.api.DataTransferException; import com.clickhouse.client.api.ServerException; import com.clickhouse.client.api.enums.ProxyType; +import com.clickhouse.client.api.enums.SSLMode; +import com.clickhouse.client.config.ClickHouseSslMode; import com.clickhouse.client.api.http.ClickHouseHttpProto; import com.clickhouse.client.api.transport.Endpoint; import com.clickhouse.client.config.ClickHouseDefaultSslContextProvider; @@ -165,11 +167,26 @@ public SSLContext createSSLContext(Map configuration) { } catch (NoSuchAlgorithmException e) { throw new ClientException("Failed to create default SSL context", e); } + final SSLMode sslMode = ClientConfigProperties.SSL_MODE.getOrDefault(configuration); final String trustStorePath = (String) configuration.get(ClientConfigProperties.SSL_TRUST_STORE.getKey()); final String caCertificate = (String) configuration.get(ClientConfigProperties.CA_CERTIFICATE.getKey()); final String sslCertificate = (String) configuration.get(ClientConfigProperties.SSL_CERTIFICATE.getKey()); final String sslKey = (String) configuration.get(ClientConfigProperties.SSL_KEY.getKey()); - if (trustStorePath != null) { + + if (sslMode == SSLMode.Trust) { + // Server certificate is not validated. Trust material (trust store or CA certificate) + // is not needed, but client certificate and key are still applied for mTLS. + try { + sslContext = sslContextProvider.getSslContextFromCerts(ClickHouseSslMode.NONE, + sslCertificate, sslKey, null); + } catch (SSLException e) { + throw new ClientMisconfigurationException("Failed to create SSL context for the Trust SSL mode", e); + } + } else if (trustStorePath != null) { + if (caCertificate != null) { + throw new ClientMisconfigurationException("CA certificate cannot be used together with a trust store." + + " The CA certificate should be imported into the trust store instead."); + } try { sslContext = sslContextProvider.getSslContextFromKeyStore( trustStorePath, @@ -272,7 +289,11 @@ public CloseableHttpClient createHttpClient(boolean initSslContext, Map true); } else { sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext); diff --git a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java index 5c6384a60..c91e1f55c 100644 --- a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java @@ -14,6 +14,7 @@ import com.clickhouse.client.api.data_formats.ClickHouseBinaryFormatReader; import com.clickhouse.client.api.enums.Protocol; import com.clickhouse.client.api.enums.ProxyType; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.http.ClickHouseHttpProto; import com.clickhouse.client.api.insert.InsertResponse; import com.clickhouse.client.api.insert.InsertSettings; @@ -291,6 +292,123 @@ public void testSecureConnection() { } } + @Test(groups = { "integration" }) + public void testSSLModeTrust() { + if (isCloud()) { + return; // test uses self-signed cert + } + + ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); + + // Default mode (Strict) without any trust material - the self-signed certificate must be rejected + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .build()) { + Assert.expectThrows(Exception.class, () -> client.queryAll("SELECT 1")); + } + + // Trust mode - the same certificate is accepted without any trust material + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setOption(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.Trust.name()) + .build()) { + List records = client.queryAll("SELECT timezone()"); + Assert.assertEquals(records.get(0).getString(1), "UTC"); + } catch (Exception e) { + Assert.fail("Trust SSL mode should accept a self-signed certificate", e); + } + } + + @Test(groups = { "integration" }) + public void testSSLModeVerifyCa() { + if (isCloud()) { + return; // test uses self-signed cert + } + + ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); + // server certificate has CN=localhost, so connecting via 127.0.0.1 fails hostname verification + final String endpointByIp = "https://127.0.0.1:" + secureServer.getPort(); + final String serverCertificate = "containers/clickhouse-server/certs/localhost.crt"; + + // Strict mode (default): certificate chain is trusted, but the hostname does not match + try (Client client = new Client.Builder() + .addEndpoint(endpointByIp) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setRootCertificate(serverCertificate) + .build()) { + Assert.expectThrows(Exception.class, () -> client.queryAll("SELECT 1")); + } + + // VerifyCa mode: certificate chain is validated, hostname mismatch is ignored + try (Client client = new Client.Builder() + .addEndpoint(endpointByIp) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setRootCertificate(serverCertificate) + .setSSLMode(SSLMode.VerifyCa) + .build()) { + List records = client.queryAll("SELECT timezone()"); + Assert.assertEquals(records.get(0).getString(1), "UTC"); + } catch (Exception e) { + Assert.fail("VerifyCa SSL mode should ignore hostname mismatch", e); + } + + // VerifyCa mode still validates the certificate chain - without the CA it must fail + try (Client client = new Client.Builder() + .addEndpoint(endpointByIp) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setSSLMode(SSLMode.VerifyCa) + .build()) { + Assert.expectThrows(Exception.class, () -> client.queryAll("SELECT 1")); + } + } + + @Test(groups = { "integration" }) + public void testSSLModeDisabled() { + if (isCloud()) { + return; // plain HTTP is not available in cloud + } + + ClickHouseNode server = getServer(ClickHouseProtocol.HTTP); + + // Disabled mode with a plain HTTP endpoint - SSL is simply not used + try (Client client = new Client.Builder() + .addEndpoint("http://" + server.getHost() + ":" + server.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setSSLMode(SSLMode.Disabled) + .build()) { + List records = client.queryAll("SELECT timezone()"); + Assert.assertEquals(records.get(0).getString(1), "UTC"); + } catch (Exception e) { + Assert.fail("Disabled SSL mode should work with a plain HTTP endpoint", e); + } + } + + @Test(groups = { "integration" }) + public void testSSLModeStrictWithTrustStoreAndCaCertificate() { + if (isCloud()) { + return; + } + + ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); + + // CA certificate cannot be combined with a trust store - it should be in the trust store already + Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setSSLTrustStore("containers/clickhouse-server/certs/KeyStore.jks") + .setRootCertificate("containers/clickhouse-server/certs/localhost.crt") + .build()); + } + @Test(groups = { "integration" }, dataProvider = "NoResponseFailureProvider") public void testInsertAndNoHttpResponseFailure(String body, int maxRetries, ThrowingFunction function, boolean shouldFail) { diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java index eca74aa53..0dd15e2da 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java @@ -2,6 +2,7 @@ import com.clickhouse.client.api.Client; import com.clickhouse.client.api.ClientConfigProperties; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.http.ClickHouseHttpProto; import com.clickhouse.data.ClickHouseDataType; import com.clickhouse.jdbc.Driver; @@ -332,7 +333,8 @@ private Map parseUrl(String url) throws SQLException { * @param urlProperties - properties parsed from URL * @param providedProperties - properties object provided by application */ - private void buildFinalProperties(Map urlProperties, Properties providedProperties) { + private void buildFinalProperties(Map urlProperties, Properties providedProperties) + throws SQLException { // Copy provided properties Map props = new HashMap<>(); @@ -379,6 +381,22 @@ private void buildFinalProperties(Map urlProperties, Properties } } + String sslMode = clientProperties.get(ClientConfigProperties.SSL_MODE.getKey()); + if (sslMode != null) { + if ("none".equalsIgnoreCase(sslMode)) { + // JDBC drivers traditionally use 'none' for the no-verification SSL mode - alias it to 'trust' + clientProperties.put(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.Trust.name()); + } else { + try { + // values are case-insensitive in JDBC - normalize before passing to the client + clientProperties.put(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.fromValue(sslMode).name()); + } catch (IllegalArgumentException e) { + throw new SQLException("Unknown value '" + sslMode + "' for property '" + + ClientConfigProperties.SSL_MODE.getKey() + "'", e); + } + } + } + // Fill list of client properties information, add not specified properties (doesn't affect client properties) for (ClientConfigProperties clientProp : ClientConfigProperties.values()) { DriverPropertyInfo propertyInfo = propertyInfos.get(clientProp.getKey()); diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java index 0ce66c84c..8972ff834 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java @@ -763,6 +763,78 @@ public void testSecureConnection() throws Exception { } } + @Test(groups = { "integration" }) + public void testSSLModeTrust() throws Exception { + if (isCloud()) { + return; // this test uses self-signed cert + } + ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); + String jdbcUrl = "jdbc:clickhouse:" + secureServer.getBaseUri(); + + Properties properties = new Properties(); + properties.put(ClientConfigProperties.USER.getKey(), "default"); + properties.put(ClientConfigProperties.PASSWORD.getKey(), ClickHouseServerForTest.getPassword()); + + // Default mode (strict) without any trust material - the self-signed certificate must be rejected + Assert.expectThrows(Exception.class, () -> { + try (Connection conn = new ConnectionImpl(jdbcUrl, properties); + Statement stmt = conn.createStatement()) { + stmt.executeQuery("SELECT 1"); + } + }); + + // 'none' is a JDBC alias for the 'trust' mode + for (String mode : new String[] { "none", "trust", "Trust" }) { + Properties trustProperties = new Properties(); + trustProperties.putAll(properties); + trustProperties.put(ClientConfigProperties.SSL_MODE.getKey(), mode); + + try (Connection conn = new ConnectionImpl(jdbcUrl, trustProperties); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT number FROM system.numbers LIMIT 10")) { + + int count = 0; + while (rs.next()) { count++; } + Assert.assertEquals(count, 10, "Failed for ssl_mode '" + mode + "'"); + } + } + } + + @Test(groups = { "integration" }) + public void testSSLModeVerifyCa() throws Exception { + if (isCloud()) { + return; // this test uses self-signed cert + } + ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); + // server certificate has CN=localhost, so connecting via 127.0.0.1 fails hostname verification + String jdbcUrl = "jdbc:clickhouse://127.0.0.1:" + secureServer.getPort() + "/"; + + Properties properties = new Properties(); + properties.put(ClientConfigProperties.USER.getKey(), "default"); + properties.put(ClientConfigProperties.PASSWORD.getKey(), ClickHouseServerForTest.getPassword()); + properties.put(DriverProperties.SECURE_CONNECTION.getKey(), "true"); + properties.put(ClientConfigProperties.CA_CERTIFICATE.getKey(), "containers/clickhouse-server/certs/localhost.crt"); + + // Default mode (strict): certificate chain is trusted, but the hostname does not match + Assert.expectThrows(Exception.class, () -> { + try (Connection conn = new ConnectionImpl(jdbcUrl, properties); + Statement stmt = conn.createStatement()) { + stmt.executeQuery("SELECT 1"); + } + }); + + // verify_ca: certificate chain is validated, hostname mismatch is ignored + properties.put(ClientConfigProperties.SSL_MODE.getKey(), "verifyca"); + try (Connection conn = new ConnectionImpl(jdbcUrl, properties); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT number FROM system.numbers LIMIT 10")) { + + int count = 0; + while (rs.next()) { count++; } + Assert.assertEquals(count, 10); + } + } + @Test(groups = { "integration" }) public void testSelectingDatabase() throws Exception { if (isCloud()) { 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 9e0abe8bc..08d80748f 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 @@ -2,6 +2,7 @@ import com.clickhouse.client.api.Client; import com.clickhouse.client.api.ClientConfigProperties; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.data.ClickHouseDataType; import com.clickhouse.jdbc.DriverProperties; @@ -17,6 +18,8 @@ import java.util.Map; import java.util.Objects; import java.util.Properties; +import java.util.Set; +import java.util.stream.Collectors; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; @@ -169,6 +172,54 @@ public void testConfigurationProperties() throws Exception { assertEquals(p.value, "default1"); } + @DataProvider(name = "sslModeValues") + public Object[][] sslModeValues() { + return new Object[][] { + // input value, expected client property value + { "none", SSLMode.Trust.name() }, // JDBC alias for the no-verification mode + { "NONE", SSLMode.Trust.name() }, + { "disabled", SSLMode.Disabled.name() }, + { "Disabled", SSLMode.Disabled.name() }, + { "trust", SSLMode.Trust.name() }, + { "Trust", SSLMode.Trust.name() }, + { "verifyca", SSLMode.VerifyCa.name() }, + { "VERIFYCA", SSLMode.VerifyCa.name() }, + { "strict", SSLMode.Strict.name() }, + { "Strict", SSLMode.Strict.name() }, + }; + } + + @Test + public void testSSLModeDatasetCoversAllModes() { + Set covered = Arrays.stream(sslModeValues()) + .map(row -> (String) row[1]) + .collect(Collectors.toSet()); + Set allModes = Arrays.stream(SSLMode.values()) + .map(Enum::name) + .collect(Collectors.toSet()); + assertEquals(covered, allModes, + "SSLMode constants changed - update the 'sslModeValues' dataset and the ssl_mode handling in JdbcConfiguration"); + } + + @Test(dataProvider = "sslModeValues") + public void testSSLModeProperty(String value, String expected) throws Exception { + // passed via Properties + Properties properties = new Properties(); + properties.setProperty(ClientConfigProperties.SSL_MODE.getKey(), value); + JdbcConfiguration configuration = new JdbcConfiguration("jdbc:clickhouse://localhost:8123/", properties); + assertEquals(configuration.getClientProperties().get(ClientConfigProperties.SSL_MODE.getKey()), expected); + + // passed as a URL parameter + configuration = new JdbcConfiguration("jdbc:clickhouse://localhost:8123/?ssl_mode=" + value, new Properties()); + assertEquals(configuration.getClientProperties().get(ClientConfigProperties.SSL_MODE.getKey()), expected); + } + + @Test + public void testSSLModeInvalidValue() { + assertThrows(SQLException.class, + () -> new JdbcConfiguration("jdbc:clickhouse://localhost:8123/?ssl_mode=insecure", new Properties())); + } + @DataProvider(name = "typeMappingsPropertyKey") public Object[][] typeMappingsPropertyKey() { return new Object[][] { From 767fc605a889d5c33d0abd3d009323a008f65804 Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Wed, 10 Jun 2026 22:11:13 -0700 Subject: [PATCH 2/6] Added examples --- .../clickhouse/client/api/enums/SSLMode.java | 62 +++++++++++++++++++ examples/client-v2/README.md | 16 +++-- .../examples/client_v2/SSLExamples.java | 53 +++++++++++++--- examples/jdbc/README.md | 17 +++-- .../clickhouse/examples/jdbc/SSLExamples.java | 57 ++++++++++++++--- 5 files changed, 178 insertions(+), 27 deletions(-) create mode 100644 client-v2/src/main/java/com/clickhouse/client/api/enums/SSLMode.java diff --git a/client-v2/src/main/java/com/clickhouse/client/api/enums/SSLMode.java b/client-v2/src/main/java/com/clickhouse/client/api/enums/SSLMode.java new file mode 100644 index 000000000..03a3bacae --- /dev/null +++ b/client-v2/src/main/java/com/clickhouse/client/api/enums/SSLMode.java @@ -0,0 +1,62 @@ +package com.clickhouse.client.api.enums; + +/** + * Defines how strictly the client verifies a server identity when a secure protocol is used. + * + *

The mode affects only connections that are already using a secure transport (for example, + * an {@code https://} endpoint). It does not enable encryption for plain protocols - an + * {@code http://} endpoint stays unencrypted whatever the mode is.

+ * + *

Modes from the least to the most strict:

+ *
    + *
  • {@link #Disabled} - SSL is not used. Plain protocols only.
  • + *
  • {@link #Trust} - encryption is used, but the server certificate chain is not validated + * and the hostname is not verified. Susceptible to MITM attacks - use only for testing or in + * fully trusted environments.
  • + *
  • {@link #VerifyCa} - the server certificate chain is validated against the trust material + * (default JVM trust store, configured trust store, or a CA certificate), but the hostname is + * not checked against the certificate.
  • + *
  • {@link #Strict} - full verification (default): certificate chain is validated and the + * hostname must match the certificate.
  • + *
+ */ +public enum SSLMode { + + /** + * SSL is not used. Connection is not encrypted. + */ + Disabled, + + /** + * Encryption without verification: any server certificate is accepted and + * the hostname is not verified. + */ + Trust, + + /** + * Server certificate chain is validated, but the hostname is not verified. + */ + VerifyCa, + + /** + * Full verification: certificate chain is validated and the hostname must match + * the certificate. Default mode. + */ + Strict; + + /** + * Case-insensitive variant of {@link #valueOf(String)}. + * + * @param value mode name in any case + * @return matching mode + * @throws IllegalArgumentException when the value does not match any mode + */ + public static SSLMode fromValue(String value) { + for (SSLMode mode : values()) { + if (mode.name().equalsIgnoreCase(value)) { + return mode; + } + } + throw new IllegalArgumentException("Unknown SSL mode '" + value + "'"); + } +} diff --git a/examples/client-v2/README.md b/examples/client-v2/README.md index b1a688b96..6ebbdd3cc 100644 --- a/examples/client-v2/README.md +++ b/examples/client-v2/README.md @@ -78,10 +78,15 @@ Notes: ## SSL Examples -`com.clickhouse.examples.client_v2.SSLExamples` shows how to connect securely to a server whose -certificate is signed by a custom (private) CA. Only the CA certificate is passed to the client -with `Client.Builder.setRootCertificate()` - no trust store configuration is required, and the JVM -default trust store stays untouched. +`com.clickhouse.examples.client_v2.SSLExamples` shows how to connect securely to a server: + +- **Custom CA certificate** - the server certificate is signed by a custom (private) CA. Only the + CA certificate is passed to the client with `Client.Builder.setRootCertificate()` (as a file path + or directly as a PEM string) - no trust store configuration is required, and the JVM default + trust store stays untouched. +- **Self-signed certificate without verification** - `Client.Builder.setSSLMode(SSLMode.Trust)` + accepts any server certificate and skips hostname verification. The connection is encrypted, but + the server identity is not verified - use it only for testing or in fully trusted environments. The example runs in one of two modes. @@ -113,7 +118,8 @@ mvn exec:java -Dexec.mainClass="com.clickhouse.examples.client_v2.SSLExamples" \ -DchRootCert="/path/to/ca.crt" ``` -`-DchRootCert` is required in this mode and must point to the CA certificate in PEM format. +`-DchRootCert` must point to the CA certificate in PEM format. When it is omitted, only the +self-signed (`SSLMode.Trust`) example runs - useful when you do not have the CA certificate at hand. ### Setting up a Docker dev instance with a self-signed certificate manually 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 5d47235f4..307c5adff 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 @@ -1,6 +1,7 @@ package com.clickhouse.examples.client_v2; import com.clickhouse.client.api.Client; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.query.GenericRecord; import lombok.extern.slf4j.Slf4j; @@ -22,6 +23,9 @@ *
  • Passing the CA certificate as a PEM string instead of a file path - useful when the * certificate comes from an environment variable or a secret manager (typical for * Kubernetes/cloud deployments) and you do not want to write it to disk.
  • + *
  • 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.
  • * * *

    More SSL examples (mTLS, trust stores, SNI) will be added to this class later.

    @@ -41,7 +45,8 @@ *
  • {@code chPort} - ClickHouse HTTPS port, default {@code 8443}
  • *
  • {@code chDatabase} - database name, default {@code default}
  • *
  • {@code chUser} and {@code chPassword} - credentials (standalone mode)
  • - *
  • {@code chRootCert} - path to the root CA certificate in PEM format (required in standalone mode)
  • + *
  • {@code chRootCert} - path to the root CA certificate in PEM format. When omitted in + * standalone mode, only the self-signed (Trust) example runs
  • *
  • {@code chImage} - Docker image for local mode, default {@code clickhouse/clickhouse-server:latest}
  • * */ @@ -58,16 +63,17 @@ public static void main(String[] args) { final String user = System.getProperty("chUser", "default"); final String password = System.getProperty("chPassword", ""); final String rootCert = trimToNull(System.getProperty("chRootCert")); - if (rootCert == null) { - log.error("chRootCert is required when chHost is set. " - + "Pass the path to the CA certificate (PEM) that signed the server certificate."); - return; - } log.info("Running in standalone mode against {}:{}", host, port); String endpoint = "https://" + host + ":" + port; - connectWithCustomRootCertificate(endpoint, database, user, password, rootCert); - connectWithRootCertificateAsString(endpoint, database, user, password, rootCert); + connectToSelfSignedServer(endpoint, database, user, password); + if (rootCert != null) { + connectWithCustomRootCertificate(endpoint, database, user, password, rootCert); + connectWithRootCertificateAsString(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."); + } return; } @@ -76,6 +82,8 @@ public static void main(String[] args) { final String image = System.getProperty("chImage", "clickhouse/clickhouse-server:latest"); log.info("Running in local mode (set -DchHost to verify your own server)"); try (SecureServerSupport server = SecureServerSupport.start(image)) { + connectToSelfSignedServer(server.getEndpoint(), database, + SecureServerSupport.USER, SecureServerSupport.PASSWORD); connectWithCustomRootCertificate(server.getEndpoint(), database, SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); connectWithRootCertificateAsString(server.getEndpoint(), database, @@ -88,6 +96,35 @@ public static void main(String[] args) { Runtime.getRuntime().exit(0); } + /** + * Connects to a ClickHouse server with a self-signed certificate without providing + * any trust material. {@link SSLMode#Trust} makes the client accept any server + * certificate and skip hostname verification. + * + *

    Warning: the connection is encrypted, but the server identity is NOT verified, + * which makes it susceptible to man-in-the-middle attacks. Use this mode only for testing + * or in fully trusted environments. Prefer {@link Client.Builder#setRootCertificate(String)} + * with the signing CA certificate whenever possible.

    + */ + static void connectToSelfSignedServer(String endpoint, String database, String user, String password) { + log.info("Connecting to {} accepting any server certificate (SSLMode.Trust)", endpoint); + try (Client client = new Client.Builder() + .addEndpoint(endpoint) + .setUsername(user) + .setPassword(password) + .setDefaultDatabase(database) + // Accept the self-signed certificate and skip hostname verification. + .setSSLMode(SSLMode.Trust) + .build()) { + + List rows = client.queryAll("SELECT currentUser() AS user, version() AS version"); + log.info("Connected (server certificate not verified) as '{}' to ClickHouse {}", + rows.get(0).getString("user"), rows.get(0).getString("version")); + } catch (Exception e) { + log.error("Connection with SSLMode.Trust failed", e); + } + } + /** * Connects to a ClickHouse server using a custom root CA certificate. * Use this when the server certificate is signed by a private CA (corporate CA, diff --git a/examples/jdbc/README.md b/examples/jdbc/README.md index 51d63327a..b6dd1c88a 100644 --- a/examples/jdbc/README.md +++ b/examples/jdbc/README.md @@ -24,10 +24,16 @@ Addition options can be passed to the application: ## SSL Examples -`com.clickhouse.examples.jdbc.SSLExamples` shows how to connect securely to a server whose -certificate is signed by a custom (private) CA. Only the CA certificate is passed with the -`sslrootcert` connection property - no trust store configuration is required, and the JVM default -trust store stays untouched. +`com.clickhouse.examples.jdbc.SSLExamples` shows how to connect securely to a server: + +- **Custom CA certificate** - the server certificate is signed by a custom (private) CA. Only the + CA certificate is passed with the `sslrootcert` connection property (as a file path or directly + as a PEM string) - no trust store configuration is required, and the JVM default trust store + stays untouched. +- **Self-signed certificate without verification** - the `ssl_mode=trust` connection property + (`ssl_mode=none` is accepted as an alias) accepts any server certificate and skips hostname + verification. The connection is encrypted, but the server identity is not verified - use it only + for testing or in fully trusted environments. The example runs in one of two modes. @@ -57,7 +63,8 @@ mvn exec:java -Dexec.mainClass="com.clickhouse.examples.jdbc.SSLExamples" \ -DchRootCert="/path/to/ca.crt" ``` -`-DchRootCert` is required in this mode and must point to the CA certificate in PEM format. +`-DchRootCert` must point to the CA certificate in PEM format. When it is omitted, only the +self-signed (`ssl_mode=trust`) example runs - useful when you do not have the CA certificate at hand. ### Setting up a Docker dev instance with a self-signed certificate manually 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 11a022fb5..f6a1cef1f 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 @@ -27,6 +27,10 @@ *
  • Passing the CA certificate as a PEM string instead of a file path - useful when the * certificate comes from an environment variable or a secret manager (typical for * Kubernetes/cloud deployments) and you do not want to write it to disk.
  • + *
  • Connecting to a server with a self-signed certificate without any trust material - + * 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.
  • * * *

    More SSL examples (mTLS, trust stores, SNI) will be added to this class later.

    @@ -45,7 +49,8 @@ *
  • {@code chUrl} - ClickHouse JDBC URL, e.g. {@code jdbc:clickhouse://my-host:8443/default}. * When set, standalone mode is used
  • *
  • {@code chUser} and {@code chPassword} - credentials (standalone mode)
  • - *
  • {@code chRootCert} - path to the root CA certificate in PEM format (required in standalone mode)
  • + *
  • {@code chRootCert} - path to the root CA certificate in PEM format. When omitted in + * standalone mode, only the self-signed (trust) example runs
  • *
  • {@code chImage} - Docker image for local mode, default {@code clickhouse/clickhouse-server:latest}
  • * */ @@ -60,18 +65,19 @@ public static void main(String[] args) { final String user = System.getProperty("chUser", "default"); final String password = System.getProperty("chPassword", ""); final String rootCert = trimToNull(System.getProperty("chRootCert")); - if (rootCert == null) { - log.error("chRootCert is required when chUrl is set. " - + "Pass the path to the CA certificate (PEM) that signed the server certificate."); - return; - } log.info("Running in standalone mode against {}", url); try { - connectWithCustomRootCertificate(url, user, password, rootCert); - connectWithRootCertificateAsString(url, user, password, rootCert); + connectToSelfSignedServer(url, user, password); + if (rootCert != null) { + connectWithCustomRootCertificate(url, user, password, rootCert); + connectWithRootCertificateAsString(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."); + } } catch (SQLException | IOException e) { - log.error("Secure connection with a custom root CA certificate failed", e); + log.error("Secure connection failed", e); } return; } @@ -81,6 +87,8 @@ public static void main(String[] args) { final String image = System.getProperty("chImage", "clickhouse/clickhouse-server:latest"); log.info("Running in local mode (set -DchUrl to verify your own server)"); try (SecureServerSupport server = SecureServerSupport.start(image)) { + connectToSelfSignedServer(server.getJdbcUrl(), + SecureServerSupport.USER, SecureServerSupport.PASSWORD); connectWithCustomRootCertificate(server.getJdbcUrl(), SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath()); connectWithRootCertificateAsString(server.getJdbcUrl(), @@ -93,6 +101,37 @@ public static void main(String[] args) { Runtime.getRuntime().exit(0); } + /** + * Connects to a ClickHouse server with a self-signed certificate without providing + * any trust material. The {@code ssl_mode=trust} connection property makes the driver + * accept any server certificate and skip hostname verification. The traditional JDBC + * value {@code ssl_mode=none} is accepted as an alias. + * + *

    Warning: the connection is encrypted, but the server identity is NOT verified, + * which makes it susceptible to man-in-the-middle attacks. Use this mode only for testing + * or in fully trusted environments. Prefer {@code sslrootcert} with the signing CA + * certificate whenever possible.

    + */ + static void connectToSelfSignedServer(String url, String user, String password) throws SQLException { + log.info("Connecting to {} accepting any server certificate (ssl_mode=trust)", 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 + // Accept the self-signed certificate and skip hostname verification. + properties.setProperty(ClientConfigProperties.SSL_MODE.getKey(), "trust"); // ssl_mode + + 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 (server certificate not verified) as '{}' to ClickHouse {}", + rs.getString("user"), rs.getString("version")); + } + } + } + /** * Connects to a ClickHouse server using a custom root CA certificate. * Use this when the server certificate is signed by a private CA (corporate CA, From 12e3b7415a2c16089b389b05dfac9f263eb8b88d Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Mon, 15 Jun 2026 11:14:22 -0700 Subject: [PATCH 3/6] updated tests and docs --- .../client/api/internal/HttpAPIClientHelper.java | 7 +++++++ .../src/test/java/com/clickhouse/client/ClientTests.java | 7 ++++--- .../java/com/clickhouse/client/HttpTransportTests.java | 9 +++++++++ docs/features.md | 8 ++++++-- 4 files changed, 26 insertions(+), 5 deletions(-) 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 67122b36b..4f5698537 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 @@ -173,6 +173,13 @@ public SSLContext createSSLContext(Map configuration) { final String sslCertificate = (String) configuration.get(ClientConfigProperties.SSL_CERTIFICATE.getKey()); final String sslKey = (String) configuration.get(ClientConfigProperties.SSL_KEY.getKey()); + // This method is only reached when a secure (https) endpoint is configured, so SSLMode.Disabled + // contradicts the endpoint scheme. The mode does not turn encryption off - the scheme decides it. + if (sslMode == SSLMode.Disabled) { + throw new ClientMisconfigurationException("SSL mode '" + SSLMode.Disabled + + "' cannot be used with a secure (https) endpoint. Use SSLMode.Trust to trust all certificates or use plain HTTP"); + } + if (sslMode == SSLMode.Trust) { // Server certificate is not validated. Trust material (trust store or CA certificate) // is not needed, but client certificate and key are still applied for mTLS. diff --git a/client-v2/src/test/java/com/clickhouse/client/ClientTests.java b/client-v2/src/test/java/com/clickhouse/client/ClientTests.java index eaa675349..862dcb12a 100644 --- a/client-v2/src/test/java/com/clickhouse/client/ClientTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/ClientTests.java @@ -332,7 +332,7 @@ public void testDefaultSettings() { Assert.assertEquals(config.get(p.getKey()), p.getDefaultValue(), "Default value doesn't match"); } } - Assert.assertEquals(config.size(), 34); // to check everything is set. Increment when new added. + Assert.assertEquals(config.size(), 35); // to check everything is set. Increment when new added. } try (Client client = new Client.Builder() @@ -365,7 +365,7 @@ public void testDefaultSettings() { .setSocketSndbuf(100000) .build()) { Map config = client.getConfiguration(); - Assert.assertEquals(config.size(), 35); // to check everything is set. Increment when new added. + Assert.assertEquals(config.size(), 36); // to check everything is set. Increment when new added. Assert.assertEquals(config.get(ClientConfigProperties.DATABASE.getKey()), "mydb"); Assert.assertEquals(config.get(ClientConfigProperties.MAX_EXECUTION_TIME.getKey()), "10"); Assert.assertEquals(config.get(ClientConfigProperties.COMPRESSION_LZ4_UNCOMPRESSED_BUF_SIZE.getKey()), "300000"); @@ -389,6 +389,7 @@ public void testDefaultSettings() { Assert.assertEquals(config.get(ClientConfigProperties.SOCKET_OPERATION_TIMEOUT.getKey()), "20000"); Assert.assertEquals(config.get(ClientConfigProperties.SOCKET_RCVBUF_OPT.getKey()), "100000"); Assert.assertEquals(config.get(ClientConfigProperties.SOCKET_SNDBUF_OPT.getKey()), "100000"); + Assert.assertEquals(config.get(ClientConfigProperties.SSL_MODE.getKey()), "Strict"); } } @@ -432,7 +433,7 @@ public void testWithOldDefaults() { Assert.assertEquals(config.get(p.getKey()), p.getDefaultValue(), "Default value doesn't match"); } } - Assert.assertEquals(config.size(), 34); // to check everything is set. Increment when new added. + Assert.assertEquals(config.size(), 35); // to check everything is set. Increment when new added. } } diff --git a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java index c91e1f55c..7b2bdb569 100644 --- a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java @@ -389,6 +389,15 @@ public void testSSLModeDisabled() { } catch (Exception e) { Assert.fail("Disabled SSL mode should work with a plain HTTP endpoint", e); } + + ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); + // Disabled mode contradicts a secure (https) endpoint - the scheme decides encryption, not the mode + Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setSSLMode(SSLMode.Disabled) + .build()); } @Test(groups = { "integration" }) diff --git a/docs/features.md b/docs/features.md index be63e9f99..13bb50807 100644 --- a/docs/features.md +++ b/docs/features.md @@ -5,7 +5,8 @@ This document lists stable, user-visible behavior in `client-v2` and `jdbc-v2` t ## `client-v2` - 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. +- 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` (encrypt but accept any server certificate and skip hostname verification, while still applying a client certificate/key for mTLS if configured), `VerifyCa` (validate the certificate chain but skip hostname verification), and `Strict` (full chain and hostname verification, default). - 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. @@ -41,13 +42,15 @@ Compatibility-sensitive traits: - `Geometry` handling is shape-sensitive: supported values are 1D through 4D Java arrays representing the nested geometry variants, and unsupported shapes or non-array values are rejected during serialization. - `Geometry` write inference is dimension-based rather than fully type-specific: point, ring/line string, polygon/multi-line string, and multi-polygon are selected from array depth, so writing `Geometry` cannot currently distinguish `Ring` from `LineString` or `Polygon` from `MultiLineString`. - Session precedence is part of the contract: client session defaults apply to each request, operation settings may override them, and only the client `session_id` is mutable at runtime while other client session properties remain fixed for the lifetime of the client. +- SSL mode behavior is compatibility-sensitive: the default is `Strict`. `ssl_mode` does not enable or disable encryption - the endpoint scheme decides that. `Disabled` is only valid with a plain `http://` endpoint; combining it with an `https://` endpoint throws `ClientMisconfigurationException`. A CA certificate and a trust store cannot be configured together (the CA certificate must be imported into the trust store), and that combination also throws `ClientMisconfigurationException`. When reading the `ssl_mode` value through the client configuration map, enum names are matched case-sensitively (`Disabled`, `Trust`, `VerifyCa`, `Strict`). +- Certificate-as-content support is compatibility-sensitive: any certificate or key value containing a PEM begin marker (`-----BEGIN`) is treated as inline PEM content, otherwise it is treated as a file path (also searched in the home directory and on the classpath). ## `jdbc-v2` - 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. +- 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`, `verifyca`, `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. - 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. @@ -85,4 +88,5 @@ Compatibility-sensitive traits: - `getString()` formatting for temporal values is stable output: `Date` uses `yyyy-MM-dd`, `DateTime` uses `yyyy-MM-dd HH:mm:ss`, and `DateTime64` preserves fractional precision, all interpreted in server timezone context where applicable. - Date and timestamp setters with `Calendar` are timezone-sensitive by design. Preserving the current day-shift and instant-preserving behavior is important for compatibility. - `setObject()` temporal behavior is specific and should not drift: `LocalDateTime` and `Instant` are rendered through `fromUnixTimestamp64Nano(...)`, while `Timestamp` and `Date` use quoted textual forms. +- JDBC `ssl_mode` handling is compatibility-sensitive: values are case-insensitive, `none` is aliased to `trust` (the no-verification mode), and an unrecognized value throws `SQLException` during connection configuration. The normalized canonical mode name is forwarded to the underlying `client-v2` transport. - INSERT result semantics depend on server-side `async_insert` and `wait_for_async_insert`. The driver does not override these settings, so it follows whatever the server profile or user configuration sets. When `async_insert=1` and `wait_for_async_insert=0`, `Statement.executeUpdate(...)` and `PreparedStatement.executeUpdate(...)` may return `0` (or an under-counted value), and parsing/data errors in the INSERT body may not be reported synchronously as a `SQLException`. Set `async_insert=0` (or `wait_for_async_insert=1`) per connection or statement to restore synchronous row counts and error reporting. From ea14e4ff0fc9178acba834389e66f7b57dd52808 Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Tue, 16 Jun 2026 19:18:41 -0700 Subject: [PATCH 4/6] Made building ssl context in builder way. Renamed SSLMode constants to match java naming convention. Misc fixes. --- .../ClickHouseDefaultSslContextProvider.java | 59 ++-- .../com/clickhouse/client/api/Client.java | 26 +- .../client/api/ClientConfigProperties.java | 2 +- .../clickhouse/client/api/enums/SSLMode.java | 31 +- .../api/internal/HttpAPIClientHelper.java | 72 ++--- .../api/internal/SslContextProvider.java | 281 ++++++++++++++++++ .../com/clickhouse/client/ClientTests.java | 2 +- .../clickhouse/client/HttpTransportTests.java | 61 +++- .../com/clickhouse/client/SettingsTests.java | 67 +++++ docs/features.md | 6 +- examples/client-v2/README.md | 4 +- .../examples/client_v2/SSLExamples.java | 12 +- .../jdbc/internal/JdbcConfiguration.java | 2 +- .../com/clickhouse/jdbc/ConnectionTest.java | 7 +- .../jdbc/internal/JdbcConfigurationTest.java | 20 +- 15 files changed, 512 insertions(+), 140 deletions(-) create mode 100644 client-v2/src/main/java/com/clickhouse/client/api/internal/SslContextProvider.java diff --git a/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java b/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java index 9b0a2a12b..cf114cb26 100644 --- a/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java +++ b/clickhouse-client/src/main/java/com/clickhouse/client/config/ClickHouseDefaultSslContextProvider.java @@ -154,38 +154,14 @@ public SSLContext getJavaSslContext(ClickHouseConfig config) throws SSLException } public SSLContext getSslContextFromCerts(String clientCert, String clientKey, String sslRootCert) throws SSLException { - return getSslContextFromCerts(ClickHouseSslMode.STRICT, clientCert, clientKey, sslRootCert); - } - - /** - * Creates an SSL context from certificates with an explicit SSL mode. - * With {@link ClickHouseSslMode#NONE} the server certificate is not validated, while client - * certificate and key are still used (if provided) so that mTLS keeps working. - * - * @param sslMode ssl mode - * @param clientCert client certificate for mTLS, file path or PEM content; may be null - * @param clientKey client private key for mTLS, file path or PEM content; may be null - * @param sslRootCert CA certificate to validate the server certificate, file path or PEM content; may be null - * @return SSL context - * @throws SSLException when the context cannot be created - */ - public SSLContext getSslContextFromCerts(ClickHouseSslMode sslMode, String clientCert, String clientKey, - String sslRootCert) throws SSLException { - return getSslContextImpl(sslMode, clientCert, clientKey, sslRootCert, null, null, KeyStore.getDefaultType()); + return getSslContextImpl(ClickHouseSslMode.STRICT, + clientCert, clientKey, sslRootCert, null, null, KeyStore.getDefaultType()); } public SSLContext getSslContextFromKeyStore(String truststorePath, String truststorePassword, String keyStoreType) throws SSLException { return getSslContextImpl(ClickHouseSslMode.STRICT, null, null, null, truststorePath, truststorePassword, keyStoreType); } - private KeyManager[] getKeyManagers(String clientCert, String clientKey) - throws NoSuchAlgorithmException, InvalidKeySpecException, IOException, CertificateException, - KeyStoreException, UnrecoverableKeyException { - KeyManagerFactory factory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - factory.init(getKeyStore(clientCert, clientKey), null); - return factory.getKeyManagers(); - } - private SSLContext getSslContextImpl(ClickHouseSslMode sslMode, String clientCert, String clientKey, String sslRootCert, String truststorePath, String truststorePassword, String keyStoreType) throws SSLException { SSLContext ctx; try { @@ -196,29 +172,34 @@ private SSLContext getSslContextImpl(ClickHouseSslMode sslMode, String clientCer if (sslMode == ClickHouseSslMode.NONE) { tms = new TrustManager[]{new NonValidatingTrustManager()}; - // client certificate and key are independent from server verification - keep mTLS working - kms = clientCert != null && !clientCert.isEmpty() ? getKeyManagers(clientCert, clientKey) - : new KeyManager[0]; + kms = new KeyManager[0]; sr = new SecureRandom(); } else if (sslMode == ClickHouseSslMode.STRICT) { - if (clientCert != null && !clientCert.isEmpty()) { - kms = getKeyManagers(clientCert, clientKey); - } - if (truststorePath != null && !truststorePath.isEmpty()) { + try (InputStream in = ClickHouseUtils.getFileInputStream(truststorePath)) { KeyStore myTrustStore = KeyStore.getInstance(keyStoreType); - myTrustStore.load(in, truststorePassword == null ? null : truststorePassword.toCharArray()); + myTrustStore.load(in, truststorePassword.toCharArray()); TrustManagerFactory factory = TrustManagerFactory .getInstance(TrustManagerFactory.getDefaultAlgorithm()); factory.init(myTrustStore); tms = factory.getTrustManagers(); + + } + } else { + if (clientCert != null && !clientCert.isEmpty()) { + KeyManagerFactory factory = KeyManagerFactory + .getInstance(KeyManagerFactory.getDefaultAlgorithm()); + factory.init(getKeyStore(clientCert, clientKey), null); + kms = factory.getKeyManagers(); + } + + if (sslRootCert != null && !sslRootCert.isEmpty()) { + TrustManagerFactory factory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + factory.init(getKeyStore(sslRootCert, null)); + tms = factory.getTrustManagers(); } - } else if (sslRootCert != null && !sslRootCert.isEmpty()) { - TrustManagerFactory factory = TrustManagerFactory - .getInstance(TrustManagerFactory.getDefaultAlgorithm()); - factory.init(getKeyStore(sslRootCert, null)); - tms = factory.getTrustManagers(); } sr = new SecureRandom(); 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 2df5ced77..84c47e55b 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 @@ -761,12 +761,13 @@ public Builder setClientKey(String path) { * *

    Supported modes:

    *
      - *
    • {@link SSLMode#Disabled} - SSL is not used; only meaningful with plain protocols
    • - *
    • {@link SSLMode#Trust} - encrypt, but accept any server certificate and skip + *
    • {@link SSLMode#DISABLED} - SSL is not used; only meaningful with plain protocols
    • + *
    • {@link SSLMode#TRUST} - encrypt, but accept any server certificate and skip + * hostname verification; a configured trust store or CA certificate is ignored (a warning + * is logged), while a client certificate/key is still applied for mTLS
    • + *
    • {@link SSLMode#VERIFY_CA} - validate the server certificate chain, but skip * hostname verification
    • - *
    • {@link SSLMode#VerifyCa} - validate the server certificate chain, but skip - * hostname verification
    • - *
    • {@link SSLMode#Strict} - full verification of the certificate chain and the + *
    • {@link SSLMode#STRICT} - full verification of the certificate chain and the * hostname (default)
    • *
    * @@ -1168,6 +1169,21 @@ public Client build() { throw new ClientMisconfigurationException("Trust store and certificates cannot be used together"); } + // A trust store and a CA certificate are not rejected here: for VERIFY_CA/STRICT the trust + // store takes precedence and the CA certificate is ignored with a warning (see createSSLContext). + + // SSLMode.DISABLED does not turn encryption off - the endpoint scheme decides that. So it + // contradicts a secure (https) endpoint and must be rejected here, before the client is created. + if (SSLMode.DISABLED.name().equals(configuration.get(ClientConfigProperties.SSL_MODE.getKey()))) { + for (Endpoint endpoint : this.endpoints) { + if ("https".equalsIgnoreCase(endpoint.getURI().getScheme())) { + throw new ClientMisconfigurationException("SSL mode '" + SSLMode.DISABLED + + "' cannot be used with a secure (https) endpoint. Use '" + SSLMode.TRUST + + "' to trust all certificates or use plain HTTP."); + } + } + } + // Check timezone settings String useTimeZoneValue = this.configuration.get(ClientConfigProperties.USE_TIMEZONE.getKey()); String serverTimeZoneValue = this.configuration.get(ClientConfigProperties.SERVER_TIMEZONE.getKey()); 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 68b86b694..7008a18ff 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 @@ -116,7 +116,7 @@ public enum ClientConfigProperties { SSL_CERTIFICATE("sslcert", String.class), - SSL_MODE("ssl_mode", SSLMode.class, SSLMode.Strict.name()), + SSL_MODE("ssl_mode", SSLMode.class, SSLMode.STRICT.name()), RETRY_ON_FAILURE("retry", Integer.class, "3"), diff --git a/client-v2/src/main/java/com/clickhouse/client/api/enums/SSLMode.java b/client-v2/src/main/java/com/clickhouse/client/api/enums/SSLMode.java index 03a3bacae..f5d64392c 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/enums/SSLMode.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/enums/SSLMode.java @@ -9,40 +9,43 @@ * *

    Modes from the least to the most strict:

    *
      - *
    • {@link #Disabled} - SSL is not used. Plain protocols only.
    • - *
    • {@link #Trust} - encryption is used, but the server certificate chain is not validated - * and the hostname is not verified. Susceptible to MITM attacks - use only for testing or in - * fully trusted environments.
    • - *
    • {@link #VerifyCa} - the server certificate chain is validated against the trust material + *
    • {@link #DISABLED} - SSL is not used. Plain protocols only.
    • + *
    • {@link #TRUST} - the hostname is not verified and any server certificate is accepted, which + * is susceptible to MITM attacks - use that only for testing or in fully trusted environments. A + * configured trust store or CA certificate has no effect in this mode and is ignored (a warning is + * logged); a configured client certificate/key is still applied for mTLS.
    • + *
    • {@link #VERIFY_CA} - the server certificate chain is validated against the trust material * (default JVM trust store, configured trust store, or a CA certificate), but the hostname is * not checked against the certificate.
    • - *
    • {@link #Strict} - full verification (default): certificate chain is validated and the + *
    • {@link #STRICT} - full verification (default): certificate chain is validated and the * hostname must match the certificate.
    • *
    */ public enum SSLMode { /** - * SSL is not used. Connection is not encrypted. + * SSL is not used. Connection is not encrypted. Doesn't work with HTTPS. + * Reserved for TCP where protocol doesn't define encryption. */ - Disabled, + DISABLED, /** - * Encryption without verification: any server certificate is accepted and - * the hostname is not verified. + * The hostname is not verified and any server certificate is accepted. A configured trust store or + * CA certificate has no effect in this mode and is ignored (a warning is logged). A configured + * client certificate/key is still applied for mTLS. */ - Trust, + TRUST, /** * Server certificate chain is validated, but the hostname is not verified. */ - VerifyCa, + VERIFY_CA, /** * Full verification: certificate chain is validated and the hostname must match - * the certificate. Default mode. + * the certificate. Default mode for HTTPs. */ - Strict; + STRICT; /** * Case-insensitive variant of {@link #valueOf(String)}. 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 4f5698537..974808c76 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 @@ -12,10 +12,8 @@ import com.clickhouse.client.api.ServerException; import com.clickhouse.client.api.enums.ProxyType; import com.clickhouse.client.api.enums.SSLMode; -import com.clickhouse.client.config.ClickHouseSslMode; import com.clickhouse.client.api.http.ClickHouseHttpProto; import com.clickhouse.client.api.transport.Endpoint; -import com.clickhouse.client.config.ClickHouseDefaultSslContextProvider; import com.clickhouse.data.ClickHouseFormat; import net.jpountz.lz4.LZ4Factory; import org.apache.commons.compress.compressors.CompressorStreamFactory; @@ -69,7 +67,6 @@ import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SNIHostName; import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLException; import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSocket; import java.io.IOException; @@ -87,7 +84,6 @@ import java.net.URLEncoder; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; -import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.Base64; import java.util.Collection; @@ -133,7 +129,7 @@ public class HttpAPIClientHelper { LZ4Factory lz4Factory; - private final ClickHouseDefaultSslContextProvider sslContextProvider = new ClickHouseDefaultSslContextProvider(); + private final SslContextProvider sslContextProvider = new SslContextProvider(); public HttpAPIClientHelper(Map configuration, Object metricsRegistry, boolean initSslContext, LZ4Factory lz4Factory) { this.metricsRegistry = metricsRegistry; @@ -161,56 +157,46 @@ public HttpAPIClientHelper(Map configuration, Object metricsRegi * @return SSLContext */ public SSLContext createSSLContext(Map configuration) { - SSLContext sslContext; - try { - sslContext = SSLContext.getDefault(); - } catch (NoSuchAlgorithmException e) { - throw new ClientException("Failed to create default SSL context", e); - } final SSLMode sslMode = ClientConfigProperties.SSL_MODE.getOrDefault(configuration); final String trustStorePath = (String) configuration.get(ClientConfigProperties.SSL_TRUST_STORE.getKey()); final String caCertificate = (String) configuration.get(ClientConfigProperties.CA_CERTIFICATE.getKey()); final String sslCertificate = (String) configuration.get(ClientConfigProperties.SSL_CERTIFICATE.getKey()); final String sslKey = (String) configuration.get(ClientConfigProperties.SSL_KEY.getKey()); - // This method is only reached when a secure (https) endpoint is configured, so SSLMode.Disabled - // contradicts the endpoint scheme. The mode does not turn encryption off - the scheme decides it. - if (sslMode == SSLMode.Disabled) { - throw new ClientMisconfigurationException("SSL mode '" + SSLMode.Disabled - + "' cannot be used with a secure (https) endpoint. Use SSLMode.Trust to trust all certificates or use plain HTTP"); + SslContextProvider.Builder builder = sslContextProvider.builder(); + + // The client certificate/key (mTLS) are independent of how the server certificate is verified, + // so they are applied whenever configured, regardless of the SSL mode. + if (sslCertificate != null && !sslCertificate.isEmpty()) { + builder.clientCertificate(sslCertificate, sslKey); } - if (sslMode == SSLMode.Trust) { - // Server certificate is not validated. Trust material (trust store or CA certificate) - // is not needed, but client certificate and key are still applied for mTLS. - try { - sslContext = sslContextProvider.getSslContextFromCerts(ClickHouseSslMode.NONE, - sslCertificate, sslKey, null); - } catch (SSLException e) { - throw new ClientMisconfigurationException("Failed to create SSL context for the Trust SSL mode", e); + if (sslMode == SSLMode.TRUST) { + // TRUST accepts any server certificate and skips the hostname check (the latter is applied + // where the connection socket factory is created). A configured trust store or CA + // certificate has no effect in this mode and is ignored with a warning. + if (trustStorePath != null || caCertificate != null) { + LOG.warn("SSL mode '{}' trusts any server certificate; the configured {} is ignored.", + SSLMode.TRUST, trustStorePath != null ? "trust store" : "CA certificate"); } + builder.trustAllCertificates(); } else if (trustStorePath != null) { + // VERIFY_CA / STRICT: validate against the trust store. A trust store and a CA certificate + // cannot both take effect, so the CA certificate is ignored with a warning. if (caCertificate != null) { - throw new ClientMisconfigurationException("CA certificate cannot be used together with a trust store." - + " The CA certificate should be imported into the trust store instead."); - } - try { - sslContext = sslContextProvider.getSslContextFromKeyStore( - trustStorePath, - (String) configuration.get(ClientConfigProperties.SSL_KEY_STORE_PASSWORD.getKey()), - (String) configuration.get(ClientConfigProperties.SSL_KEYSTORE_TYPE.getKey()) - ); - } catch (SSLException e) { - throw new ClientMisconfigurationException("Failed to create SSL context from a keystore", e); - } - } else if (caCertificate != null || sslCertificate != null|| sslKey != null) { - try { - sslContext = sslContextProvider.getSslContextFromCerts(sslCertificate, sslKey, caCertificate); - } catch (SSLException e) { - throw new ClientMisconfigurationException("Failed to create SSL context from certificates", e); + LOG.warn("Both a trust store and a CA certificate are configured; using the trust store and" + + " ignoring the CA certificate. Import the CA certificate into the trust store instead."); } + builder.trustStore(trustStorePath, + (String) configuration.get(ClientConfigProperties.SSL_KEY_STORE_PASSWORD.getKey()), + (String) configuration.get(ClientConfigProperties.SSL_KEYSTORE_TYPE.getKey())); + } else if (caCertificate != null) { + // VERIFY_CA / STRICT: validate against the CA certificate. + builder.rootCertificate(caCertificate); } - return sslContext; + // else VERIFY_CA / STRICT with no trust material: the JVM default trust store is used. + + return builder.build(); } private static final long CONNECTION_INACTIVITY_CHECK = 5000L; @@ -299,7 +285,7 @@ public CloseableHttpClient createHttpClient(boolean initSslContext, Map true); } else { diff --git a/client-v2/src/main/java/com/clickhouse/client/api/internal/SslContextProvider.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/SslContextProvider.java new file mode 100644 index 000000000..65d3dd289 --- /dev/null +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/SslContextProvider.java @@ -0,0 +1,281 @@ +package com.clickhouse.client.api.internal; + +import com.clickhouse.client.api.ClientMisconfigurationException; +import com.clickhouse.data.ClickHouseUtils; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; + +/** + * Builds {@link SSLContext} instances for the {@code client-v2} HTTP transport. + * + *

    This is the {@code client-v2} owned counterpart of the deprecated v1 + * {@code ClickHouseDefaultSslContextProvider}. It is kept separate so the v1 provider can evolve + * (or stay frozen) independently of {@code client-v2}.

    + * + *

    Contexts are assembled through {@link #builder()}, which sets key material (client + * certificate/key for mTLS) and trust material (trust store, CA certificate, or "trust all") + * independently, mirroring the structure of the v1 {@code getSslContextImpl}.

    + */ +public class SslContextProvider { + + // Defaults copied from the v1 com.clickhouse.client.config.ClickHouseDefaults to avoid depending + // on the deprecated v1 configuration classes. + private static final String SSL_PROTOCOL = "TLS"; + private static final String KEY_ALGORITHM = "RSA"; + private static final String CERTIFICATE_TYPE = "X.509"; + + static final String PEM_HEADER_PREFIX = "---BEGIN "; + static final String PEM_HEADER_SUFFIX = " PRIVATE KEY---"; + static final String PEM_FOOTER_PREFIX = "---END "; + + /** Standard PEM encapsulation boundary (RFC 7468). Present in any PEM content, never in a file path. */ + static final String PEM_BEGIN_MARKER = "-----BEGIN"; + + /** + * Opens a stream over PEM material that may be supplied either as a file path (also searched in the home + * directory and on the classpath) or directly as PEM content. + * + * @param certOrContent file path or PEM content of a certificate or a private key + * @return stream over the PEM content + * @throws IOException when the value is a path and the file cannot be opened + */ + static InputStream getCertificateInputStream(String certOrContent) throws IOException { + if (certOrContent.contains(PEM_BEGIN_MARKER)) { + return new ByteArrayInputStream(certOrContent.getBytes(StandardCharsets.US_ASCII)); + } + return ClickHouseUtils.getFileInputStream(certOrContent); + } + + /** + * An insecure {@link javax.net.ssl.TrustManager}, that don't validate the + * certificate. + */ + static class NonValidatingTrustManager implements X509TrustManager { + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + @Override + @SuppressWarnings("squid:S4830") + public void checkClientTrusted(X509Certificate[] certs, String authType) { + // ignore + } + + @Override + @SuppressWarnings("squid:S4830") + public void checkServerTrusted(X509Certificate[] certs, String authType) { + // ignore + } + } + + static String getAlgorithm(String header, String defaultAlg) { + int startIndex = header.indexOf(PEM_HEADER_PREFIX); + int endIndex = startIndex < 0 ? startIndex + : header.indexOf(PEM_HEADER_SUFFIX, (startIndex += PEM_HEADER_PREFIX.length())); + return startIndex < endIndex ? header.substring(startIndex, endIndex) : defaultAlg; + } + + public static PrivateKey getPrivateKey(String keyFile) + throws NoSuchAlgorithmException, InvalidKeySpecException, IOException { + String algorithm = KEY_ALGORITHM; + StringBuilder builder = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(getCertificateInputStream(keyFile)))) { + String line = reader.readLine(); + if (line != null) { + algorithm = getAlgorithm(line, algorithm); + + while ((line = reader.readLine()) != null) { + if (line.contains(PEM_FOOTER_PREFIX)) { + break; + } + + builder.append(line); + } + } + } + byte[] encoded = Base64.getDecoder().decode(builder.toString()); + KeyFactory kf = KeyFactory.getInstance(algorithm); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded); + return kf.generatePrivate(keySpec); + } + + public KeyStore getKeyStore(String cert, String key) throws NoSuchAlgorithmException, InvalidKeySpecException, + IOException, CertificateException, KeyStoreException { + final KeyStore ks; + try { + ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); // needed to initialize the key store + } catch (KeyStoreException e) { + throw new NoSuchAlgorithmException( + String.format("%s KeyStore not available", KeyStore.getDefaultType())); + } + + try (InputStream in = getCertificateInputStream(cert)) { + CertificateFactory factory = CertificateFactory.getInstance(CERTIFICATE_TYPE); + if (key == null || key.isEmpty()) { + int index = 1; + for (Certificate c : factory.generateCertificates(in)) { + ks.setCertificateEntry("cert" + (index++), c); + } + } else { + Certificate[] certChain = factory.generateCertificates(in).toArray(new Certificate[0]); + ks.setKeyEntry("key", getPrivateKey(key), null, certChain); + } + } + return ks; + } + + /** + * Creates a new {@link Builder} for assembling an {@link SSLContext}. Key material (client + * certificate/key for mTLS) and trust material (trust store, CA certificate, or "trust all") are + * configured independently, mirroring the structure of the v1 {@code getSslContextImpl}. + * + * @return new builder + */ + public Builder builder() { + return new Builder(); + } + + /** + * Assembles an {@link SSLContext} from independently configured key and trust material. + * + *

    The two are orthogonal:

    + *
      + *
    • {@link #clientCertificate(String, String)} sets the client certificate/key applied for + * mTLS; it is independent of how the server certificate is verified.
    • + *
    • {@link #trustAllCertificates()}, {@link #trustStore(String, String, String)} and + * {@link #rootCertificate(String)} are mutually exclusive trust strategies; the last one set + * wins. When none is set, the JVM default trust store is used.
    • + *
    + */ + public class Builder { + + private String clientCert; + private String clientKey; + private String trustStorePath; + private String trustStorePassword; + private String trustStoreType; + private String rootCertificate; + private boolean trustAll; + + /** + * Sets the client certificate and key applied for mutual TLS. Independent of the trust strategy. + * + * @param clientCert client certificate, file path or PEM content; may be null + * @param clientKey client private key, file path or PEM content; may be null + * @return this builder + */ + public Builder clientCertificate(String clientCert, String clientKey) { + this.clientCert = clientCert; + this.clientKey = clientKey; + return this; + } + + /** + * Trust strategy: accept any server certificate without validating it (no server identity check). + * + * @return this builder + */ + public Builder trustAllCertificates() { + this.trustAll = true; + return this; + } + + /** + * Trust strategy: validate the server certificate against the given trust store. + * + * @param path trust store file path + * @param password trust store password; may be null + * @param type trust store type; when null or empty the JVM default type is used + * @return this builder + */ + public Builder trustStore(String path, String password, String type) { + this.trustStorePath = path; + this.trustStorePassword = password; + this.trustStoreType = type; + return this; + } + + /** + * Trust strategy: validate the server certificate against the given CA certificate. + * + * @param rootCertificate CA certificate, file path or PEM content; may be null + * @return this builder + */ + public Builder rootCertificate(String rootCertificate) { + this.rootCertificate = rootCertificate; + return this; + } + + /** + * Builds the SSL context from the configured key and trust material. + * + * @return SSL context + * @throws ClientMisconfigurationException when the context cannot be created + */ + public SSLContext build() { + try { + KeyManager[] kms = null; + if (clientCert != null && !clientCert.isEmpty()) { + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(getKeyStore(clientCert, clientKey), null); + kms = kmf.getKeyManagers(); + } + + TrustManager[] tms = null; + if (trustAll) { + tms = new TrustManager[]{new NonValidatingTrustManager()}; + } else if (trustStorePath != null && !trustStorePath.isEmpty()) { + String type = trustStoreType == null || trustStoreType.isEmpty() + ? KeyStore.getDefaultType() : trustStoreType; + try (InputStream in = ClickHouseUtils.getFileInputStream(trustStorePath)) { + KeyStore trustStore = KeyStore.getInstance(type); + trustStore.load(in, trustStorePassword == null ? null : trustStorePassword.toCharArray()); + TrustManagerFactory tmf = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + tms = tmf.getTrustManagers(); + } + } else if (rootCertificate != null && !rootCertificate.isEmpty()) { + TrustManagerFactory tmf = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(getKeyStore(rootCertificate, null)); + tms = tmf.getTrustManagers(); + } + + SSLContext ctx = SSLContext.getInstance(SSL_PROTOCOL); + ctx.init(kms, tms, new SecureRandom()); + return ctx; + } catch (GeneralSecurityException | IOException e) { + throw new ClientMisconfigurationException("Failed to create SSL context", e); + } + } + } +} diff --git a/client-v2/src/test/java/com/clickhouse/client/ClientTests.java b/client-v2/src/test/java/com/clickhouse/client/ClientTests.java index 862dcb12a..43b2d0e80 100644 --- a/client-v2/src/test/java/com/clickhouse/client/ClientTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/ClientTests.java @@ -389,7 +389,7 @@ public void testDefaultSettings() { Assert.assertEquals(config.get(ClientConfigProperties.SOCKET_OPERATION_TIMEOUT.getKey()), "20000"); Assert.assertEquals(config.get(ClientConfigProperties.SOCKET_RCVBUF_OPT.getKey()), "100000"); Assert.assertEquals(config.get(ClientConfigProperties.SOCKET_SNDBUF_OPT.getKey()), "100000"); - Assert.assertEquals(config.get(ClientConfigProperties.SSL_MODE.getKey()), "Strict"); + Assert.assertEquals(config.get(ClientConfigProperties.SSL_MODE.getKey()), "STRICT"); } } diff --git a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java index 7b2bdb569..180cc1b70 100644 --- a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java @@ -55,6 +55,7 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.testcontainers.utility.ThrowingFunction; import org.testng.Assert; +import org.testng.SkipException; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; @@ -295,7 +296,7 @@ public void testSecureConnection() { @Test(groups = { "integration" }) public void testSSLModeTrust() { if (isCloud()) { - return; // test uses self-signed cert + throw new SkipException("Test uses a self-signed certificate, not applicable to cloud"); } ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); @@ -314,19 +315,48 @@ public void testSSLModeTrust() { .addEndpoint("https://localhost:" + secureServer.getPort()) .setUsername("default") .setPassword(ClickHouseServerForTest.getPassword()) - .setOption(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.Trust.name()) + .setOption(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.TRUST.name()) .build()) { List records = client.queryAll("SELECT timezone()"); Assert.assertEquals(records.get(0).getString(1), "UTC"); } catch (Exception e) { Assert.fail("Trust SSL mode should accept a self-signed certificate", e); } + + // Trust mode ignores a configured trust store (a warning is logged). A non-existent path proves + // the trust store is never loaded - the connection still succeeds by trusting any certificate. + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setSSLMode(SSLMode.TRUST) + .setSSLTrustStore("non-existent-trust-store.jks") + .build()) { + List records = client.queryAll("SELECT timezone()"); + Assert.assertEquals(records.get(0).getString(1), "UTC"); + } catch (Exception e) { + Assert.fail("Trust SSL mode should ignore a configured trust store", e); + } + + // Trust mode ignores a configured CA certificate as well (a warning is logged). + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .setSSLMode(SSLMode.TRUST) + .setRootCertificate("non-existent-ca.crt") + .build()) { + List records = client.queryAll("SELECT timezone()"); + Assert.assertEquals(records.get(0).getString(1), "UTC"); + } catch (Exception e) { + Assert.fail("Trust SSL mode should ignore a configured CA certificate", e); + } } @Test(groups = { "integration" }) public void testSSLModeVerifyCa() { if (isCloud()) { - return; // test uses self-signed cert + throw new SkipException("Test uses a self-signed certificate, not applicable to cloud"); } ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); @@ -350,7 +380,7 @@ public void testSSLModeVerifyCa() { .setUsername("default") .setPassword(ClickHouseServerForTest.getPassword()) .setRootCertificate(serverCertificate) - .setSSLMode(SSLMode.VerifyCa) + .setSSLMode(SSLMode.VERIFY_CA) .build()) { List records = client.queryAll("SELECT timezone()"); Assert.assertEquals(records.get(0).getString(1), "UTC"); @@ -363,7 +393,7 @@ public void testSSLModeVerifyCa() { .addEndpoint(endpointByIp) .setUsername("default") .setPassword(ClickHouseServerForTest.getPassword()) - .setSSLMode(SSLMode.VerifyCa) + .setSSLMode(SSLMode.VERIFY_CA) .build()) { Assert.expectThrows(Exception.class, () -> client.queryAll("SELECT 1")); } @@ -372,7 +402,7 @@ public void testSSLModeVerifyCa() { @Test(groups = { "integration" }) public void testSSLModeDisabled() { if (isCloud()) { - return; // plain HTTP is not available in cloud + throw new SkipException("Plain HTTP is not available on cloud"); } ClickHouseNode server = getServer(ClickHouseProtocol.HTTP); @@ -382,7 +412,7 @@ public void testSSLModeDisabled() { .addEndpoint("http://" + server.getHost() + ":" + server.getPort()) .setUsername("default") .setPassword(ClickHouseServerForTest.getPassword()) - .setSSLMode(SSLMode.Disabled) + .setSSLMode(SSLMode.DISABLED) .build()) { List records = client.queryAll("SELECT timezone()"); Assert.assertEquals(records.get(0).getString(1), "UTC"); @@ -396,26 +426,33 @@ public void testSSLModeDisabled() { .addEndpoint("https://localhost:" + secureServer.getPort()) .setUsername("default") .setPassword(ClickHouseServerForTest.getPassword()) - .setSSLMode(SSLMode.Disabled) + .setSSLMode(SSLMode.DISABLED) .build()); } @Test(groups = { "integration" }) public void testSSLModeStrictWithTrustStoreAndCaCertificate() { if (isCloud()) { - return; + throw new SkipException("Test uses a self-signed certificate, not applicable to cloud"); } ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); - // CA certificate cannot be combined with a trust store - it should be in the trust store already - Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder() + // A trust store and a CA certificate cannot both take effect: the trust store is used and the + // CA certificate is ignored (a warning is logged). The connection still succeeds via the trust store. + try (Client client = new Client.Builder() .addEndpoint("https://localhost:" + secureServer.getPort()) .setUsername("default") .setPassword(ClickHouseServerForTest.getPassword()) .setSSLTrustStore("containers/clickhouse-server/certs/KeyStore.jks") + .setSSLTrustStorePassword("iloveclickhouse") .setRootCertificate("containers/clickhouse-server/certs/localhost.crt") - .build()); + .build()) { + List records = client.queryAll("SELECT timezone()"); + Assert.assertEquals(records.get(0).getString(1), "UTC"); + } catch (Exception e) { + Assert.fail("Trust store should be used when a CA certificate is also configured", e); + } } @Test(groups = { "integration" }, dataProvider = "NoResponseFailureProvider") diff --git a/client-v2/src/test/java/com/clickhouse/client/SettingsTests.java b/client-v2/src/test/java/com/clickhouse/client/SettingsTests.java index 81261dc33..113b33090 100644 --- a/client-v2/src/test/java/com/clickhouse/client/SettingsTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/SettingsTests.java @@ -1,13 +1,21 @@ package com.clickhouse.client; import com.clickhouse.client.api.ClientConfigProperties; +import com.clickhouse.client.api.ClientMisconfigurationException; import com.clickhouse.client.api.Session; +import com.clickhouse.client.api.enums.SSLMode; import com.clickhouse.client.api.insert.InsertSettings; import com.clickhouse.client.api.internal.ServerSettings; +import com.clickhouse.client.api.internal.SslContextProvider; import com.clickhouse.client.api.query.QuerySettings; import org.testng.Assert; import org.testng.annotations.Test; +import javax.net.ssl.SSLContext; +import java.io.File; +import java.io.OutputStream; +import java.nio.file.Files; +import java.security.KeyStore; import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Collections; @@ -25,6 +33,65 @@ void testClientSettings() { Assert.assertEquals(listB, source); } + @Test + void testSSLModeFromValue() { + // Every constant resolves from its exact name and is case-insensitive. + for (SSLMode mode : SSLMode.values()) { + Assert.assertEquals(SSLMode.fromValue(mode.name()), mode); + Assert.assertEquals(SSLMode.fromValue(mode.name().toLowerCase()), mode); + Assert.assertEquals(SSLMode.fromValue(mode.name().toUpperCase()), mode); + } + + // VERIFY_CA matches only with the underscore - matching does not normalize separators. + Assert.assertEquals(SSLMode.fromValue("verify_ca"), SSLMode.VERIFY_CA); + Assert.assertEquals(SSLMode.fromValue("Verify_Ca"), SSLMode.VERIFY_CA); + Assert.assertThrows(IllegalArgumentException.class, () -> SSLMode.fromValue("verifyca")); + + // Unknown and null values are rejected. + Assert.assertThrows(IllegalArgumentException.class, () -> SSLMode.fromValue("insecure")); + Assert.assertThrows(IllegalArgumentException.class, () -> SSLMode.fromValue("")); + Assert.assertThrows(IllegalArgumentException.class, () -> SSLMode.fromValue(null)); + } + + @Test + void testSslContextFromKeyStore() throws Exception { + SslContextProvider provider = new SslContextProvider(); + final String type = "PKCS12"; + final String password = "secret"; + File trustStore = createEmptyTrustStore(type, password); + try { + // Happy path: a readable trust store with the right password yields a usable TLS context. + SSLContext ctx = provider.builder().trustStore(trustStore.getAbsolutePath(), password, type).build(); + Assert.assertNotNull(ctx); + Assert.assertEquals(ctx.getProtocol(), "TLS"); + + // Wrong password fails the integrity check. + Assert.assertThrows(ClientMisconfigurationException.class, + () -> provider.builder().trustStore(trustStore.getAbsolutePath(), "wrong", type).build()); + + // Missing file. + Assert.assertThrows(ClientMisconfigurationException.class, + () -> provider.builder().trustStore(trustStore.getAbsolutePath() + ".missing", password, type).build()); + + // Unknown keystore type. + Assert.assertThrows(ClientMisconfigurationException.class, + () -> provider.builder().trustStore(trustStore.getAbsolutePath(), password, "NOT_A_TYPE").build()); + } finally { + Files.deleteIfExists(trustStore.toPath()); + } + } + + private static File createEmptyTrustStore(String type, String password) throws Exception { + KeyStore ks = KeyStore.getInstance(type); + ks.load(null, null); + File file = File.createTempFile("client-v2-truststore", "." + type.toLowerCase()); + file.deleteOnExit(); + try (OutputStream out = Files.newOutputStream(file.toPath())) { + ks.store(out, password.toCharArray()); + } + return file; + } + @Test void testMergeSettings() { { diff --git a/docs/features.md b/docs/features.md index 13bb50807..8522e6201 100644 --- a/docs/features.md +++ b/docs/features.md @@ -6,7 +6,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` (encrypt but accept any server certificate and skip hostname verification, while still applying a client certificate/key for mTLS if configured), `VerifyCa` (validate the certificate chain but skip hostname verification), and `Strict` (full chain and hostname verification, default). +- 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). - 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. @@ -42,7 +42,7 @@ Compatibility-sensitive traits: - `Geometry` handling is shape-sensitive: supported values are 1D through 4D Java arrays representing the nested geometry variants, and unsupported shapes or non-array values are rejected during serialization. - `Geometry` write inference is dimension-based rather than fully type-specific: point, ring/line string, polygon/multi-line string, and multi-polygon are selected from array depth, so writing `Geometry` cannot currently distinguish `Ring` from `LineString` or `Polygon` from `MultiLineString`. - Session precedence is part of the contract: client session defaults apply to each request, operation settings may override them, and only the client `session_id` is mutable at runtime while other client session properties remain fixed for the lifetime of the client. -- SSL mode behavior is compatibility-sensitive: the default is `Strict`. `ssl_mode` does not enable or disable encryption - the endpoint scheme decides that. `Disabled` is only valid with a plain `http://` endpoint; combining it with an `https://` endpoint throws `ClientMisconfigurationException`. A CA certificate and a trust store cannot be configured together (the CA certificate must be imported into the trust store), and that combination also throws `ClientMisconfigurationException`. When reading the `ssl_mode` value through the client configuration map, enum names are matched case-sensitively (`Disabled`, `Trust`, `VerifyCa`, `Strict`). +- SSL mode behavior is compatibility-sensitive: the default is `STRICT`. `ssl_mode` does not enable or disable encryption - the endpoint scheme decides that. `DISABLED` is only valid with a plain `http://` endpoint; combining it with an `https://` endpoint throws `ClientMisconfigurationException`. `TRUST` accepts any server certificate and skips hostname verification; a configured trust store or CA certificate has no effect in this mode and is ignored (a warning is logged), while a client certificate/key is still applied for mTLS. For `VERIFY_CA` and `STRICT`, a trust store and a CA certificate cannot both take effect: when both are configured the trust store is used and the CA certificate is ignored (a warning is logged). A trust store and a client certificate (`sslcert`) still cannot be configured together and throw `ClientMisconfigurationException`. When reading the `ssl_mode` value through the client configuration map, enum names are matched case-sensitively (`DISABLED`, `TRUST`, `VERIFY_CA`, `STRICT`). - Certificate-as-content support is compatibility-sensitive: any certificate or key value containing a PEM begin marker (`-----BEGIN`) is treated as inline PEM content, otherwise it is treated as a file path (also searched in the home directory and on the classpath). @@ -50,7 +50,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`, `verifyca`, `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. - 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/README.md b/examples/client-v2/README.md index 4ed978c00..e807c07c3 100644 --- a/examples/client-v2/README.md +++ b/examples/client-v2/README.md @@ -84,7 +84,7 @@ Notes: CA certificate is passed to the client with `Client.Builder.setRootCertificate()` (as a file path or directly as a PEM string) - no trust store configuration is required, and the JVM default trust store stays untouched. -- **Self-signed certificate without verification** - `Client.Builder.setSSLMode(SSLMode.Trust)` +- **Self-signed certificate without verification** - `Client.Builder.setSSLMode(SSLMode.TRUST)` accepts any server certificate and skips hostname verification. The connection is encrypted, but the server identity is not verified - use it only for testing or in fully trusted environments. @@ -119,7 +119,7 @@ mvn exec:java -Dexec.mainClass="com.clickhouse.examples.client_v2.SSLExamples" \ ``` `-DchRootCert` must point to the CA certificate in PEM format. When it is omitted, only the -self-signed (`SSLMode.Trust`) example runs - useful when you do not have the CA certificate at hand. +self-signed (`SSLMode.TRUST`) example runs - useful when you do not have the CA certificate at hand. ### Setting up a Docker dev instance with a self-signed certificate manually 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 2f87f281d..520b82a03 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 @@ -24,7 +24,7 @@ * certificate comes from an environment variable or a secret manager (typical for * Kubernetes/cloud deployments) and you do not want to write it to disk. *
  • Connecting to a server with a self-signed certificate without any trust material - - * {@link SSLMode#Trust} accepts any server certificate and skips hostname verification. + * {@link SSLMode#TRUST} accepts any server certificate and skips hostname verification. * Use it only for testing or in fully trusted environments.
  • * * @@ -46,7 +46,7 @@ *
  • {@code chDatabase} - database name, default {@code default}
  • *
  • {@code chUser} and {@code chPassword} - credentials (standalone mode)
  • *
  • {@code chRootCert} - path to the root CA certificate in PEM format. When omitted in - * standalone mode, only the self-signed (Trust) example runs
  • + * standalone mode, only the self-signed (TRUST) example runs *
  • {@code chImage} - Docker image for local mode, default {@code clickhouse/clickhouse-server:latest}
  • * */ @@ -95,7 +95,7 @@ public static void main(String[] args) { /** * Connects to a ClickHouse server with a self-signed certificate without providing - * any trust material. {@link SSLMode#Trust} makes the client accept any server + * any trust material. {@link SSLMode#TRUST} makes the client accept any server * certificate and skip hostname verification. * *

    Warning: the connection is encrypted, but the server identity is NOT verified, @@ -104,21 +104,21 @@ public static void main(String[] args) { * with the signing CA certificate whenever possible.

    */ static void connectToSelfSignedServer(String endpoint, String database, String user, String password) { - log.info("Connecting to {} accepting any server certificate (SSLMode.Trust)", endpoint); + log.info("Connecting to {} accepting any server certificate (SSLMode.TRUST)", endpoint); try (Client client = new Client.Builder() .addEndpoint(endpoint) .setUsername(user) .setPassword(password) .setDefaultDatabase(database) // Accept the self-signed certificate and skip hostname verification. - .setSSLMode(SSLMode.Trust) + .setSSLMode(SSLMode.TRUST) .build()) { List rows = client.queryAll("SELECT currentUser() AS user, version() AS version"); log.info("Connected (server certificate not verified) as '{}' to ClickHouse {}", rows.get(0).getString("user"), rows.get(0).getString("version")); } catch (Exception e) { - log.error("Connection with SSLMode.Trust failed", e); + log.error("Connection with SSLMode.TRUST failed", e); } } diff --git a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java index 0dd15e2da..99669b981 100644 --- a/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java +++ b/jdbc-v2/src/main/java/com/clickhouse/jdbc/internal/JdbcConfiguration.java @@ -385,7 +385,7 @@ private void buildFinalProperties(Map urlProperties, Properties if (sslMode != null) { if ("none".equalsIgnoreCase(sslMode)) { // JDBC drivers traditionally use 'none' for the no-verification SSL mode - alias it to 'trust' - clientProperties.put(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.Trust.name()); + clientProperties.put(ClientConfigProperties.SSL_MODE.getKey(), SSLMode.TRUST.name()); } else { try { // values are case-insensitive in JDBC - normalize before passing to the client diff --git a/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java b/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java index 8972ff834..1b3d5cdae 100644 --- a/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java +++ b/jdbc-v2/src/test/java/com/clickhouse/jdbc/ConnectionTest.java @@ -13,6 +13,7 @@ import com.github.tomakehurst.wiremock.common.ConsoleNotifier; import com.github.tomakehurst.wiremock.core.WireMockConfiguration; import org.testng.Assert; +import org.testng.SkipException; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; @@ -766,7 +767,7 @@ public void testSecureConnection() throws Exception { @Test(groups = { "integration" }) public void testSSLModeTrust() throws Exception { if (isCloud()) { - return; // this test uses self-signed cert + throw new SkipException("Test uses a self-signed certificate, not applicable to cloud"); } ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); String jdbcUrl = "jdbc:clickhouse:" + secureServer.getBaseUri(); @@ -803,7 +804,7 @@ public void testSSLModeTrust() throws Exception { @Test(groups = { "integration" }) public void testSSLModeVerifyCa() throws Exception { if (isCloud()) { - return; // this test uses self-signed cert + throw new SkipException("Test uses a self-signed certificate, not applicable to cloud"); } ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); // server certificate has CN=localhost, so connecting via 127.0.0.1 fails hostname verification @@ -824,7 +825,7 @@ public void testSSLModeVerifyCa() throws Exception { }); // verify_ca: certificate chain is validated, hostname mismatch is ignored - properties.put(ClientConfigProperties.SSL_MODE.getKey(), "verifyca"); + properties.put(ClientConfigProperties.SSL_MODE.getKey(), "verify_ca"); try (Connection conn = new ConnectionImpl(jdbcUrl, properties); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT number FROM system.numbers LIMIT 10")) { 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 08d80748f..eba15719c 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 @@ -176,16 +176,16 @@ public void testConfigurationProperties() throws Exception { public Object[][] sslModeValues() { return new Object[][] { // input value, expected client property value - { "none", SSLMode.Trust.name() }, // JDBC alias for the no-verification mode - { "NONE", SSLMode.Trust.name() }, - { "disabled", SSLMode.Disabled.name() }, - { "Disabled", SSLMode.Disabled.name() }, - { "trust", SSLMode.Trust.name() }, - { "Trust", SSLMode.Trust.name() }, - { "verifyca", SSLMode.VerifyCa.name() }, - { "VERIFYCA", SSLMode.VerifyCa.name() }, - { "strict", SSLMode.Strict.name() }, - { "Strict", SSLMode.Strict.name() }, + { "none", SSLMode.TRUST.name() }, // JDBC alias for the no-verification mode + { "NONE", SSLMode.TRUST.name() }, + { "disabled", SSLMode.DISABLED.name() }, + { "Disabled", SSLMode.DISABLED.name() }, + { "trust", SSLMode.TRUST.name() }, + { "Trust", SSLMode.TRUST.name() }, + { "verify_ca", SSLMode.VERIFY_CA.name() }, + { "VERIFY_CA", SSLMode.VERIFY_CA.name() }, + { "strict", SSLMode.STRICT.name() }, + { "Strict", SSLMode.STRICT.name() }, }; } From ae870c1f4b0b827765a0b5a05a8ed0f5b4d808e7 Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Tue, 16 Jun 2026 19:32:18 -0700 Subject: [PATCH 5/6] Fixed problem of disabled verification --- .../com/clickhouse/client/api/Client.java | 31 +++++++++++---- .../client/api/ClientBuilderTest.java | 39 +++++++++++++++++++ docs/features.md | 2 +- 3 files changed, 63 insertions(+), 9 deletions(-) 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 84c47e55b..7ce51f064 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 @@ -1172,14 +1172,29 @@ public Client build() { // A trust store and a CA certificate are not rejected here: for VERIFY_CA/STRICT the trust // store takes precedence and the CA certificate is ignored with a warning (see createSSLContext). - // SSLMode.DISABLED does not turn encryption off - the endpoint scheme decides that. So it - // contradicts a secure (https) endpoint and must be rejected here, before the client is created. - if (SSLMode.DISABLED.name().equals(configuration.get(ClientConfigProperties.SSL_MODE.getKey()))) { - for (Endpoint endpoint : this.endpoints) { - if ("https".equalsIgnoreCase(endpoint.getURI().getScheme())) { - throw new ClientMisconfigurationException("SSL mode '" + SSLMode.DISABLED - + "' cannot be used with a secure (https) endpoint. Use '" + SSLMode.TRUST - + "' to trust all certificates or use plain HTTP."); + // Resolve ssl_mode case-insensitively and normalize it to the canonical enum name so that + // downstream parsing is consistent and an unknown value is reported as a misconfiguration + // here instead of failing later with a generic enum-parsing error. + String sslModeValue = configuration.get(ClientConfigProperties.SSL_MODE.getKey()); + if (sslModeValue != null) { + SSLMode sslMode; + try { + sslMode = SSLMode.fromValue(sslModeValue); + } catch (IllegalArgumentException e) { + throw new ClientMisconfigurationException("Invalid value '" + sslModeValue + "' for '" + + ClientConfigProperties.SSL_MODE.getKey() + "'", e); + } + configuration.put(ClientConfigProperties.SSL_MODE.getKey(), sslMode.name()); + + // SSLMode.DISABLED does not turn encryption off - the endpoint scheme decides that. So it + // contradicts a secure (https) endpoint and must be rejected here, before the client is created. + if (sslMode == SSLMode.DISABLED) { + for (Endpoint endpoint : this.endpoints) { + if ("https".equalsIgnoreCase(endpoint.getURI().getScheme())) { + throw new ClientMisconfigurationException("SSL mode '" + SSLMode.DISABLED + + "' cannot be used with a secure (https) endpoint. Use '" + SSLMode.TRUST + + "' to trust all certificates or use plain HTTP."); + } } } } 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 044a37de0..36489ecc5 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 @@ -1,5 +1,6 @@ package com.clickhouse.client.api; +import com.clickhouse.client.api.enums.SSLMode; import org.testng.Assert; import org.testng.annotations.Test; @@ -22,6 +23,44 @@ public void testAddEndpointToleratesUnderscoreHostname() throws Exception { } } + @Test + public void testSslModeDisabledRejectedForHttpsRegardlessOfCase() { + // Value supplied as a raw (non-canonical case) string via setOption must still be recognized + // as DISABLED and rejected with ClientMisconfigurationException for an https endpoint. + for (String value : new String[] { "DISABLED", "disabled", "Disabled" }) { + Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .setOption(ClientConfigProperties.SSL_MODE.getKey(), value) + .build()); + } + } + + @Test + public void testSslModeInvalidValueRejected() { + Assert.expectThrows(ClientMisconfigurationException.class, () -> new Client.Builder() + .addEndpoint("https://localhost:8443") + .setUsername("default") + .setPassword("") + .setOption(ClientConfigProperties.SSL_MODE.getKey(), "insecure") + .build()); + } + + @Test + public void testSslModeNormalizedToCanonicalName() throws Exception { + // A non-canonical case value is accepted and normalized to the canonical enum name. + try (Client client = new Client.Builder() + .addEndpoint("http://localhost:8123") + .setUsername("default") + .setPassword("") + .setOption(ClientConfigProperties.SSL_MODE.getKey(), "trust") + .build()) { + Assert.assertEquals(client.getConfiguration().get(ClientConfigProperties.SSL_MODE.getKey()), + SSLMode.TRUST.name()); + } + } + private static String extractFirstEndpointUri(Client client) throws Exception { Field endpointsField = Client.class.getDeclaredField("endpoints"); endpointsField.setAccessible(true); diff --git a/docs/features.md b/docs/features.md index 8522e6201..29cdfb8e4 100644 --- a/docs/features.md +++ b/docs/features.md @@ -42,7 +42,7 @@ Compatibility-sensitive traits: - `Geometry` handling is shape-sensitive: supported values are 1D through 4D Java arrays representing the nested geometry variants, and unsupported shapes or non-array values are rejected during serialization. - `Geometry` write inference is dimension-based rather than fully type-specific: point, ring/line string, polygon/multi-line string, and multi-polygon are selected from array depth, so writing `Geometry` cannot currently distinguish `Ring` from `LineString` or `Polygon` from `MultiLineString`. - Session precedence is part of the contract: client session defaults apply to each request, operation settings may override them, and only the client `session_id` is mutable at runtime while other client session properties remain fixed for the lifetime of the client. -- SSL mode behavior is compatibility-sensitive: the default is `STRICT`. `ssl_mode` does not enable or disable encryption - the endpoint scheme decides that. `DISABLED` is only valid with a plain `http://` endpoint; combining it with an `https://` endpoint throws `ClientMisconfigurationException`. `TRUST` accepts any server certificate and skips hostname verification; a configured trust store or CA certificate has no effect in this mode and is ignored (a warning is logged), while a client certificate/key is still applied for mTLS. For `VERIFY_CA` and `STRICT`, a trust store and a CA certificate cannot both take effect: when both are configured the trust store is used and the CA certificate is ignored (a warning is logged). A trust store and a client certificate (`sslcert`) still cannot be configured together and throw `ClientMisconfigurationException`. When reading the `ssl_mode` value through the client configuration map, enum names are matched case-sensitively (`DISABLED`, `TRUST`, `VERIFY_CA`, `STRICT`). +- SSL mode behavior is compatibility-sensitive: the default is `STRICT`. `ssl_mode` does not enable or disable encryption - the endpoint scheme decides that. `DISABLED` is only valid with a plain `http://` endpoint; combining it with an `https://` endpoint throws `ClientMisconfigurationException`. `TRUST` accepts any server certificate and skips hostname verification; a configured trust store or CA certificate has no effect in this mode and is ignored (a warning is logged), while a client certificate/key is still applied for mTLS. For `VERIFY_CA` and `STRICT`, a trust store and a CA certificate cannot both take effect: when both are configured the trust store is used and the CA certificate is ignored (a warning is logged). A trust store and a client certificate (`sslcert`) still cannot be configured together and throw `ClientMisconfigurationException`. `ssl_mode` values are matched case-insensitively and normalized to the canonical enum name (`DISABLED`, `TRUST`, `VERIFY_CA`, `STRICT`) when the client is built; an unrecognized value throws `ClientMisconfigurationException`. - Certificate-as-content support is compatibility-sensitive: any certificate or key value containing a PEM begin marker (`-----BEGIN`) is treated as inline PEM content, otherwise it is treated as a file path (also searched in the home directory and on the classpath). From 683dd2559785fe26c905d6a1d495ec6098f04f8f Mon Sep 17 00:00:00 2001 From: Sergey Chernov Date: Wed, 17 Jun 2026 10:07:23 -0700 Subject: [PATCH 6/6] Added test to verify that strict mode throws exception with no trust store --- .../client/api/internal/HttpAPIClientHelper.java | 5 +++++ .../com/clickhouse/client/HttpTransportTests.java | 15 +++++++++++++++ 2 files changed, 20 insertions(+) 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 974808c76..f3e695c5f 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 @@ -67,6 +67,7 @@ import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SNIHostName; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSocket; import java.io.IOException; @@ -894,6 +895,10 @@ public RuntimeException wrapException(String message, Exception cause, String qu return (RuntimeException) cause; } + if (cause instanceof SSLException) { + return new ClickHouseException("SSL Problem", cause, queryId); + } + if (cause instanceof ConnectionRequestTimeoutException || cause instanceof NoHttpResponseException || cause instanceof ConnectTimeoutException || diff --git a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java index 180cc1b70..6d1a27010 100644 --- a/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java +++ b/client-v2/src/test/java/com/clickhouse/client/HttpTransportTests.java @@ -1,5 +1,6 @@ package com.clickhouse.client; +import com.clickhouse.client.api.ClickHouseException; import com.clickhouse.client.api.Client; import com.clickhouse.client.api.ClientConfigProperties; import com.clickhouse.client.api.ClientException; @@ -7,6 +8,7 @@ import com.clickhouse.client.api.ClientMisconfigurationException; import com.clickhouse.client.api.ConnectionInitiationException; import com.clickhouse.client.api.ConnectionReuseStrategy; +import com.clickhouse.client.api.DataTransferException; import com.clickhouse.client.api.ServerException; import com.clickhouse.client.api.Session; import com.clickhouse.client.api.command.CommandResponse; @@ -59,6 +61,7 @@ import org.testng.annotations.DataProvider; import org.testng.annotations.Test; +import javax.net.ssl.SSLHandshakeException; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.StringWriter; @@ -437,6 +440,18 @@ public void testSSLModeStrictWithTrustStoreAndCaCertificate() { } ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP); + // A trust store and a CA certificate cannot both take effect: the trust store is used and the + // CA certificate is ignored (a warning is logged). The connection still succeeds via the trust store. + try (Client client = new Client.Builder() + .addEndpoint("https://localhost:" + secureServer.getPort()) + .setUsername("default") + .setPassword(ClickHouseServerForTest.getPassword()) + .build()) { + ClientException ex = Assert.expectThrows(ClientException.class, () -> client.queryAll("SELECT timezone()")); + + Assert.assertTrue(ex.getCause() instanceof ClickHouseException); + Assert.assertTrue(ex.getCause().getMessage().startsWith("SSL Problem")); + } // A trust store and a CA certificate cannot both take effect: the trust store is used and the // CA certificate is ignored (a warning is logged). The connection still succeeds via the trust store.