Skip to content

Commit a2e5568

Browse files
Merge branch 'cassandra-4.1' into cassandra-5.0
* cassandra-4.1: Rate limit password changes
2 parents b584a43 + 1cbdef6 commit a2e5568

File tree

6 files changed

+206
-1
lines changed

6 files changed

+206
-1
lines changed

CHANGES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Merged from 4.1:
1515
* Disk usage guardrail cannot be disabled when failure threshold is reached (CASSANDRA-21057)
1616
* ReadCommandController should close fast to avoid deadlock when building secondary index (CASSANDRA-19564)
1717
Merged from 4.0:
18+
* Rate limit password changes (CASSANDRA-21202)
1819
* Node does not send multiple inflight echos (CASSANDRA-18866)
1920
* Obsolete expired SSTables before compaction starts (CASSANDRA-19776)
2021
* Switch lz4-java to at.yawk.lz4 version due to CVE (CASSANDRA-21052)

src/java/org/apache/cassandra/auth/CassandraRoleManager.java

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,18 @@
2727
import java.util.stream.Collectors;
2828
import java.util.stream.Stream;
2929

30+
import com.github.benmanes.caffeine.cache.Cache;
31+
import com.github.benmanes.caffeine.cache.Caffeine;
3032
import com.google.common.annotations.VisibleForTesting;
3133
import com.google.common.base.Strings;
3234
import com.google.common.collect.ImmutableSet;
3335
import org.apache.commons.lang3.StringUtils;
36+
import org.mindrot.jbcrypt.BCrypt;
3437
import org.slf4j.Logger;
3538
import org.slf4j.LoggerFactory;
3639

3740
import org.apache.cassandra.concurrent.ScheduledExecutors;
41+
import org.apache.cassandra.config.CassandraRelevantProperties;
3842
import org.apache.cassandra.config.DatabaseDescriptor;
3943
import org.apache.cassandra.schema.SchemaConstants;
4044
import org.apache.cassandra.cql3.*;
@@ -49,7 +53,7 @@
4953
import org.apache.cassandra.transport.messages.ResultMessage;
5054
import org.apache.cassandra.utils.ByteBufferUtil;
5155
import org.apache.cassandra.utils.NoSpamLogger;
52-
import org.mindrot.jbcrypt.BCrypt;
56+
import org.apache.cassandra.utils.Clock;
5357

5458
import static org.apache.cassandra.config.CassandraRelevantProperties.AUTH_BCRYPT_GENSALT_LOG2_ROUNDS;
5559
import static org.apache.cassandra.service.QueryState.forInternalCalls;
@@ -118,6 +122,19 @@ public class CassandraRoleManager implements IRoleManager
118122

119123
private static final int GENSALT_LOG2_ROUNDS = getGensaltLogRounds();
120124

125+
private static int PASSWORD_UPDATE_MIN_INTERVAL_MS = CassandraRelevantProperties.ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS.getInt();
126+
// in-memory protection against excessive loadRoleWithWritetimeStatement queries
127+
private static Cache<String, Boolean> recentPasswordUpdates = Caffeine.newBuilder()
128+
.expireAfterWrite(PASSWORD_UPDATE_MIN_INTERVAL_MS, TimeUnit.MILLISECONDS)
129+
.build();
130+
131+
@VisibleForTesting
132+
public static synchronized void updatePasswordUpdateMinInterval(int newInterval)
133+
{
134+
recentPasswordUpdates = Caffeine.newBuilder().expireAfterWrite(newInterval, TimeUnit.MILLISECONDS).build();
135+
PASSWORD_UPDATE_MIN_INTERVAL_MS = newInterval;
136+
}
137+
121138
static int getGensaltLogRounds()
122139
{
123140
int rounds = AUTH_BCRYPT_GENSALT_LOG2_ROUNDS.getInt(10);
@@ -129,6 +146,7 @@ static int getGensaltLogRounds()
129146

130147
private SelectStatement loadRoleStatement;
131148
private SelectStatement loadIdentityStatement;
149+
private SelectStatement loadRoleWithWritetimeStatement;
132150

133151
private final Set<Option> supportedOptions;
134152
private final Set<Option> alterableOptions;
@@ -218,6 +236,10 @@ protected final void loadRoleStatement()
218236
loadRoleStatement = (SelectStatement) prepare("SELECT * from %s.%s WHERE role = ?",
219237
SchemaConstants.AUTH_KEYSPACE_NAME,
220238
AuthKeyspace.ROLES);
239+
240+
loadRoleWithWritetimeStatement = (SelectStatement) prepare("SELECT writetime(salted_hash) AS salted_hash_writetime from %s.%s WHERE role = ?",
241+
SchemaConstants.AUTH_KEYSPACE_NAME,
242+
AuthKeyspace.ROLES);
221243
}
222244

