Skip to content

Commit eb26940

Browse files
committed
Guardrail for client driver versions
1 parent 1a9ab95 commit eb26940

11 files changed

Lines changed: 710 additions & 5 deletions

File tree

conf/cassandra.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2682,6 +2682,20 @@ max_security_label_length: 48
26822682
# compressed by dictionary compressor and training_min_frequency is set to 0m (the default when unset).
26832683
#unset_training_min_frequency_enabled: true
26842684

2685+
# Minimum client driver versions. Connections from drivers whose version is below
2686+
# the configured minimum will be warned or rejected. Connections that do not report
2687+
# a driver name or version are considered valid. The map key is the driver name
2688+
# as reported in the native protocol STARTUP message. The value is the minimum
2689+
# version string.
2690+
#minimum_client_driver_versions_warned:
2691+
# DataStax Java Driver: "4.0.0"
2692+
# DataStax Python Driver: "3.0.0"
2693+
# github.com/apache/cassandra-gocql-driver: "2.0.0"
2694+
#minimum_client_driver_versions_disallowed:
2695+
# DataStax Java Driver: "4.0.0"
2696+
# DataStax Python Driver: "3.0.0"
2697+
# github.com/apache/cassandra-gocql-driver: "2.0.0"
2698+
26852699
# Startup Checks are executed as part of Cassandra startup process, not all of them
26862700
# are configurable (so you can disable them) but these which are enumerated bellow.
26872701
# Uncomment the startup checks and configure them appropriately to cover your needs.

conf/cassandra_latest.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2446,6 +2446,20 @@ default_secondary_index_enabled: true
24462446
# compressed by dictionary compressor and training_min_frequency is set to 0m (the default when unset).
24472447
#unset_training_min_frequency_enabled: true
24482448

2449+
# Minimum client driver versions. Connections from drivers whose version is below
2450+
# the configured minimum will be warned or rejected. Connections that do not report
2451+
# a driver name or version are considered valid. The map key is the driver name
2452+
# as reported in the native protocol STARTUP message. The value is the minimum
2453+
# version string.
2454+
#minimum_client_driver_versions_warned:
2455+
# DataStax Java Driver: "4.0.0"
2456+
# DataStax Python Driver: "3.0.0"
2457+
# github.com/apache/cassandra-gocql-driver: "2.0.0"
2458+
#minimum_client_driver_versions_disallowed:
2459+
# DataStax Java Driver: "4.0.0"
2460+
# DataStax Python Driver: "3.0.0"
2461+
# github.com/apache/cassandra-gocql-driver: "2.0.0"
2462+
24492463
# Startup Checks are executed as part of Cassandra startup process, not all of them
24502464
# are configurable (so you can disable them) but these which are enumerated bellow.
24512465
# Uncomment the startup checks and configure them appropriately to cover your needs.

src/java/org/apache/cassandra/config/Config.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,9 @@ public static void setClientMode(boolean clientMode)
10061006
public volatile boolean unset_training_min_frequency_warned = true;
10071007
public volatile boolean unset_training_min_frequency_enabled = true;
10081008

1009+
public volatile Map<String, String> minimum_client_driver_versions_warned = Collections.emptyMap();
1010+
public volatile Map<String, String> minimum_client_driver_versions_disallowed = Collections.emptyMap();
1011+
10091012
public volatile int sai_sstable_indexes_per_query_warn_threshold = 32;
10101013
public volatile int sai_sstable_indexes_per_query_fail_threshold = -1;
10111014
public volatile DataStorageSpec.LongBytesBound sai_string_term_size_warn_threshold = new DataStorageSpec.LongBytesBound("1KiB");

src/java/org/apache/cassandra/config/GuardrailsOptions.java

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,20 @@
1818

1919
package org.apache.cassandra.config;
2020

