From e7fa71efaabe687251c78173670922125633c87f Mon Sep 17 00:00:00 2001 From: Stefan Miklosovic Date: Tue, 31 Mar 2026 12:50:53 +0200 Subject: [PATCH] Implement a guardrail for client driver versions patch by Stefan Miklosovic; reviewed by Andy Tolbert, Bohdan Siryk for CASSANDRA-21146 --- CHANGES.txt | 1 + conf/cassandra.yaml | 21 ++ conf/cassandra_latest.yaml | 21 ++ .../org/apache/cassandra/config/Config.java | 3 + .../cassandra/config/GuardrailsOptions.java | 94 ++++- .../ClientDriverVersionGuardrail.java | 181 +++++++++ .../cassandra/db/guardrails/Guardrails.java | 68 ++++ .../db/guardrails/GuardrailsConfig.java | 13 + .../db/guardrails/GuardrailsMBean.java | 24 ++ .../nodetool/GuardrailsConfigCommand.java | 4 +- .../transport/messages/StartupMessage.java | 6 +- .../GuardrailClientDriverVersionTest.java | 350 ++++++++++++++++++ 12 files changed, 781 insertions(+), 5 deletions(-) create mode 100644 src/java/org/apache/cassandra/db/guardrails/ClientDriverVersionGuardrail.java create mode 100644 test/unit/org/apache/cassandra/db/guardrails/GuardrailClientDriverVersionTest.java diff --git a/CHANGES.txt b/CHANGES.txt index 579b190f00..84812a1cad 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,5 @@ 6.0-alpha2 + * Implement a guardrail for client driver versions (CASSANDRA-21146) * Enable IAuthenticator to declare supported and alterable role options (CASSANDRA-20834) * Avoid capturing lambda allocation in UnfilteredSerializer.deserializeRowBody (CASSANDRA-21289) * Avoid Cell iterator allocation for alive rows in MetricsRecording transformation of ReadCommand (CASSANDRA-21288) diff --git a/conf/cassandra.yaml b/conf/cassandra.yaml index fdfb98403d..692bb0f404 100644 --- a/conf/cassandra.yaml +++ b/conf/cassandra.yaml @@ -2682,6 +2682,27 @@ max_security_label_length: 48 # compressed by dictionary compressor and training_min_frequency is set to 0m (the default when unset). #unset_training_min_frequency_enabled: true +# Minimum client driver versions. Connections from drivers whose version is below +# the configured minimum will be warned or rejected. Connections that do not report +# a driver name or version are considered valid unless 'unset' config entry is specified. +# The map key is the driver name as reported in the native protocol STARTUP message. +# The value is the minimum version string, which has to be a valid semver. +# An operator can use special "unknown" and "unset" entries for both guardrails with a placeholder version. +# A connection will fail / will be warned when a driver id is not among known entries when "unknown" entry is specified. +# A connection will fail / will be warned when a driver id is not set at all when "unset" entry is specified. +#minimum_client_driver_versions_warned: +# DataStax Java Driver: "4.0.0" +# DataStax Python Driver: "3.0.0" +# github.com/apache/cassandra-gocql-driver: "2.0.0" +# unknown: "0.0.0" +# unset: "0.0.0" +#minimum_client_driver_versions_disallowed: +# DataStax Java Driver: "4.0.0" +# DataStax Python Driver: "3.0.0" +# github.com/apache/cassandra-gocql-driver: "2.0.0" +# unknown: "0.0.0" +# unset: "0.0.0" + # Startup Checks are executed as part of Cassandra startup process, not all of them # are configurable (so you can disable them) but these which are enumerated bellow. # Uncomment the startup checks and configure them appropriately to cover your needs. diff --git a/conf/cassandra_latest.yaml b/conf/cassandra_latest.yaml index 382bef78d9..c1948fd296 100644 --- a/conf/cassandra_latest.yaml +++ b/conf/cassandra_latest.yaml @@ -2446,6 +2446,27 @@ default_secondary_index_enabled: true # compressed by dictionary compressor and training_min_frequency is set to 0m (the default when unset). #unset_training_min_frequency_enabled: true +# Minimum client driver versions. Connections from drivers whose version is below +# the configured minimum will be warned or rejected. Connections that do not report +# a driver name or version are considered valid unless 'unset' config entry is specified. +# The map key is the driver name as reported in the native protocol STARTUP message. +# The value is the minimum version string, which has to be a valid semver. +# An operator can use special "unknown" and "unset" entries for both guardrails with a placeholder version. +# A connection will fail / will be warned when a driver id is not among known entries when "unknown" entry is specified. +# A connection will fail / will be warned when a driver id is not set at all when "unset" entry is specified. +#minimum_client_driver_versions_warned: +# DataStax Java Driver: "4.0.0" +# DataStax Python Driver: "3.0.0" +# github.com/apache/cassandra-gocql-driver: "2.0.0" +# unknown: "0.0.0" +# unset: "0.0.0" +#minimum_client_driver_versions_disallowed: +# DataStax Java Driver: "4.0.0" +# DataStax Python Driver: "3.0.0" +# github.com/apache/cassandra-gocql-driver: "2.0.0" +# unknown: "0.0.0" +# unset: "0.0.0" + # Startup Checks are executed as part of Cassandra startup process, not all of them # are configurable (so you can disable them) but these which are enumerated bellow. # Uncomment the startup checks and configure them appropriately to cover your needs. diff --git a/src/java/org/apache/cassandra/config/Config.java b/src/java/org/apache/cassandra/config/Config.java index c777d0fc89..22b3fe2cef 100644 --- a/src/java/org/apache/cassandra/config/Config.java +++ b/src/java/org/apache/cassandra/config/Config.java @@ -1006,6 +1006,9 @@ public class Config public volatile boolean unset_training_min_frequency_warned = true; public volatile boolean unset_training_min_frequency_enabled = true; + public volatile Map minimum_client_driver_versions_warned = Collections.emptyMap(); + public volatile Map minimum_client_driver_versions_disallowed = Collections.emptyMap(); + public volatile int sai_sstable_indexes_per_query_warn_threshold = 32; public volatile int sai_sstable_indexes_per_query_fail_threshold = -1; public volatile DataStorageSpec.LongBytesBound sai_string_term_size_warn_threshold = new DataStorageSpec.LongBytesBound("1KiB"); diff --git a/src/java/org/apache/cassandra/config/GuardrailsOptions.java b/src/java/org/apache/cassandra/config/GuardrailsOptions.java index b7bea893d2..c166e71ec1 100644 --- a/src/java/org/apache/cassandra/config/GuardrailsOptions.java +++ b/src/java/org/apache/cassandra/config/GuardrailsOptions.java @@ -18,15 +18,20 @@ package org.apache.cassandra.config; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.regex.Pattern; import javax.annotation.Nullable; +import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Sets; +import com.vdurmont.semver4j.Semver; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -113,6 +118,8 @@ public class GuardrailsOptions implements GuardrailsConfig false); validatePasswordPolicy(config.password_policy); validateRoleNamePolicy(config.role_name_policy); + validateAndSanitizeClientDriverVersions(config.minimum_client_driver_versions_warned, "minimum_client_driver_versions_warned"); + validateAndSanitizeClientDriverVersions(config.minimum_client_driver_versions_disallowed, "minimum_client_driver_versions_disallowed"); } @Override @@ -195,7 +202,7 @@ public class GuardrailsOptions implements GuardrailsConfig { return config.keyspace_properties_warned; } - + public void setKeyspacePropertiesWarned(Set properties) { updatePropertyWithLogging("keyspace_properties_warned", @@ -209,7 +216,7 @@ public class GuardrailsOptions implements GuardrailsConfig { return config.keyspace_properties_ignored; } - + public void setKeyspacePropertiesIgnored(Set properties) { updatePropertyWithLogging("keyspace_properties_ignored", @@ -223,7 +230,7 @@ public class GuardrailsOptions implements GuardrailsConfig { return config.keyspace_properties_disallowed; } - + public void setKeyspacePropertiesDisallowed(Set properties) { updatePropertyWithLogging("keyspace_properties_disallowed", @@ -1368,6 +1375,34 @@ public class GuardrailsOptions implements GuardrailsConfig return config.unset_training_min_frequency_enabled; } + @Override + public Map getMinimumClientDriverVersionsWarned() + { + return config.minimum_client_driver_versions_warned; + } + + @Override + public Map getMinimumClientDriverVersionsDisallowed() + { + return config.minimum_client_driver_versions_disallowed; + } + + public void setMinimumClientDriverVersionsWarned(Map versions) + { + updatePropertyWithLogging("minimum_client_driver_versions_warned", + versions, + () -> config.minimum_client_driver_versions_warned, + x -> config.minimum_client_driver_versions_warned = x); + } + + public void setMinimumClientDriverVersionsDisallowed(Map versions) + { + updatePropertyWithLogging("minimum_client_driver_versions_disallowed", + versions, + () -> config.minimum_client_driver_versions_disallowed, + x -> config.minimum_client_driver_versions_disallowed = x); + } + private static void updatePropertyWithLogging(String propertyName, T newValue, Supplier getter, Consumer setter) { T oldValue = getter.get(); @@ -1601,4 +1636,57 @@ public class GuardrailsOptions implements GuardrailsConfig { ValueGenerator.getGenerator("role_name_policy", config).generate(ValueValidator.getValidator("role_name_policy", config), Map.of()); } + + @VisibleForTesting + public static void validateAndSanitizeClientDriverVersions(Map map, String guardrailName) + { + if (map == null || map.isEmpty()) + return; + + List invalidEntries = new ArrayList<>(); + + for (Map.Entry entry : map.entrySet()) + { + String sanitized = sanitizeVersion(entry.getValue()); + if (!isValidVersion(sanitized)) + invalidEntries.add(entry.getKey()); + } + + if (!invalidEntries.isEmpty()) + throw new IllegalArgumentException("Invalid version entries for " + guardrailName + " guardrail, they do not follow semver: " + invalidEntries); + + map.replaceAll((driver, version) -> sanitizeVersion(version)); + } + + public static boolean isValidVersion(String version) + { + if (version == null) + return false; + + // try to construct it + try + { + new Semver(version); + } + catch (Throwable t) + { + return false; + } + + return true; + } + + private static final Pattern VERSION_SANITATION_PATTERN = Pattern.compile("^[vV]"); + + public static String sanitizeVersion(String driverVersion) + { + String sanitizedVersionId = driverVersion == null ? null : driverVersion.trim(); + if (sanitizedVersionId != null) + sanitizedVersionId = VERSION_SANITATION_PATTERN.matcher(sanitizedVersionId).replaceFirst(""); + + if (sanitizedVersionId == null || sanitizedVersionId.isBlank()) + return null; + + return sanitizedVersionId; + } } diff --git a/src/java/org/apache/cassandra/db/guardrails/ClientDriverVersionGuardrail.java b/src/java/org/apache/cassandra/db/guardrails/ClientDriverVersionGuardrail.java new file mode 100644 index 0000000000..445fdd17a6 --- /dev/null +++ b/src/java/org/apache/cassandra/db/guardrails/ClientDriverVersionGuardrail.java @@ -0,0 +1,181 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.db.guardrails; + +import java.util.Map; +import java.util.function.Function; + +import javax.annotation.Nullable; + +import com.vdurmont.semver4j.Semver; +import com.vdurmont.semver4j.Semver.SemverType; + +import org.apache.cassandra.config.GuardrailsOptions; +import org.apache.cassandra.service.ClientState; + +/** + * A guardrail that warns or rejects client connections whose driver version + * is below a configured minimum per driver name. + *

