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");
     }
 }

Reply via email to