223245

@@ -276,6 +298,9 @@ public void dropRole(AuthenticatedUser performer, RoleResource role) throws Requ
276298

277299
public void alterRole(AuthenticatedUser performer, RoleResource role, RoleOptions options)
278300
{
301+
if (options.getPassword().isPresent())
302+
enforcePasswordUpdateRateLimit(performer, role.getRoleName());
303+
279304
// Unlike most of the other data access methods here, this does not use a
280305
// prepared statement in order to allow the set of assignments to be variable.
281306
String assignments = optionsToAssignments(options.getOptions());
@@ -627,6 +652,49 @@ private String optionsToAssignments(Map<Option, Object> options)
627652
.collect(Collectors.joining(","));
628653
}
629654

655+
/**
656+
* Rate limit password updates on each role.
657+
* @throws OverloadedException if the password was changed within ROLE_PASSWORD_UPDATE_INTERVAL
658+
*/
659+
private void enforcePasswordUpdateRateLimit(AuthenticatedUser performer, String roleName)
660+
{
661+
if (PASSWORD_UPDATE_MIN_INTERVAL_MS <= 0)
662+
return;
663+
664+
if (Boolean.TRUE != recentPasswordUpdates.getIfPresent(roleName))
665+
{
666+
QueryOptions options = QueryOptions.forInternalCalls(consistencyForRole(roleName),
667+
Collections.singletonList(ByteBufferUtil.bytes(roleName)));
668+
669+
ResultMessage.Rows rows = select(loadRoleWithWritetimeStatement, options);
670+
boolean hasRecentPasswordUpdates = !rows.result.isEmpty();
671+
if (hasRecentPasswordUpdates)
672+
{
673+
UntypedResultSet.Row row = UntypedResultSet.create(rows.result).one();
674+
675+
hasRecentPasswordUpdates = row.has("salted_hash_writetime")
676+
&& PASSWORD_UPDATE_MIN_INTERVAL_MS >= (Clock.Global.currentTimeMillis() - TimeUnit.MICROSECONDS.toMillis(row.getLong("salted_hash_writetime")));
677+
}
678+
if (!hasRecentPasswordUpdates)
679+
{
680+
recentPasswordUpdates.put(roleName, Boolean.TRUE);
681+
logger.info(String.format("Password changing for role %s by %s", roleName, performer.getName()));
682+
return;
683+
}
684+
}
685+
String failure = String.format("Password for role %s can only be changed every %sms.", roleName, PASSWORD_UPDATE_MIN_INTERVAL_MS);
686+
logger.warn(String.format("%s [performer: %s]", failure, performer.getName()));
687+
throw new OverloadedException(failure);
688+
}
689+
690+
protected static ConsistencyLevel consistencyForRole(String role)
691+
{
692+
if (role.equals(DEFAULT_SUPERUSER_NAME))
693+
return ConsistencyLevel.QUORUM;
694+
else
695+
return ConsistencyLevel.LOCAL_ONE;
696+
}
697+
630698
private static String hashpw(String password)
631699
{
632700
return BCrypt.hashpw(password, BCrypt.gensalt(GENSALT_LOG2_ROUNDS));

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,8 @@ public enum CassandraRelevantProperties
424424
*/
425425
RESET_BOOTSTRAP_PROGRESS("cassandra.reset_bootstrap_progress"),
426426
RING_DELAY("cassandra.ring_delay_ms"),
427+
/** How often a role's password can be changed */
428+
ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS("cassandra.role_password_update_min_interval_in_ms", "5000"),
427429

428430
// SAI specific properties
429431

test/unit/org/apache/cassandra/audit/AuditLoggerAuthTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ public static void setup() throws Exception
8181
});
8282

