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 0138c0726 fix(auth): burn a dummy hash on unknown usernames to close
the login timing side channel (#5134)
0138c0726 is described below
commit 0138c0726ed0a46dfd2cb3eb364f8e72b25f0216
Author: cyberslack_lee <[email protected]>
AuthorDate: Thu Oct 1 18:12:02 2026 +0800
fix(auth): burn a dummy hash on unknown usernames to close the login timing
side channel (#5134)
Signed-off-by: enkilee <[email protected]>
---
.../apache/rocketmq/studio/auth/AuthService.java | 12 +++++++--
.../studio/auth/AuthServiceDatabaseTest.java | 29 ++++++++++++++++++++++
2 files changed, 39 insertions(+), 2 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
index 2c13ec58b..782608abd 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthService.java
@@ -382,8 +382,16 @@ public class AuthService {
private LoginVO loginDatabaseUser(LoginDTO request) {
ensureBootstrapUsers();
- RmqStudioUser user = findUserByUsername(request.getUsername())
- .orElseThrow(() -> new BusinessException(401, "Invalid
username or password"));
+ Optional<RmqStudioUser> found =
findUserByUsername(request.getUsername());
+ if (found.isEmpty()) {
+ // Burn one dummy derivation so the response timing matches the
wrong-password path
+ // on an existing account. Without this, an attacker could
distinguish "user not
+ // found" (fast) from "user found but wrong password" (slow
PBKDF2) and enumerate
+ // valid usernames by measuring response time.
+ passwordHasher.matches(request.getPassword(), DUMMY_PASSWORD_HASH);
+ throw new BusinessException(401, "Invalid username or password");
+ }
+ RmqStudioUser user = found.get();
if (!Boolean.TRUE.equals(user.getEnabled())) {
// Answer exactly like a wrong password on an enabled account:
burn one dummy
// derivation so the response timing matches, and never touch this
account's
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
index 9e0a43c9f..dd993bb8e 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceDatabaseTest.java
@@ -511,6 +511,35 @@ class AuthServiceDatabaseTest {
.hasMessage("Invalid username or password");
}
+ @Test
+ void unknownUserLoginBurnsDummyHashToPreventTimingSideChannelTest() {
+ PasswordHasher hasherSpy = mock(PasswordHasher.class);
+ SettingsRepository repository = mock(SettingsRepository.class);
+ when(repository.loadGeneralSettings())
+
.thenReturn(GeneralSettingsVO.builder().sessionTimeout(30).build());
+ authService = new AuthService(new AuthProperties(), repository,
+ Clock.fixed(Instant.parse("2026-08-13T00:00:00Z"),
ZoneOffset.UTC), userMapper,
+ sessionMapper, hasherSpy);
+ when(userMapper.selectCount(isNull())).thenReturn(1L);
+ when(userMapper.selectOne(any(Wrapper.class))).thenReturn(null);
+
+ LoginDTO request = new LoginDTO();
+ request.setUsername("no-such-user");
+ request.setPassword("any-password");
+
+ assertThatThrownBy(() -> authService.login(request))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(exception ->
+ assertThat(((BusinessException)
exception).getCode()).isEqualTo(401))
+ .hasMessage("Invalid username or password");
+ // The dummy PBKDF2 derivation must run even when the user is not
found, so the
+ // response timing is indistinguishable from a wrong-password attempt
on a real
+ // account. Without this, an attacker could enumerate valid usernames
by measuring
+ // the response time difference between "user not found" and "wrong
password".
+ verify(hasherSpy, times(1)).matches(anyString(), argThat(hash ->
+ hash != null && hash.startsWith("pbkdf2$210000$")));
+ }
+
@Test
void
enabledAccountsWithWrongPasswordsGetTheUniformInvalidCredentialsResponse() {
when(userMapper.selectCount(isNull())).thenReturn(1L);