21+
import java.util.ArrayList;
2122
import java.util.Collections;
23+
import java.util.List;
2224
import java.util.Map;
2325
import java.util.Set;
2426
import java.util.function.Consumer;
2527
import java.util.function.Supplier;
28+
import java.util.regex.Pattern;
2629

2730
import javax.annotation.Nullable;
2831

32+
import com.google.common.annotations.VisibleForTesting;
2933
import com.google.common.collect.Sets;
34+
import com.vdurmont.semver4j.Semver;
3035

3136
import org.slf4j.Logger;
3237
import org.slf4j.LoggerFactory;
@@ -113,6 +118,8 @@ public GuardrailsOptions(Config config)
113118
false);
114119
validatePasswordPolicy(config.password_policy);
115120
validateRoleNamePolicy(config.role_name_policy);
121+
validateAndSanitizeClientDriverVersions(config.minimum_client_driver_versions_warned, "minimum_client_driver_versions_warned");
122+
validateAndSanitizeClientDriverVersions(config.minimum_client_driver_versions_disallowed, "minimum_client_driver_versions_disallowed");
116123
}
117124

118125
@Override
@@ -195,7 +202,7 @@ public Set<String> getKeyspacePropertiesWarned()
195202
{
196203
return config.keyspace_properties_warned;
197204
}
198-
205+
199206
public void setKeyspacePropertiesWarned(Set<String> properties)
200207
{
201208
updatePropertyWithLogging("keyspace_properties_warned",
@@ -209,7 +216,7 @@ public Set<String> getKeyspacePropertiesIgnored()
209216
{
210217
return config.keyspace_properties_ignored;
211218
}
212-
219+
213220
public void setKeyspacePropertiesIgnored(Set<String> properties)
214221
{
215222
updatePropertyWithLogging("keyspace_properties_ignored",
@@ -223,7 +230,7 @@ public Set<String> getKeyspacePropertiesDisallowed()
223230
{
224231
return config.keyspace_properties_disallowed;
225232
}
226-
233+
227234
public void setKeyspacePropertiesDisallowed(Set<String> properties)
228235
{
229236
updatePropertyWithLogging("keyspace_properties_disallowed",
@@ -1368,6 +1375,34 @@ public boolean getUnsetTrainingMinFrequencyEnabled()
13681375
return config.unset_training_min_frequency_enabled;
13691376
}
13701377

1378+
@Override
1379+
public Map<String, String> getMinimumClientDriverVersionsWarned()
1380+
{
1381+
return config.minimum_client_driver_versions_warned;
1382+
}
1383+
1384+
@Override
1385+
public Map<String, String> getMinimumClientDriverVersionsDisallowed()
1386+
{
1387+
return config.minimum_client_driver_versions_disallowed;
1388+
}
1389+
1390+
public void setMinimumClientDriverVersionsWarned(Map<String, String> versions)
1391+
{
1392+
updatePropertyWithLogging("minimum_client_driver_versions_warned",
1393+
versions,
1394+
() -> config.minimum_client_driver_versions_warned,
1395+
x -> config.minimum_client_driver_versions_warned = x);
1396+
}
1397+
1398+
public void setMinimumClientDriverVersionsDisallowed(Map<String, String> versions)
1399+
{
1400+
updatePropertyWithLogging("minimum_client_driver_versions_disallowed",
1401+
versions,
1402+
() -> config.minimum_client_driver_versions_disallowed,
1403+
x -> config.minimum_client_driver_versions_disallowed = x);
1404+
}
1405+
13711406
private static <T> void updatePropertyWithLogging(String propertyName, T newValue, Supplier<T> getter, Consumer<T> setter)
13721407
{
13731408
T oldValue = getter.get();
@@ -1601,4 +1636,57 @@ private static void validateRoleNamePolicy(CustomGuardrailConfig config)
16011636
{
16021637
ValueGenerator.getGenerator("role_name_policy", config).generate(ValueValidator.getValidator("role_name_policy", config), Map.of());
16031638
}
1639+
1640+
@VisibleForTesting
1641+
public static void validateAndSanitizeClientDriverVersions(Map<String, String> map, String guardrailName)
1642+
{
1643+
if (map == null || map.isEmpty())
1644+
return;
1645+
1646+
List<String> invalidEntries = new ArrayList<>();
1647+
1648+
for (Map.Entry<String, String> entry : map.entrySet())
1649+
{
1650+
String sanitized = sanitizeVersion(entry.getValue());
1651+
if (!isValidVersion(sanitized))
1652+
invalidEntries.add(entry.getKey());
1653+
}
1654+
1655+
if (!invalidEntries.isEmpty())
1656+
throw new IllegalArgumentException("Invalid version entries for " + guardrailName + " guardrail: " + invalidEntries);
1657+
1658+
map.replaceAll((driver, version) -> sanitizeVersion(version));
1659+
}
1660+
1661+
public static boolean isValidVersion(String version)
1662+
{
1663+
if (version == null)
1664+
return false;
1665+
1666+
// try to construct it
1667+
try
1668+
{
1669+
new Semver(version);
1670+
}
1671+
catch (Throwable t)
1672+
{
1673+
return false;
1674+
}
1675+
1676+
return true;
1677+
}
1678+
1679+
private static final Pattern VERSION_SANITATION_PATTERN = Pattern.compile("^[vV]");
1680+
1681+
public static String sanitizeVersion(String driverVersion)
1682+
{
1683+
String sanitizedVersionId = driverVersion == null ? null : driverVersion.trim();
1684+
if (sanitizedVersionId != null)
1685+
sanitizedVersionId = VERSION_SANITATION_PATTERN.matcher(sanitizedVersionId).replaceFirst("");
1686+
1687+
if (sanitizedVersionId == null || sanitizedVersionId.isBlank())
1688+
return null;
1689+
1690+
return sanitizedVersionId;
1691+
}
16041692
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
package org.apache.cassandra.db.guardrails;
20+
21+
import java.util.Map;
22+
import java.util.function.Function;
23+
24+
import javax.annotation.Nullable;
25+
26+
import com.vdurmont.semver4j.Semver;
27+
import com.vdurmont.semver4j.Semver.SemverType;
28+
29+
import org.apache.cassandra.config.GuardrailsOptions;
30+
import org.apache.cassandra.service.ClientState;
31+
32+
/**
33+
* A guardrail that warns or rejects client connections whose driver version
34+
* is below a configured minimum per driver name.
35+
* <p>
36+
* The guardrail is configured with maps of driver name to minimum version string.
37+
* Connections that do not report a driver name or version are considered valid
38+
* and are not subject to this guardrail.
39+
* <p>
40+
* Version comparison uses semantic versioning via {@link Semver} with loose parsing
41+
* to handle non-standard version strings reported by drivers.
42+
*/
43+
public class ClientDriverVersionGuardrail extends Predicates<String>
44+
{
45+
private final Function<ClientState, Map<String, String>> warnVersions;
46+
private final Function<ClientState, Map<String, String>> disallowVersions;
47+
48+
public ClientDriverVersionGuardrail(Function<ClientState, Map<String, String>> warnVersions,
49+
Function<ClientState, Map<String, String>> disallowVersions)
50+
{
51+
super("minimum_client_driver_versions", null, null, null, null);
52+
this.warnVersions = warnVersions;
53+
this.disallowVersions = disallowVersions;
54+
}
55+
56+
public void guard(@Nullable String driverName, @Nullable String driverVersion, @Nullable ClientState state)
57+
{
58+
if (!enabled(state))
59+
return;
60+
61+
String sanitizedDriverId = driverName == null ? null : driverName.trim();
62+
if (sanitizedDriverId == null || sanitizedDriverId.isBlank())
63+
{
64+
logger.debug("minimum_client_driver_versions guardrail identified empty driver " +
65+
"id to check the minimum version of, such connections will be allowed but " +
66+
"an operator should check what kind of clients are connecting to the cluster.");
67+
return;
68+
}
69+
70+
String sanitizedDriverVersion = GuardrailsOptions.sanitizeVersion(driverVersion);
71+
if (sanitizedDriverVersion == null)
72+
{
73+
logger.debug("minimum_client_driver_versions guardrail identified empty driver " +
74+
"version to check the minimum version of, such connections will be allowed but " +
75+
"an operator should check what kind of clients are connecting to the cluster.");
76+
return;
77+
}
78+
79+
if (!GuardrailsOptions.isValidVersion(sanitizedDriverVersion))
80+
{
81+
logger.debug("minimum_client_driver_versions guardrail identified driver " +
82+
"version which is not compliant semver version, such connections will be allowed but " +
83+
"an operator should check what kind of clients are connecting to the cluster.");
84+
return;
85+
}
86+
87+
Map<String, String> disallowed = disallowVersions.apply(state);
88+
if (disallowed != null && !disallowed.isEmpty())
89+
{
90+
String minimumVersionFail = disallowed.get(sanitizedDriverId);
91+
if (minimumVersionFail != null && isBelowMinimum(sanitizedDriverVersion, minimumVersionFail))
92+
{
93+
fail(String.format("Client driver %s is below required minimum version %s, connection rejected",
94+
sanitizedDriverId, minimumVersionFail), state);
95+
return;
96+
}
97+
}
98+
99+
Map<String, String> warned = warnVersions.apply(state);
100+
if (warned != null && !warned.isEmpty())
101+
{
102+
String minimumVersionWarn = warned.get(sanitizedDriverId);
103+
if (minimumVersionWarn != null && isBelowMinimum(sanitizedDriverVersion, minimumVersionWarn))
104+
{
105+
warn(String.format("Client driver %s is below recommended minimum version %s",
106+
sanitizedDriverId, minimumVersionWarn));
107+
}
108+
}
109+
}
110+
111+
/**
112+
* Driver id should be in the format "driver:version". It will be parsed and
113+
* {@link #guard(String, String, ClientState)} called.
114+
* <p>
115+
* This method is not expected to be called in normal circumstances, it is just that we
116+
* would need to pass it with colon separator from StartupMessage as guard method
117+
* expects one string as a value to check, just so that we would break it apart in turn to get
118+
* driver id and version again.
119+
*
120+
* @param rawDriverId the value to check, driver name and version delimited by a colon.
121+
* @param state client state
122+
*/
123+
@Override
124+
public void guard(String rawDriverId, @Nullable ClientState state)
125+
{
126+
if (rawDriverId == null)
127+
return;
128+
129+
String[] pair = rawDriverId.trim().split(":");
130+
if (pair.length != 2)
131+
return;
132+
133+
String sanitizedDriverName = pair[0].trim();
134+
if (sanitizedDriverName.isBlank())
135+
return;
136+
137+
String sanitizedDriverVersion = pair[1].trim();
138+
if (sanitizedDriverVersion.isBlank())
139+
return;
140+
141+
guard(sanitizedDriverName, sanitizedDriverVersion, state);
142+
}
143+
144+
/**
145+
* Checks if the driver version is below the minimum version
146+
* specified in the config map. If driver name is not in minimum versions,
147+
* such connection is considered to be allowed.
148+
* <p>
149+
*
150+
* @param driverVersion version of a driver
151+
* @param minimumVersion minimum allowed version
152+
* @return true if the driver version is lower than the configured minimum
153+
*/
154+
static boolean isBelowMinimum(String driverVersion, String minimumVersion)
155+
{
156+
Semver versionToCheck = new Semver(driverVersion, SemverType.LOOSE);
157+
Semver minimumVersionAllowed = new Semver(minimumVersion, SemverType.LOOSE);
158+
return versionToCheck.isLowerThan(minimumVersionAllowed);
159+
}
160+
}

0 commit comments

Comments
 (0)