8383
SUPERUSER_SETUP_DELAY_MS.setLong(0);
84+
CassandraRoleManager.updatePasswordUpdateMinInterval(0);
8485
embedded = ServerTestUtils.startEmbeddedCassandraService();
8586

8687
executeWithCredentials(

test/unit/org/apache/cassandra/auth/CassandraRoleManagerTest.java

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@
2727
import org.junit.Test;
2828

2929
import org.apache.cassandra.SchemaLoader;
30+
import org.apache.cassandra.config.CassandraRelevantProperties;
3031
import org.apache.cassandra.config.DatabaseDescriptor;
3132
import org.apache.cassandra.db.ColumnFamilyStore;
33+
import org.apache.cassandra.exceptions.OverloadedException;
3234
import org.apache.cassandra.schema.KeyspaceParams;
3335
import org.apache.cassandra.schema.SchemaConstants;
3436
import org.apache.cassandra.schema.TableMetadata;
@@ -37,6 +39,7 @@
3739
import static org.apache.cassandra.auth.AuthTestUtils.*;
3840
import static org.junit.Assert.assertEquals;
3941
import static org.junit.Assert.assertTrue;
42+
import static org.junit.Assert.fail;
4043

4144
public class CassandraRoleManagerTest
4245
{
@@ -151,6 +154,53 @@ public void warmCacheLoadsAllEntries()
151154
}
152155
}
153156

