From e49a73e8cece5f344ac9823809cd61dcf43d1b4b Mon Sep 17 00:00:00 2001
From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com>
Date: Thu, 9 Jul 2026 16:00:58 +0000
Subject: [PATCH 1/4] Support application-supplied SSLContext in client-v2 and
jdbc-v2
Adds a way to inject a fully pre-built javax.net.ssl.SSLContext so trust/key
material can be assembled in memory and never written to disk - the use-case
from #2909 (diskless TLS behind connection pools such as HikariCP).
client-v2:
- New ClientConfigProperties.SSL_CONTEXT ("ssl_context"). It holds a live
object, so it is never parsed from or represented as a string.
- Client.Builder.setSSLContext(SSLContext) stores the context and it is
injected into the parsed (object) configuration in the Client constructor.
- HttpAPIClientHelper.createSSLContext returns the supplied context as is,
bypassing the trust/key material builder. ssl_mode still governs server
hostname verification at the socket-factory layer.
jdbc-v2:
- JdbcConfiguration accepts a live SSLContext under the ssl_context key in the
connection Properties (scoped relaxation of the string-only validation; any
other non-string value still throws) and forwards it to the client builder.
Tests: client-v2 ClientBuilderTest (builder plumbing + createSSLContext
short-circuit) and jdbc-v2 JdbcConfigurationTest (Properties capture, forwarding
to the builder, and that other non-string values are still rejected).
Examples (client-v2 and jdbc SSLExamples) and docs/features.md updated.
Fixes: https://github.com/ClickHouse/clickhouse-java/issues/2909
Co-Authored-By: Claude Opus 4.7
---
.../com/clickhouse/client/api/Client.java | 37 ++++++++-
.../client/api/ClientConfigProperties.java | 11 +++
.../api/internal/HttpAPIClientHelper.java | 9 +++
.../client/api/ClientBuilderTest.java | 76 +++++++++++++++++++
docs/features.md | 4 +
.../examples/client_v2/SSLExamples.java | 68 +++++++++++++++++
.../clickhouse/examples/jdbc/SSLExamples.java | 73 ++++++++++++++++++
.../jdbc/internal/JdbcConfiguration.java | 17 +++++
.../jdbc/internal/JdbcConfigurationTest.java | 44 +++++++++++
9 files changed, 337 insertions(+), 2 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 3bf94c529..738efe1c5 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
@@ -80,6 +80,8 @@
import java.util.function.Supplier;
import java.util.stream.Collectors;
+import javax.net.ssl.SSLContext;
+
/**
*
Client is the starting point for all interactions with ClickHouse.
*
@@ -147,8 +149,14 @@ public class Client implements AutoCloseable {
private Client(Collection endpoints, Map configuration,
ExecutorService sharedOperationExecutor, ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy,
- Object metricsRegistry, Supplier queryIdGenerator, CredentialsManager cManager) {
+ Object metricsRegistry, Supplier queryIdGenerator, CredentialsManager cManager,
+ SSLContext sslContext) {
Map parsedConfiguration = new ConcurrentHashMap<>(ClientConfigProperties.parseConfigMap(configuration));
+ // A pre-built SSLContext is a live object and cannot travel through the string-based configuration
+ // map, so it is injected into the parsed (object) configuration directly.
+ if (sslContext != null) {
+ parsedConfiguration.put(ClientConfigProperties.SSL_CONTEXT.getKey(), sslContext);
+ }
this.credentialsManager = cManager;
this.session = Session.extractFrom(parsedConfiguration);
this.configuration = new ConcurrentHashMap<>(parsedConfiguration);
@@ -270,6 +278,7 @@ public static class Builder {
private ColumnToMethodMatchingStrategy columnToMethodMatchingStrategy;
private Object metricRegistry = null;
private Supplier queryIdGenerator;
+ private SSLContext sslContext = null;
public Builder() {
this.endpoints = new HashSet<>();
@@ -785,6 +794,29 @@ public Builder setSSLMode(SSLMode sslMode) {
return this;
}
+ /**
+ * Supplies a pre-built {@link SSLContext} to be used for secure connections instead of one built
+ * from the configured trust/key material (trust store, CA certificate, client certificate/key).
+ *
+ *
When a context is set, the client uses it as is - it is the application's responsibility to
+ * configure it correctly. Trust- and key-material options ({@link Builder#setSSLTrustStore(String)},
+ * {@link Builder#setRootCertificate(String)}, {@link Builder#setClientCertificate(String)}, ...)
+ * are then ignored because they only feed the context the client would otherwise build.
+ * {@link SSLMode} still applies, but only to server hostname verification: {@link SSLMode#TRUST}
+ * and {@link SSLMode#VERIFY_CA} skip the hostname check while {@link SSLMode#STRICT} (default)
+ * enforces it.
+ *
+ *
This is primarily useful when certificates and keys are held in memory (for example, loaded
+ * from a secret store) and must never be written to disk.
+ *
+ * @param sslContext a fully configured SSL context; {@code null} clears any previously set context
+ * @return same instance of the builder
+ */
+ public Builder setSSLContext(SSLContext sslContext) {
+ this.sslContext = sslContext;
+ 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.
@@ -1229,7 +1261,8 @@ public Client build() {
}
return new Client(this.endpoints, this.configuration, this.sharedOperationExecutor,
- this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator, cManager);
+ this.columnToMethodMatchingStrategy, this.metricRegistry, this.queryIdGenerator, cManager,
+ this.sslContext);
}
}
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 9a38232ba..5a6f04e65 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
@@ -8,6 +8,8 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.net.ssl.SSLContext;
+
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -118,6 +120,15 @@ public enum ClientConfigProperties {
SSL_MODE("ssl_mode", SSLMode.class, SSLMode.STRICT.name()),
+ /**
+ * A pre-built {@link javax.net.ssl.SSLContext} supplied by the application. When set, the client uses
+ * it as is instead of building one from the configured trust/key material, and {@link #SSL_MODE} then
+ * only controls server hostname verification. The value is a live object, so it can only be provided
+ * programmatically (for example via {@code Client.Builder#setSSLContext}); it is never parsed from a
+ * string and has no textual representation in a configuration map.
+ */
+ SSL_CONTEXT("ssl_context", SSLContext.class),
+
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 ab4b0153c..fb1f80426 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
@@ -159,6 +159,15 @@ public HttpAPIClientHelper(Map configuration, Object metricsRegi
* @return SSLContext
*/
public SSLContext createSSLContext(Map configuration) {
+ // A pre-built SSLContext supplied by the application is used as is; the client does not build one
+ // from the configured trust/key material. Server hostname verification is still governed by the
+ // SSL mode where the connection socket factory is created (see createHttpClient).
+ final Object customSSLContext = configuration.get(ClientConfigProperties.SSL_CONTEXT.getKey());
+ if (customSSLContext instanceof SSLContext) {
+ LOG.debug("Using application-supplied SSLContext; trust/key material options are ignored.");
+ return (SSLContext) customSSLContext;
+ }
+
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());
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 baccdc767..19238ad85 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,12 +1,16 @@
package com.clickhouse.client.api;
import com.clickhouse.client.api.enums.SSLMode;
+import com.clickhouse.client.api.internal.HttpAPIClientHelper;
import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
+import javax.net.ssl.SSLContext;
import java.lang.reflect.Field;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
public class ClientBuilderTest {
@@ -86,6 +90,78 @@ public void testSslModeInvalidValueRejected() {
.build());
}
+ @Test
+ public void testSetSSLContextStoredInConfiguration() throws Exception {
+ SSLContext customContext = SSLContext.getInstance("TLS");
+ customContext.init(null, null, null);
+
+ try (Client client = new Client.Builder()
+ .addEndpoint("https://localhost:8443")
+ .setUsername("default")
+ .setPassword("")
+ .setSSLContext(customContext)
+ .build()) {
+ Assert.assertSame(extractConfiguration(client).get(ClientConfigProperties.SSL_CONTEXT.getKey()),
+ customContext, "The application-supplied SSLContext should be stored in the configuration");
+ }
+
+ // Without setSSLContext the key must be absent so the client builds its own context.
+ try (Client client = new Client.Builder()
+ .addEndpoint("https://localhost:8443")
+ .setUsername("default")
+ .setPassword("")
+ .build()) {
+ Assert.assertNull(extractConfiguration(client).get(ClientConfigProperties.SSL_CONTEXT.getKey()),
+ "No SSLContext should be stored when none is supplied");
+ }
+ }
+
+ @Test
+ public void testCreateSSLContextReturnsCustomContext() throws Exception {
+ SSLContext customContext = SSLContext.getInstance("TLS");
+ customContext.init(null, null, null);
+
+ try (Client client = new Client.Builder()
+ .addEndpoint("https://localhost:8443")
+ .setUsername("default")
+ .setPassword("")
+ .setSSLContext(customContext)
+ .build()) {
+ HttpAPIClientHelper helper = extractHttpClientHelper(client);
+ SSLContext resolved = helper.createSSLContext(extractConfiguration(client));
+ Assert.assertSame(resolved, customContext,
+ "createSSLContext must return the application-supplied context as is");
+ }
+
+ // When no custom context is configured, createSSLContext builds a context (not the custom one).
+ try (Client client = new Client.Builder()
+ .addEndpoint("https://localhost:8443")
+ .setUsername("default")
+ .setPassword("")
+ .build()) {
+ HttpAPIClientHelper helper = extractHttpClientHelper(client);
+ Map configWithCustom = new HashMap<>(extractConfiguration(client));
+ configWithCustom.put(ClientConfigProperties.SSL_CONTEXT.getKey(), customContext);
+ Assert.assertSame(helper.createSSLContext(configWithCustom), customContext,
+ "createSSLContext must honor a custom context supplied via the configuration map");
+ Assert.assertNotSame(helper.createSSLContext(extractConfiguration(client)), customContext,
+ "createSSLContext must build its own context when none is supplied");
+ }
+ }
+
+ private static HttpAPIClientHelper extractHttpClientHelper(Client client) throws Exception {
+ Field helperField = Client.class.getDeclaredField("httpClientHelper");
+ helperField.setAccessible(true);
+ return (HttpAPIClientHelper) helperField.get(client);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map extractConfiguration(Client client) throws Exception {
+ Field configField = Client.class.getDeclaredField("configuration");
+ configField.setAccessible(true);
+ return (Map) configField.get(client);
+ }
+
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 11a0e4ac4..232c677ac 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -7,6 +7,7 @@ This document lists stable, user-visible behavior in `client-v2` and `jdbc-v2` t
- HTTP and HTTPS connectivity: Connects to ClickHouse over HTTP(S), supports endpoint paths, and exposes a basic `ping` health check.
- TLS configuration: Supports trust stores, client certificates/keys, SSL certificate authentication, and SNI for HTTPS connections. Trust material (root CA and client certificate/key) can be supplied either as a file path or directly as PEM content.
- SSL verification modes: `Client.Builder.setSSLMode(SSLMode)` (or the `ssl_mode` property) controls how strictly the server identity is verified on secure connections: `DISABLED` (SSL not used; plain protocols only), `TRUST` (accept any server certificate and skip hostname verification; a configured trust store or CA certificate is ignored with a warning, while a client certificate/key is still applied for mTLS if configured), `VERIFY_CA` (validate the certificate chain but skip hostname verification), and `STRICT` (full chain and hostname verification, default).
+- Custom SSL context: `Client.Builder.setSSLContext(SSLContext)` supplies a fully pre-built `javax.net.ssl.SSLContext`. When set, the client uses it as is instead of building one from the trust/key material (trust store, CA certificate, client certificate/key), so those options are ignored; `ssl_mode` then only controls server hostname verification. This is primarily for material assembled in memory that must never be written to disk.
- Authentication modes: Supports username/password credentials, ClickHouse auth headers, bearer tokens, and optional HTTP Basic authentication.
- Runtime credential updates: Existing `Client` instances can update username/password or bearer-token credentials for subsequent requests without rebuilding the client.
- Proxy support: Can send requests through configured HTTP proxies, including proxy credentials.
@@ -44,6 +45,7 @@ Compatibility-sensitive traits:
- `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`. `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`.
+- Custom SSL context precedence is compatibility-sensitive: when an application-supplied `SSLContext` is set (`Client.Builder.setSSLContext(SSLContext)`), it is used as is and the trust/key material options (trust store, CA certificate, client certificate/key) are ignored. `ssl_mode` still applies but only to server hostname verification (`TRUST`/`VERIFY_CA` skip it, `STRICT` enforces it). The supplied context is a live object and is never parsed from or represented as a string.
- 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).
- JSONEachRow reading depends on the selected parser factory and request format settings: parser materialization determines Java value types, the reader infers minimal schema from the first row, and JSON number server settings are applied only when `QuerySettings` resolves to `ClickHouseFormat.JSONEachRow` and `json_disable_number_quoting` is enabled.
- JSONEachRow schema inference is intentionally best-effort: scalar values use Java-to-ClickHouse type mappings, while JSON arrays and objects are identified structurally as `Array` and `Map`. For arrays, maps, and some nested or ambiguous values, the inferred type may not include the most specific element, key, value, or nested ClickHouse type.
@@ -54,6 +56,7 @@ Compatibility-sensitive traits:
- JDBC driver registration: Registers through the standard JDBC service mechanism and is available through `DriverManager`.
- JDBC URL parsing: Accepts `jdbc:clickhouse:` and `jdbc:ch:` URLs with host, port, optional HTTP path, optional database, and query parameters.
- SSL URL support: Supports HTTPS connections through URL and property configuration, including default protocol and port handling. The `ssl_mode` property selects the verification strictness (`disabled`, `trust`, `verify_ca`, `strict`); values are case-insensitive and the traditional JDBC value `none` is accepted as an alias for `trust`. Root CA and client certificate/key may be supplied as a file path or as inline PEM content.
+- Custom SSL context via properties: A fully pre-built `javax.net.ssl.SSLContext` may be passed as a live object in the connection `Properties` under the `ssl_context` key (added with `Properties.put`, since it is not a string). It is forwarded to the underlying `client-v2` transport and used as is; `ssl_mode` then only controls hostname verification. This supports diskless, in-memory TLS material behind connection pools that only expose `java.util.Properties`.
- Driver and client properties: Separates JDBC-specific properties from passthrough client options used by the underlying `client-v2` transport.
- DataSource support: Provides a JDBC `DataSource` implementation backed by the same driver configuration model.
- Connection lifecycle: Supports connection close, validity checks, ping-based health checks, and network timeout management.
@@ -93,4 +96,5 @@ Compatibility-sensitive traits:
- 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.
+- Connection `Properties` values must be strings, with one scoped exception: the `ssl_context` key may carry a live `javax.net.ssl.SSLContext` object. Any other non-string property value still throws `IllegalArgumentException` during connection configuration.
- 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.
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 520b82a03..05a6ec15b 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
@@ -5,10 +5,16 @@
import com.clickhouse.client.api.query.GenericRecord;
import lombok.extern.slf4j.Slf4j;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManagerFactory;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
+import java.security.KeyStore;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
import java.util.List;
/**
@@ -26,6 +32,11 @@
*
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.
+ *
Supplying a fully pre-built {@link javax.net.ssl.SSLContext} with
+ * {@link Client.Builder#setSSLContext(javax.net.ssl.SSLContext)} - useful when the trust/key
+ * material is assembled entirely in memory (e.g. fetched and decrypted from a secret store) and
+ * must never be written to disk. The client uses the context as is; {@link SSLMode} then only
+ * controls hostname verification.
*
*
*
More SSL examples (mTLS, trust stores, SNI) will be added to this class later.
@@ -70,6 +81,7 @@ public static void main(String[] args) {
if (rootCert != null) {
connectWithCustomRootCertificate(endpoint, database, user, password, rootCert);
connectWithRootCertificateAsString(endpoint, database, user, password, rootCert);
+ connectWithCustomSSLContext(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.");
@@ -88,6 +100,8 @@ public static void main(String[] args) {
SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath());
connectWithRootCertificateAsString(server.getEndpoint(), database,
SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath());
+ connectWithCustomSSLContext(server.getEndpoint(), database,
+ SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath());
} catch (Exception e) {
log.error("Failed to run the SSL example against a local Docker server", e);
}
@@ -192,6 +206,60 @@ static void connectWithRootCertificateAsString(String endpoint, String database,
}
}
+ /**
+ * Connects using a fully pre-built {@link SSLContext} supplied with
+ * {@link Client.Builder#setSSLContext(SSLContext)}.
+ *
+ *
This mirrors an enterprise use-case where certificates and keys are held only in memory
+ * (for example fetched and decrypted from a secret store) and must never be written to disk.
+ * The whole {@link SSLContext} is assembled by the application and handed to the client, which
+ * uses it as is - the CA certificate, trust store and client certificate/key builder options are
+ * then ignored. {@link SSLMode} still applies to server hostname verification only; here the
+ * default {@link SSLMode#STRICT} keeps full verification because the in-memory trust material
+ * validates the whole certificate chain.
+ */
+ static void connectWithCustomSSLContext(String endpoint, String database, String user, String password,
+ String rootCertPath) {
+ final SSLContext sslContext;
+ try {
+ // Build the trust material entirely in memory. In a real application the PEM bytes would come
+ // from a secret manager; here we read the file generated for this example to keep it runnable.
+ byte[] caPem = Files.readAllBytes(Paths.get(rootCertPath));
+ CertificateFactory cf = CertificateFactory.getInstance("X.509");
+ X509Certificate caCert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(caPem));
+
+ KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ trustStore.load(null, null);
+ trustStore.setCertificateEntry("ca", caCert);
+
+ TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ tmf.init(trustStore);
+
+ sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(null, tmf.getTrustManagers(), null);
+ } catch (Exception e) {
+ log.error("Failed to build the in-memory SSLContext from {}", rootCertPath, e);
+ return;
+ }
+
+ log.info("Connecting to {} using an application-supplied in-memory SSLContext", endpoint);
+ try (Client client = new Client.Builder()
+ .addEndpoint(endpoint)
+ .setUsername(user)
+ .setPassword(password)
+ .setDefaultDatabase(database)
+ // The client uses this context as is; trust/key builder options would be ignored.
+ .setSSLContext(sslContext)
+ .build()) {
+
+ List rows = client.queryAll("SELECT currentUser() AS user, version() AS version");
+ log.info("Connected securely (custom SSLContext) as '{}' to ClickHouse {}",
+ rows.get(0).getString("user"), rows.get(0).getString("version"));
+ } catch (Exception e) {
+ log.error("Secure connection with a custom SSLContext failed", e);
+ }
+ }
+
private static String trimToNull(String value) {
if (value == null) {
return null;
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 f6a1cef1f..1c11e57f0 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
@@ -4,10 +4,17 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManagerFactory;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
@@ -31,6 +38,11 @@
* 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.
+ *
Passing a fully pre-built {@link javax.net.ssl.SSLContext} as a live object in the
+ * connection {@link Properties} under the {@code ssl_context} key - useful when the trust/key
+ * material is assembled entirely in memory (e.g. fetched and decrypted from a secret store) and
+ * must never be written to disk, which is common behind connection pools such as HikariCP. The
+ * driver uses the context as is; {@code ssl_mode} then only controls hostname verification.
*
*
*
More SSL examples (mTLS, trust stores, SNI) will be added to this class later.
@@ -72,6 +84,7 @@ public static void main(String[] args) {
if (rootCert != null) {
connectWithCustomRootCertificate(url, user, password, rootCert);
connectWithRootCertificateAsString(url, user, password, rootCert);
+ connectWithCustomSSLContext(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.");
@@ -93,6 +106,8 @@ public static void main(String[] args) {
SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath());
connectWithRootCertificateAsString(server.getJdbcUrl(),
SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath());
+ connectWithCustomSSLContext(server.getJdbcUrl(),
+ SecureServerSupport.USER, SecureServerSupport.PASSWORD, server.getCaCertPath());
} catch (Exception e) {
log.error("Failed to run the SSL example against a local Docker server", e);
Runtime.getRuntime().exit(-1);
@@ -196,6 +211,64 @@ static void connectWithRootCertificateAsString(String url, String user, String p
}
}
+ /**
+ * Connects by passing a fully pre-built {@link SSLContext} as a live object in the connection
+ * {@link Properties} under the {@code ssl_context} key.
+ *
+ *
This mirrors an enterprise use-case where certificates and keys are held only in memory
+ * (for example fetched and decrypted from a secret store) and must never be written to disk, and
+ * where the application is confined to the {@link java.sql.DriverManager}/{@link Properties} API
+ * behind a connection pool such as HikariCP. Because an {@link SSLContext} cannot be represented
+ * as a string, it is added with {@link Properties#put(Object, Object)} (not
+ * {@link Properties#setProperty(String, String)}). The driver uses the context as is - the
+ * {@code sslrootcert}/{@code sslcert} properties would be ignored - and {@code ssl_mode}
+ * (default {@code strict}) then only controls hostname verification.
+ */
+ static void connectWithCustomSSLContext(String url, String user, String password, String rootCertPath)
+ throws SQLException, IOException {
+ // In a real application the PEM content would typically come from an env variable or a secret
+ // manager; here we read the file generated for this example to keep it runnable.
+ byte[] caPem = Files.readAllBytes(Paths.get(rootCertPath));
+
+ final SSLContext sslContext;
+ try {
+ // Assemble the trust material and the SSLContext entirely in memory.
+ CertificateFactory cf = CertificateFactory.getInstance("X.509");
+ X509Certificate caCert = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(caPem));
+
+ KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ trustStore.load(null, null);
+ trustStore.setCertificateEntry("ca", caCert);
+
+ TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ tmf.init(trustStore);
+
+ sslContext = SSLContext.getInstance("TLS");
+ sslContext.init(null, tmf.getTrustManagers(), null);
+ } catch (GeneralSecurityException | IOException e) {
+ log.error("Failed to build the in-memory SSLContext from {}", rootCertPath, e);
+ return;
+ }
+
+ log.info("Connecting to {} using an application-supplied in-memory SSLContext", 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
+ // The SSLContext is a live object, so it is added with put(...) rather than setProperty(...).
+ properties.put(ClientConfigProperties.SSL_CONTEXT.getKey(), sslContext); // ssl_context
+
+ try (Connection connection = DriverManager.getConnection(url, properties);
+ Statement stmt = connection.createStatement();
+ ResultSet rs = stmt.executeQuery("SELECT currentUser() AS user, version() AS version")) {
+ if (rs.next()) {
+ log.info("Connected securely (custom SSLContext) as '{}' to ClickHouse {}",
+ rs.getString("user"), rs.getString("version"));
+ }
+ }
+ }
+
private static String trimToNull(String value) {
if (value == null) {
return null;
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 99669b981..e3829628a 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
@@ -31,6 +31,8 @@
import java.util.regex.Pattern;
import java.util.stream.Collectors;
+import javax.net.ssl.SSLContext;
+
public class JdbcConfiguration {
public static final Logger LOG = LoggerFactory.getLogger(JdbcConfiguration.class);
@@ -74,6 +76,13 @@ public Map getClientProperties() {
return ImmutableMap.copyOf(clientProperties);
}
+ // A pre-built SSLContext can be passed only as a live object in the connection Properties, so it is
+ // kept apart from the string-only clientProperties and forwarded to the underlying client directly.
+ private SSLContext sslContext;
+ public SSLContext getSslContext() {
+ return sslContext;
+ }
+
private final Map driverProperties;
private final String connectionUrl;
@@ -347,6 +356,11 @@ private void buildFinalProperties(Map urlProperties, Properties
for (Map.Entry
*/
static void connectWithCustomSSLContext(String endpoint, String database, String user, String password,
String rootCertPath) {
@@ -248,7 +248,7 @@ static void connectWithCustomSSLContext(String endpoint, String database, String
.setUsername(user)
.setPassword(password)
.setDefaultDatabase(database)
- // The client uses this context as is; trust/key builder options would be ignored.
+ // The client uses this context as is; trust/key builder options cannot be combined with it.
.setSSLContext(sslContext)
.build()) {
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 1c11e57f0..37586ebfc 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
@@ -221,8 +221,8 @@ static void connectWithRootCertificateAsString(String url, String user, String p
* behind a connection pool such as HikariCP. Because an {@link SSLContext} cannot be represented
* as a string, it is added with {@link Properties#put(Object, Object)} (not
* {@link Properties#setProperty(String, String)}). The driver uses the context as is - the
- * {@code sslrootcert}/{@code sslcert} properties would be ignored - and {@code ssl_mode}
- * (default {@code strict}) then only controls hostname verification.
+ * {@code sslrootcert}/{@code sslcert} properties cannot be combined with it and are rejected - and
+ * {@code ssl_mode} (default {@code strict}) then only controls hostname verification.
*/
static void connectWithCustomSSLContext(String url, String user, String password, String rootCertPath)
throws SQLException, IOException {
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 9d96f3e45..017c2af25 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.ClientMisconfigurationException;
import com.clickhouse.client.api.enums.SSLMode;
import com.clickhouse.data.ClickHouseDataType;
@@ -283,6 +284,22 @@ public void testStringSSLContextViaPropertyRejected() {
() -> new JdbcConfiguration("jdbc:clickhouse://localhost:8123/", properties));
}
+ @Test
+ public void testCustomSSLContextRejectsTrustMaterial() throws Exception {
+ SSLContext customContext = SSLContext.getInstance("TLS");
+ customContext.init(null, null, null);
+
+ Properties properties = new Properties();
+ properties.put(ClientConfigProperties.SSL_CONTEXT.getKey(), customContext);
+ properties.setProperty(ClientConfigProperties.CA_CERTIFICATE.getKey(), "/path/to/ca.crt");
+ JdbcConfiguration configuration = new JdbcConfiguration("jdbc:clickhouse://localhost:8123/", properties);
+
+ Client.Builder builder = new Client.Builder();
+ configuration.applyClientProperties(builder);
+ // A live context forwarded from Properties plus trust/key material is invalid configuration.
+ assertThrows(ClientMisconfigurationException.class, builder::build);
+ }
+
@DataProvider(name = "typeMappingsPropertyKey")
public Object[][] typeMappingsPropertyKey() {
return new Object[][] {
From 7409993c377cc338d360417aca3dc18b097d8c07 Mon Sep 17 00:00:00 2001
From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com>
Date: Fri, 17 Jul 2026 20:53:06 +0000
Subject: [PATCH 4/4] Reject non-SSLContext ssl_context value at point of use
in createHttpClient
Per @chernser review: when the 'ssl_context' configuration value is present but
not a javax.net.ssl.SSLContext instance, HttpAPIClientHelper.createHttpClient now
throws ClientMisconfigurationException instead of silently falling back to building
a context from trust/key material. A null value still builds from material and a
real SSLContext is still used as-is.
Implements: https://github.com/ClickHouse/clickhouse-java/issues/2909
---
.../api/internal/HttpAPIClientHelper.java | 12 +++++++++---
.../client/api/ClientBuilderTest.java | 17 +++++++++++++++++
2 files changed, 26 insertions(+), 3 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 303b5ae0f..f610f2155 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
@@ -285,9 +285,15 @@ public CloseableHttpClient createHttpClient(boolean initSslContext, Map configWithBadContext = new HashMap<>(extractConfiguration(client));
+ configWithBadContext.put(ClientConfigProperties.SSL_CONTEXT.getKey(), "not-a-context");
+ // A non-SSLContext value under 'ssl_context' is a misconfiguration; createHttpClient must
+ // reject it instead of silently building a context from trust/key material.
+ Assert.expectThrows(ClientMisconfigurationException.class,
+ () -> helper.createHttpClient(true, configWithBadContext));
+ }
+ }
+
private static HttpAPIClientHelper extractHttpClientHelper(Client client) throws Exception {
Field helperField = Client.class.getDeclaredField("httpClientHelper");
helperField.setAccessible(true);