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 354e5fd4 fix(auth): restrict privileged reads and harden ACL updates
(#2046)
354e5fd4 is described below
commit 354e5fd4584e1a2f52b1ff1dc58070635e2257d6
Author: youngkermit8-coder <[email protected]>
AuthorDate: Thu Aug 13 19:50:25 2026 +0800
fix(auth): restrict privileged reads and harden ACL updates (#2046)
Consolidates #1716, #1753, #2046: limit cloud catalog GET endpoints to
administrators, reject blank ACL usernames on update, and restrict remote
ACL rule discovery to administrators.
---
.../rocketmq/studio/auth/AuthInterceptor.java | 7 ++
.../rocketmq/studio/instance/acl/AclService.java | 3 +
.../rocketmq/studio/auth/AuthInterceptorTest.java | 74 ++++++++++++++++++++++
.../studio/instance/acl/AclServiceTest.java | 15 +++++
4 files changed, 99 insertions(+)
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 ee87fc81..9669a9b9 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
@@ -102,10 +102,17 @@ public class AuthInterceptor implements
HandlerInterceptor {
private boolean isAdminOnlyGetPath(String path) {
String normalizedPath = normalizePath(stripPathParameters(path));
return "/api/llm/models".equals(normalizedPath)
+ || isCloudCatalogPath(normalizedPath)
+ || "/api/acl/remote/rules".equals(normalizedPath)
|| isCredentialRevealPath(normalizedPath, "/api/acl/users/")
|| isCredentialRevealPath(normalizedPath,
"/api/cloud-credentials/");
}
+ private boolean isCloudCatalogPath(String path) {
+ return path.startsWith("/api/cloud/aliyun/")
+ || path.startsWith("/api/cloud/tencent/");
+ }
+
private boolean isCredentialRevealPath(String path, String prefix) {
return path != null && path.startsWith(prefix) &&
path.endsWith("/credentials");
}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
index d6ea5435..f9b98804 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/AclService.java
@@ -125,6 +125,9 @@ public class AclService {
log.info("Updating ACL user id={}, username={}", user.getId(),
user.getUsername());
AclUserVO existing = aclRepository.findUserById(user.getId())
.orElseThrow(() -> new BusinessException(404, "ACL user not
found: " + user.getId()));
+ if (user.getUsername() != null &&
!StringUtils.hasText(user.getUsername())) {
+ throw new BusinessException(400, "ACL username is required");
+ }
AclUserVO merged = AclUserVO.builder()
.id(existing.getId())
.username(user.getUsername() == null ? existing.getUsername()
: user.getUsername())
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 64727985..936de2e3 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
@@ -21,6 +21,8 @@ import org.junit.jupiter.api.AfterEach;
import org.apache.rocketmq.studio.settings.GeneralSettingsVO;
import org.apache.rocketmq.studio.settings.SettingsRepository;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.http.HttpHeaders;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
@@ -217,6 +219,36 @@ class AuthInterceptorTest {
assertThat(allowed).isTrue();
}
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "/api/cloud/aliyun/regions",
+ "/api/cloud/aliyun/instances",
+ "/api/cloud/tencent/regions",
+ "/api/cloud/tencent/instances"
+ })
+ void shouldRejectCloudCatalogReadsForNonAdminUser(String path) throws
Exception {
+ TestSession session = login(false);
+ MockHttpServletRequest request = authenticatedRequest("GET", path,
session.token());
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean allowed = session.interceptor().preHandle(request, response,
new Object());
+
+ assertThat(allowed).isFalse();
+ assertThat(response.getStatus()).isEqualTo(403);
+ }
+
+ @Test
+ void shouldAllowCloudCatalogReadsForAdminUser() throws Exception {
+ TestSession session = login(true);
+ MockHttpServletRequest request = authenticatedRequest(
+ "GET", "/api/cloud/aliyun/instances", session.token());
+
+ boolean allowed = session.interceptor().preHandle(
+ request, new MockHttpServletResponse(), new Object());
+
+ assertThat(allowed).isTrue();
+ }
+
@Test
void shouldRejectLlmModelDiscoveryForNonAdminUser() throws Exception {
TestSession session = login(false);
@@ -382,6 +414,48 @@ class AuthInterceptorTest {
assertThat(allowed).isTrue();
}
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "/api/acl/remote/rules",
+ "/api/acl/remote/rules/",
+ "/api/acl;source=remote/remote;view=all/rules;format=json"
+ })
+ void shouldRejectRemoteAclPolicyDiscoveryForNonAdminUser(String path)
throws Exception {
+ TestSession session = login(false);
+ MockHttpServletRequest request = authenticatedRequest("GET", path,
session.token());
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ boolean allowed = session.interceptor().preHandle(request, response,
new Object());
+
+ assertThat(allowed).isFalse();
+ assertThat(response.getStatus()).isEqualTo(403);
+ assertThat(response.getContentAsString()).contains("Admin permission
required");
+ }
+
+ @Test
+ void shouldAllowRemoteAclPolicyDiscoveryForAdminUser() throws Exception {
+ TestSession session = login(true);
+ MockHttpServletRequest request = authenticatedRequest(
+ "GET", "/api/acl/remote/rules", session.token());
+
+ boolean allowed = session.interceptor().preHandle(
+ request, new MockHttpServletResponse(), new Object());
+
+ assertThat(allowed).isTrue();
+ }
+
+ @Test
+ void shouldKeepLocalAclRulesReadableForNonAdminUser() throws Exception {
+ TestSession session = login(false);
+ MockHttpServletRequest request = authenticatedRequest(
+ "GET", "/api/acl/rules", session.token());
+
+ boolean allowed = session.interceptor().preHandle(
+ request, new MockHttpServletResponse(), new Object());
+
+ assertThat(allowed).isTrue();
+ }
+
private TestSession login(boolean admin) {
AuthProperties properties = new AuthProperties();
properties.setLoginRequired(true);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
index 4408bcca..1acbb2be 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/AclServiceTest.java
@@ -454,6 +454,21 @@ class AclServiceTest {
assertThat(captor.getValue().isAdmin()).isTrue();
}
+ @Test
+ void updateUserShouldRejectBlankUsernameWithoutSaving() {
+ UpdateAclUserDTO input = new UpdateAclUserDTO();
+ input.setId("user-1");
+ input.setUsername(" ");
+
+
when(aclRepository.findUserById("user-1")).thenReturn(Optional.of(existingUser));
+
+ assertThatThrownBy(() -> aclService.updateUser(input))
+ .isInstanceOf(BusinessException.class)
+ .hasMessage("ACL username is required")
+ .satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(400));
+ verify(aclRepository, never()).saveUser(any(AclUserVO.class));
+ }
+
@Test
void updateUserShouldThrowWhenUserDoesNotExist() {
UpdateAclUserDTO input = new UpdateAclUserDTO();