+ * The guardrail is configured with maps of driver name to minimum version string. + * Connections that do not report a driver name or version are considered valid + * and are not subject to this guardrail. + *

+ * Version comparison uses semantic versioning via {@link Semver} with loose parsing + * to handle non-standard version strings reported by drivers. + *

+ * This guadrail also reacts on "unknown: 0.0.0" and "unset: 0.0.0" entries in the configuration map. + * When either is set, when a driver does not set its driver id or driver id is not among explicitly enumerated + * in the configuration map, the connection will fail / will be warned respectively. + */ +public class ClientDriverVersionGuardrail extends Predicates +{ + private final Function> warnVersions; + private final Function> disallowVersions; + + public ClientDriverVersionGuardrail(Function> warnVersions, + Function> disallowVersions) + { + super("minimum_client_driver_versions", null, null, null, null); + this.warnVersions = warnVersions; + this.disallowVersions = disallowVersions; + } + + public void guard(@Nullable String driverName, @Nullable String driverVersion, @Nullable ClientState state) + { + if (!enabled(state)) + return; + + Map disallowed = disallowVersions.apply(state); + Map warned = warnVersions.apply(state); + + String sanitizedDriverId = driverName == null ? null : driverName.trim(); + if (sanitizedDriverId == null || sanitizedDriverId.isBlank()) + { + logger.debug("minimum_client_driver_versions guardrail identified empty driver id to check the minimum version of"); + if (disallowed != null && disallowed.containsKey("unset")) + { + fail("Connections with unset driver id's are forbidden.", state); + return; + } + + if (warned != null && warned.containsKey("unset")) + { + warn("Connection with unset driver id was detected."); + } + return; + } + + String sanitizedDriverVersion = GuardrailsOptions.sanitizeVersion(driverVersion); + if (sanitizedDriverVersion == null) + { + sanitizedDriverVersion = "unset"; + logger.debug("minimum_client_driver_versions guardrail identified empty driver version to check the minimum version of"); + } + else if (!GuardrailsOptions.isValidVersion(sanitizedDriverVersion)) + { + logger.debug("minimum_client_driver_versions guardrail identified driver version which is not compliant semver version"); + return; + } + + if (disallowed != null && !disallowed.isEmpty()) + { + String minimumVersionFail = disallowed.get(sanitizedDriverId); + if (minimumVersionFail != null && (sanitizedDriverVersion.equals("unset") || isBelowMinimum(sanitizedDriverVersion, minimumVersionFail))) + { + fail(String.format("Client driver %s, version %s is below required minimum version %s, connection rejected", + sanitizedDriverId, + sanitizedDriverVersion, + minimumVersionFail), + state); + return; + } + else if (minimumVersionFail == null && disallowed.containsKey("unknown")) + { + fail(String.format("Detected unknown driver id: %s.", sanitizedDriverId), state); + return; + } + } + + if (warned != null && !warned.isEmpty()) + { + String minimumVersionWarn = warned.get(sanitizedDriverId); + if (minimumVersionWarn != null && (sanitizedDriverVersion.equals("unset") || isBelowMinimum(sanitizedDriverVersion, minimumVersionWarn))) + { + warn(String.format("Client driver %s, version %s is below recommended minimum version %s", + sanitizedDriverId, + sanitizedDriverVersion, + minimumVersionWarn)); + } + else if (minimumVersionWarn == null && warned.containsKey("unknown")) + { + warn(String.format("Detected unknown driver id: %s", sanitizedDriverId)); + } + } + } + + /** + * This method should not be used in normal circumstances as {@link #guard(String, String, ClientState)} + * should be called directly instead. + * + * @param rawDriverId the value to check, driver name and version delimited by a colon. + * @param state client state + */ + @Override + public void guard(String rawDriverId, @Nullable ClientState state) + { + if (rawDriverId == null) + { + guard(null, null, state); + } + else + { + String[] pair = rawDriverId.trim().split(":"); + if (pair.length != 2) + return; + + String sanitizedDriverName = pair[0].trim(); + if (sanitizedDriverName.isBlank()) + return; + + String sanitizedDriverVersion = pair[1].trim(); + if (sanitizedDriverVersion.isBlank()) + return; + + guard(sanitizedDriverName, sanitizedDriverVersion, state); + } + } + + /** + * Checks if the driver version is below the minimum version + * specified in the config map. If driver name is not in minimum versions, + * such connection is considered to be allowed. + *

+ * + * @param driverVersion version of a driver + * @param minimumVersion minimum allowed version + * @return true if the driver version is lower than the configured minimum + */ + static boolean isBelowMinimum(String driverVersion, String minimumVersion) + { + Semver versionToCheck = new Semver(driverVersion, SemverType.LOOSE); + Semver minimumVersionAllowed = new Semver(minimumVersion, SemverType.LOOSE); + return versionToCheck.isLowerThan(minimumVersionAllowed); + } +} diff --git a/src/java/org/apache/cassandra/db/guardrails/Guardrails.java b/src/java/org/apache/cassandra/db/guardrails/Guardrails.java index 9f0e63bbcd..cea9d59511 100644 --- a/src/java/org/apache/cassandra/db/guardrails/Guardrails.java +++ b/src/java/org/apache/cassandra/db/guardrails/Guardrails.java @@ -19,12 +19,14 @@ package org.apache.cassandra.db.guardrails; import java.util.Collections; +import java.util.Map; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import javax.annotation.Nullable; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableSet; @@ -571,6 +573,16 @@ public final class Guardrails implements GuardrailsMBean isWarning ? "Disk usage in keyspace datacenter exceeds warning threshold" : "Write request failed because disk usage exceeds failure threshold in keyspace datacenter."); + /** + * Guardrail on client driver versions. Warns or rejects connections from drivers + * whose version is below the configured minimum for that driver name. + * Connections that do not report a driver name or version are considered valid. + */ + public static final ClientDriverVersionGuardrail minimumClientDriverVersion = + new ClientDriverVersionGuardrail( + state -> CONFIG_PROVIDER.getOrCreate(state).getMinimumClientDriverVersionsWarned(), + state -> CONFIG_PROVIDER.getOrCreate(state).getMinimumClientDriverVersionsDisallowed()); + /** * Guardrail on passwords for CREATE / ALTER ROLE statements. */ @@ -1853,6 +1865,62 @@ public final class Guardrails implements GuardrailsMBean return DEFAULT_CONFIG.getUnsetTrainingMinFrequencyEnabled(); } + @Override + public String getMinimumClientDriverVersionsWarned() + { + try + { + return JsonUtils.JSON_OBJECT_MAPPER.writeValueAsString(DEFAULT_CONFIG.getMinimumClientDriverVersionsWarned()); + } + catch (Throwable t) + { + throw new RuntimeException("Unable to serialize minimum_client_driver_versions_warned configuration: " + t.getMessage()); + } + } + + @Override + public String getMinimumClientDriverVersionsDisallowed() + { + try + { + return JsonUtils.JSON_OBJECT_MAPPER.writeValueAsString(DEFAULT_CONFIG.getMinimumClientDriverVersionsDisallowed()); + } + catch (Throwable t) + { + throw new RuntimeException("Unable to serialize minimum_client_driver_versions_disallowed configuration: " + t.getMessage()); + } + } + + @Override + public void setMinimumClientDriverVersionsWarned(String value) + { + try + { + Map map = JsonUtils.JSON_OBJECT_MAPPER.readValue(value, new TypeReference<>() {}); + GuardrailsOptions.validateAndSanitizeClientDriverVersions(map, "minimum_client_driver_versions_warned"); + DEFAULT_CONFIG.setMinimumClientDriverVersionsWarned(map); + } + catch (JsonProcessingException t) + { + throw new RuntimeException("Unable to deserialize payload for minimum_client_driver_versions_warned: " + t.getMessage()); + } + } + + @Override + public void setMinimumClientDriverVersionsDisallowed(String value) + { + try + { + Map map = JsonUtils.JSON_OBJECT_MAPPER.readValue(value, new TypeReference<>() {}); + GuardrailsOptions.validateAndSanitizeClientDriverVersions(map, "minimum_client_driver_versions_disallowed"); + DEFAULT_CONFIG.setMinimumClientDriverVersionsDisallowed(map); + } + catch (JsonProcessingException t) + { + throw new RuntimeException("Unable to deserialize minimum_client_driver_versions_disallowed: " + t.getMessage()); + } + } + private static String toCSV(Set values) { return values == null || values.isEmpty() ? "" : String.join(",", values); diff --git a/src/java/org/apache/cassandra/db/guardrails/GuardrailsConfig.java b/src/java/org/apache/cassandra/db/guardrails/GuardrailsConfig.java index 70e1b7f7d3..2d893fe3f5 100644 --- a/src/java/org/apache/cassandra/db/guardrails/GuardrailsConfig.java +++ b/src/java/org/apache/cassandra/db/guardrails/GuardrailsConfig.java @@ -18,6 +18,7 @@ package org.apache.cassandra.db.guardrails; +import java.util.Map; import java.util.Set; import javax.annotation.Nullable; @@ -682,4 +683,16 @@ public interface GuardrailsConfig * dictionary compressor as frequently as needed, without any limits, false otherwise. */ boolean getUnsetTrainingMinFrequencyEnabled(); + + /** + * @return a map of driver name to minimum version that triggers a warning when the client's + * driver version is below the specified minimum. + */ + Map getMinimumClientDriverVersionsWarned(); + + /** + * @return a map of driver name to minimum version that triggers a failure when the client's + * driver version is below the specified minimum. + */ + Map getMinimumClientDriverVersionsDisallowed(); } diff --git a/src/java/org/apache/cassandra/db/guardrails/GuardrailsMBean.java b/src/java/org/apache/cassandra/db/guardrails/GuardrailsMBean.java index fe139b3bbb..9b341425df 100644 --- a/src/java/org/apache/cassandra/db/guardrails/GuardrailsMBean.java +++ b/src/java/org/apache/cassandra/db/guardrails/GuardrailsMBean.java @@ -1190,4 +1190,28 @@ public interface GuardrailsMBean * dictionary compressor as frequently as needed, without any limits, false otherwise. */ boolean getUnsetTrainingMinFrequencyEnabled(); + + /** + * @return JSON representation of the minimum client driver versions that trigger a warning. + */ + String getMinimumClientDriverVersionsWarned(); + + /** + * @return JSON representation of the minimum client driver versions that trigger a failure. + */ + String getMinimumClientDriverVersionsDisallowed(); + + /** + * Sets the minimum client driver versions that trigger a warning. + * + * @param value JSON representation of a map of driver name to minimum version string. + */ + void setMinimumClientDriverVersionsWarned(String value); + + /** + * Sets the minimum client driver versions that trigger a failure. + * + * @param value JSON representation of a map of driver name to minimum version string. + */ + void setMinimumClientDriverVersionsDisallowed(String value); } diff --git a/src/java/org/apache/cassandra/tools/nodetool/GuardrailsConfigCommand.java b/src/java/org/apache/cassandra/tools/nodetool/GuardrailsConfigCommand.java index 095b4b85f1..780112b47d 100644 --- a/src/java/org/apache/cassandra/tools/nodetool/GuardrailsConfigCommand.java +++ b/src/java/org/apache/cassandra/tools/nodetool/GuardrailsConfigCommand.java @@ -365,7 +365,9 @@ public abstract class GuardrailsConfigCommand extends AbstractCommand "SimpleStrategyEnabled", "simplestrategy_enabled", "NonPartitionRestrictedQueryEnabled", "non_partition_restricted_index_query_enabled"); - private static final Set ignored = Set.of("password_policy", "role_name_policy"); + private static final Set ignored = Set.of("password_policy", "role_name_policy", + "minimum_client_driver_versions_warned", + "minimum_client_driver_versions_disallowed"); /** * Set of guardrails which are flags, even though their suffix would suggest they are part of "values" which have warned, ignored, and disallowed sub-categories diff --git a/src/java/org/apache/cassandra/transport/messages/StartupMessage.java b/src/java/org/apache/cassandra/transport/messages/StartupMessage.java index 85c531c20a..c76db6826e 100644 --- a/src/java/org/apache/cassandra/transport/messages/StartupMessage.java +++ b/src/java/org/apache/cassandra/transport/messages/StartupMessage.java @@ -22,6 +22,7 @@ import java.util.Map; import org.apache.cassandra.auth.IAuthenticator; import org.apache.cassandra.config.DatabaseDescriptor; +import org.apache.cassandra.db.guardrails.Guardrails; import org.apache.cassandra.service.ClientState; import org.apache.cassandra.service.QueryState; import org.apache.cassandra.transport.CBUtil; @@ -124,12 +125,15 @@ public class StartupMessage extends Message.Request ClientState clientState = state.getClientState(); clientState.setClientOptions(options); String driverName = options.get(DRIVER_NAME); + String driverVersion = options.get(DRIVER_VERSION); if (null != driverName) { clientState.setDriverName(driverName); - clientState.setDriverVersion(options.get(DRIVER_VERSION)); + clientState.setDriverVersion(driverVersion); } + Guardrails.minimumClientDriverVersion.guard(driverName, driverVersion, clientState); + IAuthenticator authenticator = DatabaseDescriptor.getAuthenticator(); if (authenticator.requireAuthentication()) { diff --git a/test/unit/org/apache/cassandra/db/guardrails/GuardrailClientDriverVersionTest.java b/test/unit/org/apache/cassandra/db/guardrails/GuardrailClientDriverVersionTest.java new file mode 100644 index 0000000000..2c32fbc4c0 --- /dev/null +++ b/test/unit/org/apache/cassandra/db/guardrails/GuardrailClientDriverVersionTest.java @@ -0,0 +1,350 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.cassandra.db.guardrails; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.apache.cassandra.config.GuardrailsOptions.validateAndSanitizeClientDriverVersions; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class GuardrailClientDriverVersionTest extends GuardrailTester +{ + private String originalWarned; + private String originalDisallowed; + + @Before + public void setup() + { + originalWarned = guardrails().getMinimumClientDriverVersionsWarned(); + originalDisallowed = guardrails().getMinimumClientDriverVersionsDisallowed(); + } + + @After + public void teardown() + { + guardrails().setMinimumClientDriverVersionsWarned(originalWarned != null ? originalWarned : "{}"); + guardrails().setMinimumClientDriverVersionsDisallowed(originalDisallowed != null ? originalDisallowed : "{}"); + } + + @Test + public void testVersionEqual() + { + assertFalse(ClientDriverVersionGuardrail.isBelowMinimum("4.18.0", "4.18.0")); + } + + @Test + public void testVersionLessThan() + { + assertTrue(ClientDriverVersionGuardrail.isBelowMinimum("4.17.0", "4.18.0")); + } + + @Test + public void testVersionGreaterThan() + { + assertFalse(ClientDriverVersionGuardrail.isBelowMinimum("4.19.0", "4.18.0")); + } + + @Test + public void testVersionMajorDifference() + { + assertTrue(ClientDriverVersionGuardrail.isBelowMinimum("3.11.0", "4.0.0")); + } + + @Test + public void testVersionMinorDifference() + { + assertTrue(ClientDriverVersionGuardrail.isBelowMinimum("4.2.0", "4.18.0")); + } + + @Test + public void testVersionWithVPrefix() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"driver\":\"v2.0.1\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{}"); + + // above minimum — no warn + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("driver", "v2.0.2", userClientState)); + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("driver", "2.0.2", userClientState)); + + // below minimum — warn + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("driver", "v2.0.0", userClientState), + "Client driver driver, version 2.0.0 is below recommended minimum version 2.0.1"); + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("driver", "2.0.0", userClientState), + "Client driver driver, version 2.0.0 is below recommended minimum version 2.0.1"); + } + + @Test + public void testGuardrailNotTriggeredWhenEmpty() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{}"); + + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "3.0.0", userClientState)); + } + + @Test + public void testGuardrailNotTriggeredWhenVersionAboveMinimum() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{}"); + + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "4.20.0", userClientState)); + } + + @Test + public void testGuardrailNotTriggeredForNotEnumeratedDriver() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{\"DataStax Java Driver\":\"4.0.0\"}"); + + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("gocql", "1.0.0", userClientState)); + } + + @Test + public void testGuardrailNotTriggeredForNullDriverName() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + assertValid(() -> Guardrails.minimumClientDriverVersion.guard(null, "4.15.0", userClientState)); + } + + @Test + public void testGuardrailNotTriggeredForNullDriverVersion() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", null, userClientState), + "Client driver DataStax Java Driver, version unset is below recommended minimum version 4.18.0"); + } + + @Test + public void testGuardrailNotTriggeredForBothNullDriverAndVersion() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + assertValid(() -> Guardrails.minimumClientDriverVersion.guard(null, null, userClientState)); + } + + @Test + public void testGuardrailNotTriggeredForEmptyDriverName() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("", "4.15.0", userClientState)); + assertValid(() -> Guardrails.minimumClientDriverVersion.guard(" ", "4.15.0", userClientState)); + } + + @Test + public void testGuardrailTriggeredForEmptyDriverVersion() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "", userClientState), + "Client driver DataStax Java Driver, version unset is below recommended minimum version 4.18.0"); + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", " ", userClientState), + "Client driver DataStax Java Driver, version unset is below recommended minimum version 4.18.0"); + } + + @Test + public void testGuardrailWarns() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{}"); + + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "4.15.0", userClientState), + "Client driver DataStax Java Driver, version 4.15.0 is below recommended minimum version 4.18.0"); + } + + @Test + public void testGuardrailFails() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{\"DataStax Java Driver\":\"4.0.0\"}"); + + assertFails(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "3.11.0", userClientState), + "Client driver DataStax Java Driver, version 3.11.0 is below required minimum version 4.0.0, connection rejected"); + } + + @Test + public void testGuardrailFailTakesPrecedenceOverWarn() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{\"DataStax Java Driver\":\"4.0.0\"}"); + + assertFails(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "3.11.0", userClientState), + "Client driver DataStax Java Driver, version 3.11.0 is below required minimum version 4.0.0, connection rejected"); + } + + @Test + public void testGuardrailWarnOnly() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{\"DataStax Java Driver\":\"4.0.0\"}"); + + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "4.15.0", userClientState), + "Client driver DataStax Java Driver, version 4.15.0 is below recommended minimum version 4.18.0"); + } + + @Test + public void testGuardrailWithVPrefixVersion() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{}"); + + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", "v4.15.0", userClientState), + "Client driver DataStax Java Driver, version 4.15.0 is below recommended minimum version 4.18.0"); + } + + @Test + public void testGuardrailColonFormatWarns() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + guardrails().setMinimumClientDriverVersionsDisallowed("{}"); + + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver:4.15.0", userClientState), + "Client driver DataStax Java Driver, version 4.15.0 is below recommended minimum version 4.18.0"); + } + + @Test + public void testGuardrailColonFormatNull() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + + assertValid(() -> Guardrails.minimumClientDriverVersion.guard(null, userClientState)); + } + + @Test + public void testGuardrailColonFormatNoSeparator() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + + assertValid(() -> Guardrails.minimumClientDriverVersion.guard("DataStax Java Driver", userClientState)); + } + + @Test + public void testWarnUnsetDriverId() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"unset\":\"0.0.0\"}"); + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard(null, null, userClientState), + "Connection with unset driver id was detected."); + } + + @Test + public void testFailUnsetDriverId() throws Throwable + { + guardrails().setMinimumClientDriverVersionsDisallowed("{\"unset\":\"0.0.0\"}"); + assertFails(() -> Guardrails.minimumClientDriverVersion.guard(null, null, userClientState), + "Connections with unset driver id's are forbidden."); + } + + @Test + public void testWarnUnknownDriverId() throws Throwable + { + guardrails().setMinimumClientDriverVersionsWarned("{\"unknown\":\"0.0.0\"}"); + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("whatever driver name", "1.2.3", userClientState), + "Detected unknown driver id: whatever driver name"); + assertWarns(() -> Guardrails.minimumClientDriverVersion.guard("whatever driver name", null, userClientState), + "Detected unknown driver id: whatever driver name"); + } + + @Test + public void testFailUnknownDriverId() throws Throwable + { + guardrails().setMinimumClientDriverVersionsDisallowed("{\"unknown\":\"0.0.0\"}"); + assertFails(() -> Guardrails.minimumClientDriverVersion.guard("whatever driver name", "1.2.3", userClientState), + "Detected unknown driver id: whatever driver name"); + assertFails(() -> Guardrails.minimumClientDriverVersion.guard("whatever driver name", null, userClientState), + "Detected unknown driver id: whatever driver name"); + } + + @Test + public void testJmxGetSetWarned() + { + guardrails().setMinimumClientDriverVersionsWarned("{\"DataStax Java Driver\":\"4.18.0\"}"); + String json = guardrails().getMinimumClientDriverVersionsWarned(); + assertTrue(json.contains("DataStax Java Driver")); + assertTrue(json.contains("4.18.0")); + } + + @Test + public void testJmxGetSetDisallowed() + { + guardrails().setMinimumClientDriverVersionsDisallowed("{\"DataStax Java Driver\":\"4.0.0\",\"DataStax Python Driver\":\"3.0.0\"}"); + String json = guardrails().getMinimumClientDriverVersionsDisallowed(); + assertTrue(json.contains("DataStax Java Driver")); + assertTrue(json.contains("DataStax Python Driver")); + } + + @Test + public void testValidateValidVersions() + { + validateAndSanitizeClientDriverVersions(new HashMap<>(Map.of("driver", "4.18.0")), "test"); + validateAndSanitizeClientDriverVersions(new HashMap<>(Map.of("driver", "v4.18.0")), "test"); + validateAndSanitizeClientDriverVersions(new HashMap<>(Map.of("driver", "V4.18.0")), "test"); + validateAndSanitizeClientDriverVersions(new HashMap<>(Map.of("a", "1.0.0", "b", "2.0.0")), "test"); + } + + @Test + public void testValidateNullMap() + { + validateAndSanitizeClientDriverVersions(null, "test"); + } + + @Test + public void testValidateEmptyMap() + { + validateAndSanitizeClientDriverVersions(Collections.emptyMap(), "test"); + } + + @Test + public void testValidateInvalidVersion() + { + assertThatThrownBy(() -> validateAndSanitizeClientDriverVersions(Map.of("driver", "not-a-version"), "test")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testValidateEmptyVersion() + { + assertThatThrownBy(() -> validateAndSanitizeClientDriverVersions(Map.of("driver", ""), "test")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testValidateBlankVersion() + { + assertThatThrownBy(() -> validateAndSanitizeClientDriverVersions(Map.of("driver", " "), "test")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void testValidateMixedValidAndInvalid() + { + Map map = new HashMap<>(); + map.put("good-driver", "4.18.0"); + map.put("bad-driver", "xyz"); + assertThatThrownBy(() -> validateAndSanitizeClientDriverVersions(map, "test")) + .isInstanceOf(IllegalArgumentException.class); + } +}