Skip to content
59 changes: 59 additions & 0 deletions client-v2/src/main/java/com/clickhouse/client/api/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -755,6 +756,34 @@
return this;
}

/**
* Defines how strictly the client verifies a server identity on secure connections.
*
* <p>Supported modes:</p>
* <ul>
* <li>{@link SSLMode#DISABLED} - SSL is not used; only meaningful with plain protocols</li>
* <li>{@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</li>
* <li>{@link SSLMode#VERIFY_CA} - validate the server certificate chain, but skip
* hostname verification</li>
* <li>{@link SSLMode#STRICT} - full verification of the certificate chain and the
* hostname (default)</li>
* </ul>
*
* <p>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 <b>not</b> make the client use
* encryption on a plain HTTP endpoint: the endpoint scheme always decides whether the
* connection is encrypted.</p>
*
* @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.
Expand Down Expand Up @@ -1127,7 +1156,7 @@
return this;
}

public Client build() {

Check failure on line 1159 in client-v2/src/main/java/com/clickhouse/client/api/Client.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 26 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ClickHouse_clickhouse-java&issues=AZ7TbFntIRleBA2bJ1df&open=AZ7TbFntIRleBA2bJ1df&pullRequest=2874
// check if endpoint are empty. so can not initiate client
if (this.endpoints.isEmpty()) {
throw new IllegalArgumentException("At least one endpoint is required");
Expand All @@ -1140,6 +1169,36 @@
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).

// 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.");
}
}
}
}
Comment thread
chernser marked this conversation as resolved.

// Check timezone settings
String useTimeZoneValue = this.configuration.get(ClientConfigProperties.USE_TIMEZONE.getKey());
String serverTimeZoneValue = this.configuration.get(ClientConfigProperties.SERVER_TIMEZONE.getKey());
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.clickhouse.client.api.enums;

/**
* Defines how strictly the client verifies a server identity when a secure protocol is used.
*
* <p>The mode affects only connections that are already using a secure transport (for example,
* an {@code https://} endpoint). It does <b>not</b> enable encryption for plain protocols - an
* {@code http://} endpoint stays unencrypted whatever the mode is.</p>
*
* <p>Modes from the least to the most strict:</p>
* <ul>
* <li>{@link #DISABLED} - SSL is not used. Plain protocols only.</li>
* <li>{@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.</li>
* <li>{@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.</li>
* <li>{@link #STRICT} - full verification (default): certificate chain is validated and the
* hostname must match the certificate.</li>
* </ul>
*/
public enum SSLMode {

/**
* SSL is not used. Connection is not encrypted. Doesn't work with HTTPS.
* Reserved for TCP where protocol doesn't define encryption.
*/
DISABLED,

/**
* 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,

/**
* Server certificate chain is validated, but the hostname is not verified.
*/
VERIFY_CA,

/**
* Full verification: certificate chain is validated and the hostname must match
* the certificate. Default mode for HTTPs.
*/
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()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use a predefined map?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • when using map we need to convert value to upper case string
  • equalsIgnoreCase() claimed to be well optimized and doesn't create additional objects
  • using map will produce more code and what I do not like - it will be code outside this method.

if (mode.name().equalsIgnoreCase(value)) {
return mode;
}
}
throw new IllegalArgumentException("Unknown SSL mode '" + value + "'");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
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.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;
Expand Down Expand Up @@ -85,7 +85,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;
Expand Down Expand Up @@ -131,7 +130,7 @@ public class HttpAPIClientHelper {

LZ4Factory lz4Factory;

private final ClickHouseDefaultSslContextProvider sslContextProvider = new ClickHouseDefaultSslContextProvider();
private final SslContextProvider sslContextProvider = new SslContextProvider();

public HttpAPIClientHelper(Map<String, Object> configuration, Object metricsRegistry, boolean initSslContext, LZ4Factory lz4Factory) {
this.metricsRegistry = metricsRegistry;
Expand Down Expand Up @@ -159,34 +158,46 @@ public HttpAPIClientHelper(Map<String, Object> configuration, Object metricsRegi
* @return SSLContext
*/
public SSLContext createSSLContext(Map<String, Object> 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());
if (trustStorePath != null) {
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);

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) {
// 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");
}
} 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);
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) {
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;
Expand Down Expand Up @@ -272,7 +283,11 @@ public CloseableHttpClient createHttpClient(boolean initSslContext, Map<String,
LayeredConnectionSocketFactory sslConnectionSocketFactory;
if (sslContext != null) {
String socketSNI = (String)configuration.get(ClientConfigProperties.SSL_SOCKET_SNI.getKey());
if (socketSNI != null && !socketSNI.trim().isEmpty()) {
SSLMode sslMode = ClientConfigProperties.SSL_MODE.getOrDefault(configuration);
// Trust and VerifyCa skip hostname verification. The same applies when a custom SNI is
// set because the connection hostname will not match the certificate.
boolean trustAllHostnames = sslMode == SSLMode.TRUST || sslMode == SSLMode.VERIFY_CA;
if (socketSNI != null && !socketSNI.trim().isEmpty() || trustAllHostnames) {
sslConnectionSocketFactory = new CustomSSLConnectionFactory(socketSNI, sslContext, (hostname, session) -> true);
} else {
sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext);
Expand Down Expand Up @@ -880,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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we need to change it to ClientException

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be need to be a new TransportException.

  • ConnectionException doesn't match because this current case is not retry-able .
  • May be MisconfigurationException because most probably something is not not configured.

}

if (cause instanceof ConnectionRequestTimeoutException ||
cause instanceof NoHttpResponseException ||
cause instanceof ConnectTimeoutException ||
Expand Down
Loading
Loading