This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new e44e6b4cd fix(auth): harden login rate limiting, password hash bounds,
and entity id validation (#2400)
e44e6b4cd is described below
commit e44e6b4cd4ae36ac4cddca509898bd0807e03406
Author: yyqdbngt <[email protected]>
AuthorDate: Sat Aug 22 14:46:31 2026 +0800
fix(auth): harden login rate limiting, password hash bounds, and entity id
validation (#2400)
Co-authored-by: Yue Wang <[email protected]>
---
.../rocketmq/studio/auth/LoginRateLimiter.java | 30 ++++++++++++++++++-
.../rocketmq/studio/auth/PasswordHasher.java | 10 +++++++
.../rocketmq/studio/common/util/EntityIds.java | 6 +++-
.../rocketmq/studio/auth/LoginRateLimiterTest.java | 34 ++++++++++++++++++++++
.../rocketmq/studio/auth/PasswordHasherTest.java | 12 ++++++++
.../util/EntityIdsTest.java} | 27 ++++++++++-------
6 files changed, 106 insertions(+), 13 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/LoginRateLimiter.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/LoginRateLimiter.java
index b0cf2711c..0dbe30ae1 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/LoginRateLimiter.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/LoginRateLimiter.java
@@ -35,16 +35,23 @@ public class LoginRateLimiter {
static final int MAX_FAILED_ATTEMPTS = 5;
static final Duration FAILURE_WINDOW = Duration.ofMinutes(5);
static final Duration LOCK_DURATION = Duration.ofMinutes(5);
+ static final int MAX_TRACKED_USERNAMES = 10_000;
private final Map<String, AttemptState> attempts = new
ConcurrentHashMap<>();
private final Clock clock;
+ private final int maxTrackedUsernames;
public LoginRateLimiter() {
this(Clock.systemUTC());
}
LoginRateLimiter(Clock clock) {
+ this(clock, MAX_TRACKED_USERNAMES);
+ }
+
+ LoginRateLimiter(Clock clock, int maxTrackedUsernames) {
this.clock = clock;
+ this.maxTrackedUsernames = maxTrackedUsernames;
}
/**
@@ -65,9 +72,16 @@ public class LoginRateLimiter {
attempts.remove(key, state);
}
- public void recordFailure(String username) {
+ public synchronized void recordFailure(String username) {
String key = key(username);
long now = clock.millis();
+ if (!attempts.containsKey(key) && attempts.size() >=
maxTrackedUsernames) {
+ removeExpiredAttempts(now);
+ if (attempts.size() >= maxTrackedUsernames) {
+ log.debug("Login rate limiter is at capacity; ignoring a new
username");
+ return;
+ }
+ }
AttemptState state = attempts.compute(key, (ignored, current) -> {
AttemptState base = current;
if (base == null || base.lockedUntilMillis() != 0
@@ -90,6 +104,20 @@ public class LoginRateLimiter {
attempts.remove(key(username));
}
+ int trackedUsernameCount() {
+ return attempts.size();
+ }
+
+ private void removeExpiredAttempts(long now) {
+ attempts.entrySet().removeIf(entry -> {
+ AttemptState state = entry.getValue();
+ if (state.lockedUntilMillis() != 0) {
+ return state.lockedUntilMillis() <= now;
+ }
+ return now - state.windowStartMillis() >=
FAILURE_WINDOW.toMillis();
+ });
+ }
+
private String key(String username) {
return username.trim().toLowerCase(Locale.ROOT);
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/PasswordHasher.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/PasswordHasher.java
index 33498738b..482081ec1 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/PasswordHasher.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/PasswordHasher.java
@@ -27,6 +27,9 @@ public class PasswordHasher {
private static final int SALT_LENGTH = 16;
private static final int MIN_ITERATIONS = 100_000;
private static final int MAX_ITERATIONS = 1_000_000;
+ private static final int DIGEST_LENGTH = KEY_LENGTH / Byte.SIZE;
+ private static final int ENCODED_SALT_LENGTH = 24;
+ private static final int ENCODED_DIGEST_LENGTH = 44;
private final SecureRandom secureRandom = new SecureRandom();
@@ -51,8 +54,15 @@ public class PasswordHasher {
if (iterations < MIN_ITERATIONS || iterations > MAX_ITERATIONS) {
return false;
}
+ if (parts[2].length() != ENCODED_SALT_LENGTH
+ || parts[3].length() != ENCODED_DIGEST_LENGTH) {
+ return false;
+ }
byte[] salt = Base64.getDecoder().decode(parts[2]);
byte[] expected = Base64.getDecoder().decode(parts[3]);
+ if (salt.length != SALT_LENGTH || expected.length !=
DIGEST_LENGTH) {
+ return false;
+ }
return MessageDigest.isEqual(expected,
derive(password.toCharArray(), salt, iterations));
} catch (IllegalArgumentException exception) {
return false;
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/common/util/EntityIds.java
b/server/src/main/java/org/apache/rocketmq/studio/common/util/EntityIds.java
index 98dd2fe06..eae66f8c0 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/common/util/EntityIds.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/common/util/EntityIds.java
@@ -38,7 +38,11 @@ public final class EntityIds {
throw new BusinessException(400, "id is required");
}
try {
- return Long.parseLong(value.trim());
+ long id = Long.parseLong(value.trim());
+ if (id <= 0) {
+ throw new BusinessException(400, "id must be a positive
numeric value: " + value);
+ }
+ return id;
} catch (NumberFormatException ex) {
throw new BusinessException(400, "id must be a numeric value: " +
value);
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/LoginRateLimiterTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/LoginRateLimiterTest.java
index 69a8c86ef..c18e08f21 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/LoginRateLimiterTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/LoginRateLimiterTest.java
@@ -116,6 +116,40 @@ class LoginRateLimiterTest {
assertThatThrownBy(() ->
limiter.checkAllowed("OPERATOR")).isInstanceOf(BusinessException.class);
}
+ @Test
+ void boundsTrackedUsernamesWithoutEvictingActiveLocksTest() {
+ limiter = new LoginRateLimiter(clock, 2);
+ for (int attempt = 0; attempt < LoginRateLimiter.MAX_FAILED_ATTEMPTS;
attempt++) {
+ limiter.recordFailure("operator");
+ }
+ limiter.recordFailure("second-user");
+
+ for (int suffix = 0; suffix < 20; suffix++) {
+ limiter.recordFailure("attacker-" + suffix);
+ }
+
+ assertThat(limiter.trackedUsernameCount()).isEqualTo(2);
+ assertThatThrownBy(() -> limiter.checkAllowed("operator"))
+ .isInstanceOf(BusinessException.class);
+ }
+
+ @Test
+ void reclaimsExpiredAttemptsBeforeAdmittingNewUsernamesTest() {
+ limiter = new LoginRateLimiter(clock, 2);
+ limiter.recordFailure("first-user");
+ limiter.recordFailure("second-user");
+ clock.advance(LoginRateLimiter.FAILURE_WINDOW.plusSeconds(1));
+
+ limiter.recordFailure("third-user");
+
+ assertThat(limiter.trackedUsernameCount()).isEqualTo(1);
+ for (int attempt = 1; attempt < LoginRateLimiter.MAX_FAILED_ATTEMPTS;
attempt++) {
+ limiter.recordFailure("third-user");
+ }
+ assertThatThrownBy(() -> limiter.checkAllowed("third-user"))
+ .isInstanceOf(BusinessException.class);
+ }
+
private static final class MutableClock extends Clock {
private Instant now;
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/PasswordHasherTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/PasswordHasherTest.java
index 7902bafc8..1de37352a 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/PasswordHasherTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/PasswordHasherTest.java
@@ -34,4 +34,16 @@ class PasswordHasherTest {
assertThat(hasher.matches("a-long-enough-password",
firstHash)).isTrue();
assertThat(hasher.matches("different-password", firstHash)).isFalse();
}
+ @Test
+ void rejectsUnexpectedSaltAndDigestSizesTest() {
+ PasswordHasher hasher = new PasswordHasher();
+ String validHash = hasher.hash("a-long-enough-password");
+ String[] parts = validHash.split("\\$", -1);
+
+ assertThat(hasher.matches("a-long-enough-password",
+ "pbkdf2$210000$" + "A".repeat(1_000_000) + "$" +
parts[3])).isFalse();
+ assertThat(hasher.matches("a-long-enough-password",
+ "pbkdf2$210000$" + parts[2] + "$AA==")).isFalse();
+ }
+
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/PasswordHasherTest.java
b/server/src/test/java/org/apache/rocketmq/studio/common/util/EntityIdsTest.java
similarity index 53%
copy from
server/src/test/java/org/apache/rocketmq/studio/auth/PasswordHasherTest.java
copy to
server/src/test/java/org/apache/rocketmq/studio/common/util/EntityIdsTest.java
index 7902bafc8..a9660c3b6 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/PasswordHasherTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/common/util/EntityIdsTest.java
@@ -14,24 +14,29 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.rocketmq.studio.auth;
+package org.apache.rocketmq.studio.common.util;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
-class PasswordHasherTest {
+class EntityIdsTest {
@Test
- void hashesPasswordsWithSaltAndVerifiesOnlyTheMatchingPassword() {
- PasswordHasher hasher = new PasswordHasher();
-
- String firstHash = hasher.hash("a-long-enough-password");
- String secondHash = hasher.hash("a-long-enough-password");
+ void parsesTrimmedPositiveIdentifiers() {
+ assertThat(EntityIds.parseId(" 42 ")).isEqualTo(42L);
+ }
- assertThat(firstHash).startsWith("pbkdf2$");
- assertThat(firstHash).isNotEqualTo(secondHash);
- assertThat(hasher.matches("a-long-enough-password",
firstHash)).isTrue();
- assertThat(hasher.matches("different-password", firstHash)).isFalse();
+ @Test
+ void rejectsZeroAndNegativeIdentifiers() {
+ assertThatThrownBy(() -> EntityIds.parseId("0"))
+ .isInstanceOfSatisfying(BusinessException.class,
+ error -> assertThat(error.getCode()).isEqualTo(400))
+ .hasMessageContaining("positive numeric value");
+ assertThatThrownBy(() -> EntityIds.parseId("-1"))
+ .isInstanceOf(BusinessException.class)
+ .hasMessageContaining("positive numeric value");
}
}