Merge branch 'cassandra-4.0' into cassandra-4.1

* cassandra-4.0:
  Rate limit password changes
This commit is contained in:
mck 2026-03-16 22:03:12 +01:00
commit 1cbdef6272
No known key found for this signature in database
GPG Key ID: E91335D77E3E87CB
6 changed files with 209 additions and 1 deletions

View File

@ -5,6 +5,7 @@
* ReadCommandController should close fast to avoid deadlock when building secondary index (CASSANDRA-19564)
* Redact security-sensitive information in system_views.settings (CASSANDRA-20856)
Merged from 4.0:
* Rate limit password changes (CASSANDRA-21202)
* Node does not send multiple inflight echos (CASSANDRA-18866)
* Obsolete expired SSTables before compaction starts (CASSANDRA-19776)
* Switch lz4-java to at.yawk.lz4 version due to CVE (CASSANDRA-21052)

View File

@ -26,6 +26,8 @@ import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
@ -34,6 +36,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.cassandra.concurrent.ScheduledExecutors;
import org.apache.cassandra.config.CassandraRelevantProperties;
import org.apache.cassandra.config.Config;
import org.apache.cassandra.config.DatabaseDescriptor;
import org.apache.cassandra.schema.SchemaConstants;
@ -47,6 +50,7 @@ import org.apache.cassandra.service.StorageService;
import org.apache.cassandra.transport.Dispatcher;
import org.apache.cassandra.transport.messages.ResultMessage;
import org.apache.cassandra.utils.ByteBufferUtil;
import org.apache.cassandra.utils.Clock;
import org.mindrot.jbcrypt.BCrypt;
import static org.apache.cassandra.service.QueryState.forInternalCalls;
@ -117,6 +121,19 @@ public class CassandraRoleManager implements IRoleManager
public static final String GENSALT_LOG2_ROUNDS_PROPERTY = Config.PROPERTY_PREFIX + "auth_bcrypt_gensalt_log2_rounds";
private static final int GENSALT_LOG2_ROUNDS = getGensaltLogRounds();
private static int PASSWORD_UPDATE_MIN_INTERVAL_MS = CassandraRelevantProperties.ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS.getInt();
// in-memory protection against excessive loadRoleWithWritetimeStatement queries
private static Cache<String, Boolean> recentPasswordUpdates = Caffeine.newBuilder()
.expireAfterWrite(PASSWORD_UPDATE_MIN_INTERVAL_MS, TimeUnit.MILLISECONDS)
.build();
@VisibleForTesting
public static synchronized void updatePasswordUpdateMinInterval(int newInterval)
{
recentPasswordUpdates = Caffeine.newBuilder().expireAfterWrite(newInterval, TimeUnit.MILLISECONDS).build();
PASSWORD_UPDATE_MIN_INTERVAL_MS = newInterval;
}
static int getGensaltLogRounds()
{
int rounds = Integer.getInteger(GENSALT_LOG2_ROUNDS_PROPERTY, 10);
@ -128,6 +145,7 @@ public class CassandraRoleManager implements IRoleManager
}
private SelectStatement loadRoleStatement;
private SelectStatement loadRoleWithWritetimeStatement;
private final Set<Option> supportedOptions;
private final Set<Option> alterableOptions;
@ -157,6 +175,10 @@ public class CassandraRoleManager implements IRoleManager
loadRoleStatement = (SelectStatement) prepare("SELECT * from %s.%s WHERE role = ?",
SchemaConstants.AUTH_KEYSPACE_NAME,
AuthKeyspace.ROLES);
loadRoleWithWritetimeStatement = (SelectStatement) prepare("SELECT writetime(salted_hash) AS salted_hash_writetime from %s.%s WHERE role = ?",
SchemaConstants.AUTH_KEYSPACE_NAME,
AuthKeyspace.ROLES);
}
public Set<Option> supportedOptions()
@ -201,6 +223,9 @@ public class CassandraRoleManager implements IRoleManager
public void alterRole(AuthenticatedUser performer, RoleResource role, RoleOptions options)
{
if (options.getPassword().isPresent())
enforcePasswordUpdateRateLimit(performer, role.getRoleName());
// Unlike most of the other data access methods here, this does not use a
// prepared statement in order to allow the set of assignments to be variable.
String assignments = optionsToAssignments(options.getOptions());
@ -521,6 +546,49 @@ public class CassandraRoleManager implements IRoleManager
.collect(Collectors.joining(","));
}
/**
* Rate limit password updates on each role.
* @throws OverloadedException if the password was changed within ROLE_PASSWORD_UPDATE_INTERVAL
*/
private void enforcePasswordUpdateRateLimit(AuthenticatedUser performer, String roleName)
{
if (PASSWORD_UPDATE_MIN_INTERVAL_MS <= 0)
return;
if (Boolean.TRUE != recentPasswordUpdates.getIfPresent(roleName))
{
QueryOptions options = QueryOptions.forInternalCalls(consistencyForRole(roleName),
Collections.singletonList(ByteBufferUtil.bytes(roleName)));
ResultMessage.Rows rows = select(loadRoleWithWritetimeStatement, options);
boolean hasRecentPasswordUpdates = !rows.result.isEmpty();
if (hasRecentPasswordUpdates)
{
UntypedResultSet.Row row = UntypedResultSet.create(rows.result).one();
hasRecentPasswordUpdates = row.has("salted_hash_writetime")
&& PASSWORD_UPDATE_MIN_INTERVAL_MS >= (Clock.Global.currentTimeMillis() - TimeUnit.MICROSECONDS.toMillis(row.getLong("salted_hash_writetime")));
}
if (!hasRecentPasswordUpdates)
{
recentPasswordUpdates.put(roleName, Boolean.TRUE);
logger.info(String.format("Password changing for role %s by %s", roleName, performer.getName()));
return;
}
}
String failure = String.format("Password for role %s can only be changed every %sms.", roleName, PASSWORD_UPDATE_MIN_INTERVAL_MS);
logger.warn(String.format("%s [performer: %s]", failure, performer.getName()));
throw new OverloadedException(failure);
}
protected static ConsistencyLevel consistencyForRole(String role)
{
if (role.equals(DEFAULT_SUPERUSER_NAME))
return ConsistencyLevel.QUORUM;
else
return ConsistencyLevel.LOCAL_ONE;
}
private static String hashpw(String password)
{
return BCrypt.hashpw(password, BCrypt.gensalt(GENSALT_LOG2_ROUNDS));

View File

@ -355,7 +355,10 @@ public enum CassandraRelevantProperties
* Do not try to calculate optimal streaming candidates. This can take a lot of time in some configs specially
* with vnodes.
*/
SKIP_OPTIMAL_STREAMING_CANDIDATES_CALCULATION("cassandra.skip_optimal_streaming_candidates_calculation", "false");
SKIP_OPTIMAL_STREAMING_CANDIDATES_CALCULATION("cassandra.skip_optimal_streaming_candidates_calculation", "false"),
/** How often a role's password can be changed */
ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS("cassandra.role_password_update_min_interval_in_ms", "5000");
CassandraRelevantProperties(String key, String defaultVal)
{

View File

@ -34,6 +34,7 @@ import com.datastax.driver.core.exceptions.AuthenticationException;
import com.datastax.driver.core.exceptions.SyntaxError;
import com.datastax.driver.core.exceptions.UnauthorizedException;
import org.apache.cassandra.ServerTestUtils;
import org.apache.cassandra.auth.CassandraRoleManager;
import org.apache.cassandra.config.DatabaseDescriptor;
import org.apache.cassandra.config.OverrideConfigurationLoader;
import org.apache.cassandra.config.ParameterizedClass;
@ -42,6 +43,7 @@ import org.apache.cassandra.locator.InetAddressAndPort;
import org.apache.cassandra.service.EmbeddedCassandraService;
import org.hamcrest.CoreMatchers;
import static org.hamcrest.CoreMatchers.containsString;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
@ -76,6 +78,7 @@ public class AuditLoggerAuthTest
});
System.setProperty("cassandra.superuser_setup_delay_ms", "0");
CassandraRoleManager.updatePasswordUpdateMinInterval(0);
embedded = ServerTestUtils.startEmbeddedCassandraService();
executeWithCredentials(

View File

@ -27,8 +27,10 @@ import org.junit.BeforeClass;
import org.junit.Test;
import org.apache.cassandra.SchemaLoader;
import org.apache.cassandra.config.CassandraRelevantProperties;
import org.apache.cassandra.config.DatabaseDescriptor;
import org.apache.cassandra.db.ColumnFamilyStore;
import org.apache.cassandra.exceptions.OverloadedException;
import org.apache.cassandra.schema.KeyspaceParams;
import org.apache.cassandra.schema.SchemaConstants;
import org.apache.cassandra.schema.TableMetadata;
@ -37,6 +39,7 @@ import org.apache.cassandra.service.StorageService;
import static org.apache.cassandra.auth.AuthTestUtils.*;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class CassandraRoleManagerTest
{
@ -151,6 +154,53 @@ public class CassandraRoleManagerTest
}
}
public void testPasswordUpdateRateLimiting() throws Exception
{
try
{
CassandraRoleManager.updatePasswordUpdateMinInterval(100);
IRoleManager roleManager = new LocalCassandraRoleManager();
roleManager.setup();
RoleResource testRole = RoleResource.role("test_password_role");
RoleOptions options = getLoginRoleOptions("initial_password");
roleManager.createRole(AuthenticatedUser.ANONYMOUS_USER, testRole, options);
// Wait for the rate limit interval to pass
Thread.sleep(150);
// First password change should succeed
RoleOptions newOptions1 = getLoginRoleOptions("new_password_1");
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, testRole, newOptions1);
// Immediate second password change should fail with OverloadedException
try
{
RoleOptions newOptions2 = getLoginRoleOptions("new_password_2");
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, testRole, newOptions2);
fail("Expected OverloadedException due to password update rate limiting");
}
catch (OverloadedException e)
{
assertEquals("Password for role test_password_role can only be changed every 100ms. ", e.getMessage());
}
// Wait for the rate limit interval to pass
Thread.sleep(150);
// After waiting, password change should succeed again
RoleOptions newOptions3 = getLoginRoleOptions("new_password_3");
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, testRole, newOptions3);
roleManager.dropRole(AuthenticatedUser.ANONYMOUS_USER, testRole);
}
finally
{
CassandraRoleManager.updatePasswordUpdateMinInterval(CassandraRelevantProperties.ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS.getInt());
}
}
@Test
public void warmCacheWithEmptyTable()
{
@ -167,4 +217,86 @@ public class CassandraRoleManagerTest
for (RoleResource expectedRole : expected)
assertTrue(actual.stream().anyMatch(role -> role.resource.equals(expectedRole)));
}
public void testPasswordUpdateRateLimitingDisabled() throws Exception
{
try
{
CassandraRoleManager.updatePasswordUpdateMinInterval(0);
IRoleManager roleManager = new LocalCassandraRoleManager();
roleManager.setup();
RoleResource testRole = RoleResource.role("test_no_limit_role");
RoleOptions options = getLoginRoleOptions("initial_password");
roleManager.createRole(AuthenticatedUser.ANONYMOUS_USER, testRole, options);
// Multiple rapid password changes should all succeed when rate limiting is disabled
for (int i = 0; i < 5; i++)
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, testRole, getLoginRoleOptions("password_" + i));
roleManager.dropRole(AuthenticatedUser.ANONYMOUS_USER, testRole);
}
finally
{
CassandraRoleManager.updatePasswordUpdateMinInterval(CassandraRelevantProperties.ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS.getInt());
}
}
@Test
public void testPasswordUpdateRateLimitingPerRole() throws Exception
{
try
{
CassandraRoleManager.updatePasswordUpdateMinInterval(100);
IRoleManager roleManager = new LocalCassandraRoleManager();
roleManager.setup();
RoleResource role1 = RoleResource.role("test_role_1");
RoleResource role2 = RoleResource.role("test_role_2");
RoleOptions options1 = getLoginRoleOptions("password1");
roleManager.createRole(AuthenticatedUser.ANONYMOUS_USER, role1, options1);
RoleOptions options2 = getLoginRoleOptions("password2");
roleManager.createRole(AuthenticatedUser.ANONYMOUS_USER, role2, options2);
// Wait for the rate limit interval to pass
Thread.sleep(150);
RoleOptions newOptions1 = getLoginRoleOptions("new_password1");
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, role1, newOptions1);
RoleOptions newOptions2 = getLoginRoleOptions("new_password2");
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, role2, newOptions2);
try
{
RoleOptions newOptions1Again = getLoginRoleOptions("another_password1");
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, role1, newOptions1Again);
fail("Expected OverloadedException for test_role_1");
}
catch (OverloadedException e)
{
assertEquals("Password for role test_role_1 can only be changed every 100ms.", e.getMessage());
}
roleManager.dropRole(AuthenticatedUser.ANONYMOUS_USER, role1);
roleManager.dropRole(AuthenticatedUser.ANONYMOUS_USER, role2);
}
finally
{
CassandraRoleManager.updatePasswordUpdateMinInterval(CassandraRelevantProperties.ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS.getInt());
}
}
public static RoleOptions getLoginRoleOptions(String password)
{
RoleOptions roleOptions = new RoleOptions();
roleOptions.setOption(IRoleManager.Option.SUPERUSER, false);
roleOptions.setOption(IRoleManager.Option.LOGIN, true);
roleOptions.setOption(IRoleManager.Option.PASSWORD, password);
return roleOptions;
}
}

View File

@ -41,6 +41,7 @@ public class CreateAndAlterRoleTest extends CQLTester
@BeforeClass
public static void setUpClass()
{
CassandraRoleManager.updatePasswordUpdateMinInterval(0);
CQLTester.setUpClass();
requireAuthentication();
requireNetwork();