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 0f2b1302 fix: harden authentication management boundaries (#947)
0f2b1302 is described below
commit 0f2b130241e60da73ff88d88b475ece7063050ab
Author: aias00 <[email protected]>
AuthorDate: Tue Aug 4 03:06:57 2026 -0700
fix: harden authentication management boundaries (#947)
* [ISSUE #840] Validate login request bodies
* fix(server): validate acl user create requests
* fix(server): normalize auth public paths
---
.../rocketmq/studio/auth/AuthController.java | 6 +++-
.../rocketmq/studio/auth/AuthInterceptor.java | 14 ++++++++
.../apache/rocketmq/studio/auth/AuthService.java | 4 +++
.../studio/instance/acl/AclController.java | 4 +--
.../studio/instance/acl/CreateAclUserDTO.java | 38 ++++++++++++++++++++++
.../rocketmq/studio/auth/AuthControllerTest.java | 12 +++++++
.../rocketmq/studio/auth/AuthInterceptorTest.java | 24 ++++++++++++++
.../rocketmq/studio/auth/AuthServiceTest.java | 8 +++++
.../studio/instance/acl/AclControllerTest.java | 32 +++++++++++++++++-
9 files changed, 138 insertions(+), 4 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
index d5398dbd..cda7e036 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthController.java
@@ -18,6 +18,7 @@
package org.apache.rocketmq.studio.auth;
import org.apache.rocketmq.studio.common.domain.Result;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
@@ -50,7 +51,10 @@ public class AuthController {
}
@PostMapping("/login")
- public Result<LoginVO> login(@RequestBody LoginDTO request) {
+ public Result<LoginVO> login(@RequestBody(required = false) LoginDTO
request) {
+ if (request == null) {
+ throw new BusinessException(400, "Login request is required");
+ }
return Result.ok(authService.login(request));
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
index 4d167137..1b1ae46d 100644
--- a/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
+++ b/server/src/main/java/org/apache/rocketmq/studio/auth/AuthInterceptor.java
@@ -50,6 +50,7 @@ public class AuthInterceptor implements HandlerInterceptor {
}
private boolean isPublicPath(String path) {
+ path = normalizePath(path);
return path.equals("/api/auth/login")
|| path.equals("/api/auth/status")
|| path.startsWith("/api-docs")
@@ -57,6 +58,19 @@ public class AuthInterceptor implements HandlerInterceptor {
|| path.startsWith("/actuator/health");
}
+ private String normalizePath(String path) {
+ if (path == null || path.isBlank()) {
+ return "";
+ }
+ if (path.equals("/")) {
+ return path;
+ }
+ while (path.endsWith("/")) {
+ path = path.substring(0, path.length() - 1);
+ }
+ return path;
+ }
+
private String requestPath(HttpServletRequest request) {
String servletPath = request.getServletPath();
if (servletPath != null && !servletPath.isBlank()) {
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 66ec35dd..102e1cf1 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
@@ -50,6 +50,10 @@ public class AuthService {
}
public LoginVO login(LoginDTO request) {
+ if (request == null) {
+ throw new BusinessException(400, "Login request is required");
+ }
+
log.info("Login attempt for user: {}", request.getUsername());
if (request.getUsername() == null || request.getUsername().isBlank()) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
index 1acb7a3a..29671efe 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclController.java
@@ -70,8 +70,8 @@ public class AclController {
}
@PostMapping("/users/create")
- public Result<AclUserVO> createUser(@RequestBody AclUserVO user) {
- return Result.ok(aclService.createUser(user));
+ public Result<AclUserVO> createUser(@Valid @RequestBody CreateAclUserDTO
user) {
+ return Result.ok(aclService.createUser(user.toAclUserVO()));
}
@PostMapping("/users/update")
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/CreateAclUserDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/CreateAclUserDTO.java
new file mode 100644
index 00000000..6b2d674d
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/CreateAclUserDTO.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.instance.acl;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class CreateAclUserDTO {
+ @NotBlank(message = "username is required")
+ private String username;
+ private boolean admin;
+ private List<String> clusters;
+
+ public AclUserVO toAclUserVO() {
+ AclUserVO user = new AclUserVO();
+ user.setUsername(username);
+ user.setAdmin(admin);
+ user.setClusters(clusters);
+ return user;
+ }
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
index 9d0a5f16..bed876d6 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthControllerTest.java
@@ -30,6 +30,7 @@ import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -144,6 +145,17 @@ class AuthControllerTest {
.andExpect(jsonPath("$.data.user.admin").value(true));
}
+ @Test
+ void loginShouldRejectMissingRequestBody() throws Exception {
+ mockMvc.perform(post("/api/auth/login")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.message").value("Login request is
required"));
+
+ verify(authService, never()).login(any(LoginDTO.class));
+ }
+
@Test
void logoutShouldReturnSuccess() throws Exception {
doNothing().when(authService).logout("Bearer token-1");
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
index 5aa924ba..afc91025 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthInterceptorTest.java
@@ -89,6 +89,18 @@ class AuthInterceptorTest {
assertThat(allowed).isTrue();
}
+ @Test
+ void shouldAllowLoginEndpointWithTrailingSlashWhenLoginIsEnabled() throws
Exception {
+ AuthProperties properties = new AuthProperties();
+ properties.setLoginRequired(true);
+ AuthInterceptor interceptor = new AuthInterceptor(properties, new
AuthService(properties));
+ MockHttpServletRequest request = new MockHttpServletRequest("POST",
"/api/auth/login/");
+
+ boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
+
+ assertThat(allowed).isTrue();
+ }
+
@Test
void shouldAllowAuthStatusEndpointWhenLoginIsEnabled() throws Exception {
AuthProperties properties = new AuthProperties();
@@ -100,4 +112,16 @@ class AuthInterceptorTest {
assertThat(allowed).isTrue();
}
+
+ @Test
+ void shouldAllowAuthStatusEndpointWithTrailingSlashWhenLoginIsEnabled()
throws Exception {
+ AuthProperties properties = new AuthProperties();
+ properties.setLoginRequired(true);
+ AuthInterceptor interceptor = new AuthInterceptor(properties, new
AuthService(properties));
+ MockHttpServletRequest request = new MockHttpServletRequest("GET",
"/api/auth/status/");
+
+ boolean allowed = interceptor.preHandle(request, new
MockHttpServletResponse(), new Object());
+
+ assertThat(allowed).isTrue();
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceTest.java
index 31959b52..21a8d4ac 100644
--- a/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceTest.java
+++ b/server/src/test/java/org/apache/rocketmq/studio/auth/AuthServiceTest.java
@@ -188,6 +188,14 @@ class AuthServiceTest {
assertThat(activeTokens).containsOnlyKeys(activeSession.getToken());
}
+ @Test
+ void loginShouldRejectNullRequest() {
+ assertThatThrownBy(() -> authService.login(null))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("Login request is required")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(400));
+ }
+
@Test
void loginShouldThrowWhenUsernameIsNull() {
LoginDTO request = new LoginDTO();
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
index 9f780696..6b8baf1b 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclControllerTest.java
@@ -20,6 +20,7 @@ package org.apache.rocketmq.studio.instance.acl;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.rocketmq.studio.common.exception.BusinessException;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@@ -31,6 +32,7 @@ import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
@@ -221,11 +223,39 @@ class AclControllerTest {
mockMvc.perform(post("/api/acl/users/create")
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"username\":\"new-user\"}"))
+ .content("""
+ {
+ "username": "new-user",
+ "admin": true,
+ "clusters": ["cluster-a"]
+ }
+ """))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.accessKey").value("access-key-123456"))
.andExpect(jsonPath("$.data.secretKey").value("secret-key-987654"));
+
+ ArgumentCaptor<AclUserVO> captor =
ArgumentCaptor.forClass(AclUserVO.class);
+ verify(aclService).createUser(captor.capture());
+ assertThat(captor.getValue().getUsername()).isEqualTo("new-user");
+ assertThat(captor.getValue().isAdmin()).isTrue();
+
assertThat(captor.getValue().getClusters()).containsExactly("cluster-a");
+ }
+
+ @Test
+ void createUserShouldRejectMissingUsername() throws Exception {
+ mockMvc.perform(post("/api/acl/users/create")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {
+ "admin": false
+ }
+ """))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(400))
+ .andExpect(jsonPath("$.message").value("username is
required"));
+
+ verifyNoInteractions(aclService);
}
@Test