157+
public void testPasswordUpdateRateLimiting() throws Exception
158+
{
159+
try
160+
{
161+
CassandraRoleManager.updatePasswordUpdateMinInterval(100);
162+
163+
IRoleManager roleManager = new LocalCassandraRoleManager();
164+
roleManager.setup();
165+
166+
RoleResource testRole = RoleResource.role("test_password_role");
167+
RoleOptions options = getLoginRoleOptions("initial_password");
168+
roleManager.createRole(AuthenticatedUser.ANONYMOUS_USER, testRole, options);
169+
170+
// Wait for the rate limit interval to pass
171+
Thread.sleep(150);
172+
173+
// First password change should succeed
174+
RoleOptions newOptions1 = getLoginRoleOptions("new_password_1");
175+
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, testRole, newOptions1);
176+
177+
// Immediate second password change should fail with OverloadedException
178+
try
179+
{
180+
RoleOptions newOptions2 = getLoginRoleOptions("new_password_2");
181+
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, testRole, newOptions2);
182+
fail("Expected OverloadedException due to password update rate limiting");
183+
}
184+
catch (OverloadedException e)
185+
{
186+
assertEquals("Password for role test_password_role can only be changed every 100ms. ", e.getMessage());
187+
}
188+
189+
// Wait for the rate limit interval to pass
190+
Thread.sleep(150);
191+
192+
// After waiting, password change should succeed again
193+
RoleOptions newOptions3 = getLoginRoleOptions("new_password_3");
194+
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, testRole, newOptions3);
195+
196+
roleManager.dropRole(AuthenticatedUser.ANONYMOUS_USER, testRole);
197+
}
198+
finally
199+
{
200+
CassandraRoleManager.updatePasswordUpdateMinInterval(CassandraRelevantProperties.ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS.getInt());
201+
}
202+
}
203+
154204
@Test
155205
public void warmCacheWithEmptyTable()
156206
{
@@ -167,4 +217,86 @@ private void assertRoleSet(Set<Role> actual, RoleResource...expected)
167217
for (RoleResource expectedRole : expected)
168218
assertTrue(actual.stream().anyMatch(role -> role.resource.equals(expectedRole)));
169219
}
220+
221+
public void testPasswordUpdateRateLimitingDisabled() throws Exception
222+
{
223+
try
224+
{
225+
CassandraRoleManager.updatePasswordUpdateMinInterval(0);
226+
227+
IRoleManager roleManager = new LocalCassandraRoleManager();
228+
roleManager.setup();
229+
230+
RoleResource testRole = RoleResource.role("test_no_limit_role");
231+
RoleOptions options = getLoginRoleOptions("initial_password");
232+
roleManager.createRole(AuthenticatedUser.ANONYMOUS_USER, testRole, options);
233+
234+
// Multiple rapid password changes should all succeed when rate limiting is disabled
235+
for (int i = 0; i < 5; i++)
236+
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, testRole, getLoginRoleOptions("password_" + i));
237+
238+
roleManager.dropRole(AuthenticatedUser.ANONYMOUS_USER, testRole);
239+
}
240+
finally
241+
{
242+
CassandraRoleManager.updatePasswordUpdateMinInterval(CassandraRelevantProperties.ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS.getInt());
243+
}
244+
}
245+
246+
@Test
247+
public void testPasswordUpdateRateLimitingPerRole() throws Exception
248+
{
249+
try
250+
{
251+
CassandraRoleManager.updatePasswordUpdateMinInterval(100);
252+
253+
IRoleManager roleManager = new LocalCassandraRoleManager();
254+
roleManager.setup();
255+
256+
RoleResource role1 = RoleResource.role("test_role_1");
257+
RoleResource role2 = RoleResource.role("test_role_2");
258+
259+
RoleOptions options1 = getLoginRoleOptions("password1");
260+
roleManager.createRole(AuthenticatedUser.ANONYMOUS_USER, role1, options1);
261+
262+
RoleOptions options2 = getLoginRoleOptions("password2");
263+
roleManager.createRole(AuthenticatedUser.ANONYMOUS_USER, role2, options2);
264+
265+
// Wait for the rate limit interval to pass
266+
Thread.sleep(150);
267+
268+
RoleOptions newOptions1 = getLoginRoleOptions("new_password1");
269+
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, role1, newOptions1);
270+
271+
RoleOptions newOptions2 = getLoginRoleOptions("new_password2");
272+
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, role2, newOptions2);
273+
274+
try
275+
{
276+
RoleOptions newOptions1Again = getLoginRoleOptions("another_password1");
277+
roleManager.alterRole(AuthenticatedUser.ANONYMOUS_USER, role1, newOptions1Again);
278+
fail("Expected OverloadedException for test_role_1");
279+
}
280+
catch (OverloadedException e)
281+
{
282+
assertEquals("Password for role test_role_1 can only be changed every 100ms.", e.getMessage());
283+
}
284+
285+
roleManager.dropRole(AuthenticatedUser.ANONYMOUS_USER, role1);
286+
roleManager.dropRole(AuthenticatedUser.ANONYMOUS_USER, role2);
287+
}
288+
finally
289+
{
290+
CassandraRoleManager.updatePasswordUpdateMinInterval(CassandraRelevantProperties.ROLE_PASSWORD_UPDATE_MIN_INTERVAL_MS.getInt());
291+
}
292+
}
293+
294+
public static RoleOptions getLoginRoleOptions(String password)
295+
{
296+
RoleOptions roleOptions = new RoleOptions();
297+
roleOptions.setOption(IRoleManager.Option.SUPERUSER, false);
298+
roleOptions.setOption(IRoleManager.Option.LOGIN, true);
299+
roleOptions.setOption(IRoleManager.Option.PASSWORD, password);
300+
return roleOptions;
301+
}
170302
}

test/unit/org/apache/cassandra/auth/CreateAndAlterRoleTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public class CreateAndAlterRoleTest extends CQLTester
4141
@BeforeClass
4242
public static void setUpClass()
4343
{
44+
CassandraRoleManager.updatePasswordUpdateMinInterval(0);
4445
ServerTestUtils.daemonInitialization();
4546

4647
DatabaseDescriptor.setPermissionsValidity(0);

0 commit comments

Comments
 (0)