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 ac0ab5ba9 fix: validate requests at service boundaries (#2163)
ac0ab5ba9 is described below

commit ac0ab5ba923cd31352b835ef1735d88832a1ded7
Author: xdz997 <[email protected]>
AuthorDate: Tue Aug 18 17:32:09 2026 +0800

    fix: validate requests at service boundaries (#2163)
    
    * fix: validate server requests at service boundaries
    
    * fix: match plain ACL address grammar
---
 server/pom.xml                                     |   8 +
 .../studio/cluster/k8s/K8sCertService.java         |  20 +-
 .../rocketmq/studio/instance/acl/AclService.java   |  11 ++
 .../acl/PlainAclRemoteAddressValidator.java        | 214 +++++++++++++++++++++
 .../rocketmq/studio/ops/alert/AlertService.java    |   8 +
 .../provider/apache/RocketMQDLQProvider.java       |  13 +-
 .../studio/cluster/k8s/K8sCertControllerTest.java  |  17 ++
 .../studio/cluster/k8s/K8sCertServiceTest.java     |  51 ++++-
 .../studio/instance/acl/AclControllerTest.java     |  28 ++-
 .../studio/instance/acl/AclServiceTest.java        |  68 ++++++-
 .../acl/PlainAclRemoteAddressValidatorTest.java    |  64 ++++++
 .../studio/ops/alert/AlertServiceTest.java         |  60 ++++++
 .../provider/apache/RocketMQDLQProviderTest.java   |  67 +++++++
 13 files changed, 619 insertions(+), 10 deletions(-)

diff --git a/server/pom.xml b/server/pom.xml
index dbcc56046..ca6c5c04d 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -98,6 +98,14 @@
                 </exclusion>
             </exclusions>
         </dependency>
+        <!-- Used by PlainAclRemoteAddressValidator for IP address grammar 
checks;
+             currently arrives transitively via rocketmq-tools -> 
rocketmq-common,
+             declared explicitly to keep the direct usage resolvable. -->
+        <dependency>
+            <groupId>commons-validator</groupId>
+            <artifactId>commons-validator</artifactId>
+            <version>1.10.0</version>
+        </dependency>
         <!-- Aliyun RocketMQ 5.x OpenAPI (async V2 SDK), used by 
provider/alibaba -->
         <dependency>
             <groupId>com.aliyun</groupId>
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
index 474be6dec..287d576aa 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertService.java
@@ -69,6 +69,10 @@ public class K8sCertService {
 
     public K8sCertVO createCert(CreateCertDTO command) {
         requireCommand(command);
+        if (command.getType() == null) {
+            throw new BusinessException(400, "type is required");
+        }
+        CertType type = parseCertType(command.getType());
         log.info("Creating K8s certificate: {}", command.getK8sId());
 
         LocalDateTime now = LocalDateTime.now(clock);
@@ -87,7 +91,7 @@ public class K8sCertService {
         K8sCertVO cert = K8sCertVO.builder()
                 .k8sId(command.getK8sId())
                 .cluster(command.getCluster())
-                .type(CertType.valueOf(command.getType()))
+                .type(type)
                 .issuer(issuer)
                 .notBefore(notBefore)
                 .notAfter(notAfter)
@@ -107,6 +111,7 @@ public class K8sCertService {
 
     public K8sCertVO updateCert(UpdateCertDTO command) {
         requireCommand(command);
+        CertType type = command.getType() == null ? null : 
parseCertType(command.getType());
         log.info("Updating K8s certificate: {}", command.getId());
         K8sCertVO existing = k8sCertRepository.findById(command.getId())
                 .orElseThrow(() -> new BusinessException(404, "Certificate not 
found: " + command.getId()));
@@ -121,8 +126,8 @@ public class K8sCertService {
         if (cluster != null) {
             updated.setCluster(cluster);
         }
-        if (command.getType() != null) {
-            updated.setType(CertType.valueOf(command.getType()));
+        if (type != null) {
+            updated.setType(type);
         }
         if (issuer != null) {
             updated.setIssuer(issuer);
@@ -174,6 +179,15 @@ public class K8sCertService {
         log.info("K8s certificate deleted: {}", command.getId());
     }
 
+    private CertType parseCertType(String type) {
+        try {
+            return CertType.valueOf(type);
+        } catch (IllegalArgumentException e) {
+            throw new BusinessException(400, "Invalid certificate type: " + 
type
+                    + ". Valid types: TLS, mTLS, ServiceAccount");
+        }
+    }
+
     private void requireCommand(Object command) {
         if (command == null) {
             throw new BusinessException(400, "K8s certificate request is 
required");
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 0c038c84b..b10f945f3 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
@@ -211,6 +211,17 @@ public class AclService {
         if (config == null || !StringUtils.hasText(config.getAccessKey())) {
             throw new BusinessException(400, "accessKey is required");
         }
+        if (config.getWhiteRemoteAddress() != null) {
+            String normalizedWhiteRemoteAddress = 
config.getWhiteRemoteAddress().trim();
+            config.setWhiteRemoteAddress(normalizedWhiteRemoteAddress.isEmpty()
+                    ? null : normalizedWhiteRemoteAddress);
+        }
+        if (StringUtils.hasText(config.getWhiteRemoteAddress())
+                && 
!PlainAclRemoteAddressValidator.isValid(config.getWhiteRemoteAddress())) {
+            throw new BusinessException(400,
+                    "whiteRemoteAddress is not a valid plain ACL address 
expression: "
+                            + config.getWhiteRemoteAddress());
+        }
         log.info("Creating/updating plain access config accessKey={}", 
config.getAccessKey());
         PlainAccessConfigVO saved = 
aclRepository.createAndUpdatePlainAccessConfig(config);
         String auditDetail = "admin=" + saved.isAdmin()
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PlainAclRemoteAddressValidator.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PlainAclRemoteAddressValidator.java
new file mode 100644
index 000000000..54d6ffb07
--- /dev/null
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/acl/PlainAclRemoteAddressValidator.java
@@ -0,0 +1,214 @@
+/*
+ * 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 org.apache.commons.validator.routines.InetAddressValidator;
+
+import java.util.regex.Pattern;
+
+/**
+ * Validates the address-expression grammar used by RocketMQ plain ACL.
+ *
+ * <p>This is deliberately separate from {@link IpRangeMatcher}: plain ACL 
does not use CIDR.
+ * Besides exact IPv4/IPv6 addresses it accepts the wildcard, range, 
comma-list and final-segment
+ * brace forms understood by RocketMQ's legacy {@code 
RemoteAddressStrategyFactory}.</p>
+ */
+final class PlainAclRemoteAddressValidator {
+
+    private static final InetAddressValidator INET_ADDRESS_VALIDATOR = 
InetAddressValidator.getInstance();
+    private static final Pattern DECIMAL = Pattern.compile("\\d{1,3}");
+    private static final Pattern HEX = Pattern.compile("[0-9a-fA-F]{1,4}");
+
+    private PlainAclRemoteAddressValidator() {
+    }
+
+    static boolean isValid(String expression) {
+        if (expression == null || expression.isBlank()) {
+            return true;
+        }
+        if (!expression.equals(expression.trim()) || 
containsWhitespace(expression)) {
+            return false;
+        }
+        if (isAllAddresses(expression)) {
+            return true;
+        }
+        if (expression.indexOf('/') >= 0) {
+            return false;
+        }
+        if (expression.indexOf('{') >= 0 || expression.indexOf('}') >= 0) {
+            return isValidFinalSegmentSet(expression);
+        }
+        if (expression.indexOf(',') >= 0) {
+            return isValidAddressList(expression);
+        }
+        if (isExactAddress(expression)) {
+            return true;
+        }
+        if (expression.indexOf(':') >= 0) {
+            return isValidIpv6Range(expression);
+        }
+        return isValidIpv4Range(expression);
+    }
+
+    private static boolean isAllAddresses(String expression) {
+        return "*".equals(expression)
+                || "*.*.*.*".equals(expression)
+                || "*:*:*:*:*:*:*:*".equals(expression);
+    }
+
+    private static boolean containsWhitespace(String expression) {
+        for (int i = 0; i < expression.length(); i++) {
+            if (Character.isWhitespace(expression.charAt(i))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean isExactAddress(String address) {
+        return INET_ADDRESS_VALIDATOR.isValidInet4Address(address)
+                || INET_ADDRESS_VALIDATOR.isValidInet6Address(address);
+    }
+
+    private static boolean isValidAddressList(String expression) {
+        String[] addresses = expression.split(",", -1);
+        if (addresses.length < 2) {
+            return false;
+        }
+        for (String address : addresses) {
+            if (!isExactAddress(address)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static boolean isValidFinalSegmentSet(String expression) {
+        int openingBrace = expression.indexOf('{');
+        if (openingBrace <= 0
+                || openingBrace != expression.lastIndexOf('{')
+                || expression.indexOf('}') != expression.length() - 1
+                || expression.indexOf('}') != expression.lastIndexOf('}')) {
+            return false;
+        }
+        char separator = expression.indexOf(':') >= 0 ? ':' : '.';
+        if (expression.charAt(openingBrace - 1) != separator) {
+            return false;
+        }
+        String prefix = expression.substring(0, openingBrace);
+        String members = expression.substring(openingBrace + 1, 
expression.length() - 1);
+        String[] values = members.split(",", -1);
+        if (values.length == 0) {
+            return false;
+        }
+        for (String value : values) {
+            if (value.isEmpty() || !isExactAddress(prefix + value)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static boolean isValidIpv4Range(String expression) {
+        String[] segments = expression.split("\\.", -1);
+        if (segments.length != 4 || !isDecimalOctet(segments[0])) {
+            return false;
+        }
+        int variableIndex = -1;
+        for (int i = 1; i < segments.length; i++) {
+            String segment = segments[i];
+            if (variableIndex < 0 && isDecimalOctet(segment)) {
+                continue;
+            }
+            if (variableIndex < 0 && ("*".equals(segment) || 
isValidRange(segment, 10, 255))) {
+                variableIndex = i;
+                continue;
+            }
+            if (variableIndex >= 0 && "*".equals(segment)) {
+                continue;
+            }
+            return false;
+        }
+        return variableIndex >= 0;
+    }
+
+    private static boolean isValidIpv6Range(String expression) {
+        if (expression.indexOf('.') >= 0 || expression.indexOf("::") != 
expression.lastIndexOf("::")) {
+            return false;
+        }
+        String[] segments = expression.split(":", -1);
+        int nonEmptySegments = 0;
+        int firstVariable = -1;
+        for (int i = 0; i < segments.length; i++) {
+            String segment = segments[i];
+            if (segment.isEmpty()) {
+                continue;
+            }
+            nonEmptySegments++;
+            if (HEX.matcher(segment).matches()) {
+                if (firstVariable >= 0) {
+                    return false;
+                }
+                continue;
+            }
+            if (firstVariable < 0 && ("*".equals(segment) || 
isValidRange(segment, 16, 0xffff))) {
+                firstVariable = i;
+                continue;
+            }
+            if (firstVariable >= 0 && "*".equals(segment) && i == 
segments.length - 1) {
+                continue;
+            }
+            return false;
+        }
+        if (firstVariable <= 0 || nonEmptySegments > 8) {
+            return false;
+        }
+        String variable = segments[firstVariable];
+        if ("*".equals(variable)) {
+            return firstVariable == segments.length - 1;
+        }
+        return firstVariable == segments.length - 1
+                || firstVariable == segments.length - 2 && 
"*".equals(segments[segments.length - 1]);
+    }
+
+    private static boolean isDecimalOctet(String value) {
+        if (!DECIMAL.matcher(value).matches()) {
+            return false;
+        }
+        return Integer.parseInt(value) <= 255;
+    }
+
+    private static boolean isValidRange(String value, int radix, int maximum) {
+        int separator = value.indexOf('-');
+        if (separator <= 0 || separator != value.lastIndexOf('-') || separator 
== value.length() - 1) {
+            return false;
+        }
+        String startValue = value.substring(0, separator);
+        String endValue = value.substring(separator + 1);
+        Pattern componentPattern = radix == 10 ? DECIMAL : HEX;
+        if (!componentPattern.matcher(startValue).matches() || 
!componentPattern.matcher(endValue).matches()) {
+            return false;
+        }
+        try {
+            int start = Integer.parseInt(startValue, radix);
+            int end = Integer.parseInt(endValue, radix);
+            return start <= end && end <= maximum;
+        } catch (NumberFormatException ignored) {
+            return false;
+        }
+    }
+}
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
index 53cc8a4a5..9d98ab672 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
@@ -95,6 +95,10 @@ public class AlertService {
         if (rule == null) {
             throw new BusinessException(400, "Alert rule request is required");
         }
+        if (!hasText(rule.getName())) {
+            throw new BusinessException(400, "Alert rule name is required");
+        }
+        rule.setName(rule.getName().trim());
         log.info("Creating alert rule: {}", rule.getName());
         AlertRuleVO saved = alertRepository.saveRule(rule);
         auditRule("CREATE_ALERT_RULE", saved, null);
@@ -106,6 +110,10 @@ public class AlertService {
         if (rule == null) {
             throw new BusinessException(400, "Alert rule request is required");
         }
+        if (!hasText(rule.getName())) {
+            throw new BusinessException(400, "Alert rule name is required");
+        }
+        rule.setName(rule.getName().trim());
         Long id = rule.getId();
         log.info("Updating alert rule: {}", id);
         validateRuleId(id);
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
index 18a0c37e9..e7d44c57b 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProvider.java
@@ -140,11 +140,18 @@ public class RocketMQDLQProvider implements DLQProvider {
     @Override
     public DLQResendResultVO resendMessages(String instanceId, String 
groupName, Long startTime, Long endTime,
                                              String targetTopic) {
-        String endpoint = 
runtimeAdminClientResolver.resolveEndpoint(instanceId);
-        String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
-
+        if (!StringUtils.hasText(groupName)) {
+            throw new BusinessException(400, "groupName is required for DLQ 
resend");
+        }
+        groupName = groupName.trim();
         long end = endTime != null ? endTime : System.currentTimeMillis();
         long begin = startTime != null ? startTime : end - ONE_HOUR_MILLIS;
+        if (begin >= end) {
+            throw new BusinessException(400, "DLQ resend start time must be 
before end time");
+        }
+
+        String endpoint = 
runtimeAdminClientResolver.resolveEndpoint(instanceId);
+        String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
 
         DeadLetterScanResult scanResult;
         try {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertControllerTest.java
index f325388e0..9f3d34a13 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertControllerTest.java
@@ -209,6 +209,23 @@ class K8sCertControllerTest {
         verifyNoInteractions(k8sCertService);
     }
 
+    @Test
+    void updateCertShouldRejectUnsupportedType() throws Exception {
+        mockMvc.perform(post("/api/k8s-certs/update")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {
+                                    "id": 1,
+                                    "type": "PEM"
+                                }
+                                """))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message").value("type must be one of 
TLS, mTLS, ServiceAccount"));
+
+        verifyNoInteractions(k8sCertService);
+    }
+
     @Test
     void renewCertShouldRejectBlankId() throws Exception {
         mockMvc.perform(post("/api/k8s-certs/renew")
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
index 035b2931c..e86a10e64 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/k8s/K8sCertServiceTest.java
@@ -42,8 +42,8 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
@@ -201,6 +201,55 @@ class K8sCertServiceTest {
         verifyNoInteractions(k8sCertRepository);
     }
 
+    @Test
+    void createCertShouldRejectInvalidTypeBeforeSave() {
+        CreateCertDTO command = CreateCertDTO.builder()
+                .k8sId("bad-cert")
+                .cluster("test-cluster")
+                .type("INVALID")
+                .issuer("test-issuer")
+                .build();
+
+        assertThatThrownBy(() -> k8sCertService.createCert(command))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Invalid certificate type: INVALID. Valid types: 
TLS, mTLS, ServiceAccount")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
+
+        verify(k8sCertRepository, never()).save(any());
+        verify(operationAuditService, never()).record(any(), any(), any(), 
any(), any(), any(), any());
+    }
+
+    @Test
+    void createCertShouldRejectMissingTypeBeforeSave() {
+        CreateCertDTO command = CreateCertDTO.builder()
+                .k8sId("bad-cert")
+                .cluster("test-cluster")
+                .issuer("test-issuer")
+                .build();
+
+        assertThatThrownBy(() -> k8sCertService.createCert(command))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("type is required")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
+
+        verifyNoInteractions(k8sCertRepository, operationAuditService);
+    }
+
+    @Test
+    void updateCertShouldRejectInvalidTypeBeforeSave() {
+        UpdateCertDTO command = UpdateCertDTO.builder()
+                .id(1L)
+                .type("INVALID")
+                .build();
+
+        assertThatThrownBy(() -> k8sCertService.updateCert(command))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Invalid certificate type: INVALID. Valid types: 
TLS, mTLS, ServiceAccount")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
+
+        verifyNoInteractions(k8sCertRepository, operationAuditService);
+    }
+
     @Test
     void createCertShouldSetCorrectValidityPeriod() {
         CreateCertDTO command = CreateCertDTO.builder()
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 940bacd5d..bd88a91ac 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
@@ -451,6 +451,7 @@ class AclControllerTest {
                 .accessKey("svc-x")
                 .admin(false)
                 .defaultTopicPerm("PUB")
+                .whiteRemoteAddress("10.0.1.*")
                 .topicPerms(List.of("order-*=PUB"))
                 .build();
         
when(aclService.createAndUpdatePlainAccessConfig(any(PlainAccessConfigVO.class))).thenReturn(saved);
@@ -459,11 +460,14 @@ class AclControllerTest {
                         .contentType(MediaType.APPLICATION_JSON)
                         .content(objectMapper.writeValueAsString(Map.of(
                                 "accessKey", "svc-x", "admin", false,
-                                "defaultTopicPerm", "PUB", "topicPerms", 
List.of("order-*=PUB")))))
+                                "defaultTopicPerm", "PUB",
+                                "whiteRemoteAddress", "10.0.1.*",
+                                "topicPerms", List.of("order-*=PUB")))))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(200))
                 .andExpect(jsonPath("$.data.accessKey").value("svc-x"))
                 .andExpect(jsonPath("$.data.admin").value(false))
+                
.andExpect(jsonPath("$.data.whiteRemoteAddress").value("10.0.1.*"))
                 
.andExpect(jsonPath("$.data.topicPerms[0]").value("order-*=PUB"));
 
         ArgumentCaptor<PlainAccessConfigVO> captor = 
ArgumentCaptor.forClass(PlainAccessConfigVO.class);
@@ -471,6 +475,28 @@ class AclControllerTest {
         PlainAccessConfigVO request = captor.getValue();
         assertThat(request.getAccessKey()).isEqualTo("svc-x");
         assertThat(request.getDefaultTopicPerm()).isEqualTo("PUB");
+        assertThat(request.getWhiteRemoteAddress()).isEqualTo("10.0.1.*");
         assertThat(request.getTopicPerms()).containsExactly("order-*=PUB");
     }
+
+    @Test
+    void 
createUpdatePlainAccessConfigShouldReturnBadRequestForInvalidWhiteRemoteAddress()
 throws Exception {
+        
when(aclService.createAndUpdatePlainAccessConfig(any(PlainAccessConfigVO.class)))
+                .thenThrow(new BusinessException(400,
+                        "whiteRemoteAddress is not a valid plain ACL address 
expression: invalid"));
+
+        mockMvc.perform(post("/api/acl/plain-access-config")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(objectMapper.writeValueAsString(Map.of(
+                                "accessKey", "svc-x",
+                                "whiteRemoteAddress", "invalid"))))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.message")
+                        .value("whiteRemoteAddress is not a valid plain ACL 
address expression: invalid"));
+
+        ArgumentCaptor<PlainAccessConfigVO> captor = 
ArgumentCaptor.forClass(PlainAccessConfigVO.class);
+        verify(aclService).createAndUpdatePlainAccessConfig(captor.capture());
+        
assertThat(captor.getValue().getWhiteRemoteAddress()).isEqualTo("invalid");
+    }
 }
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 c07d38e5d..0142d97ff 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
@@ -30,6 +30,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.ArgumentCaptor;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
@@ -624,6 +625,69 @@ class AclServiceTest {
         verify(aclRepository, never()).createAndUpdatePlainAccessConfig(any());
     }
 
+
+    @ParameterizedTest
+    @ValueSource(strings = {"999.999.999.999", "10.0.0.0/8", 
"192.168.100-1.*", "192.168.1.{}"})
+    void 
createAndUpdatePlainAccessConfigShouldRejectInvalidWhiteRemoteAddress(String 
whiteRemoteAddress) {
+        PlainAccessConfigVO config = PlainAccessConfigVO.builder()
+                .accessKey("ak-1")
+                .whiteRemoteAddress(whiteRemoteAddress)
+                .build();
+
+        assertThatThrownBy(() -> 
aclService.createAndUpdatePlainAccessConfig(config))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("whiteRemoteAddress is not a valid plain 
ACL address expression")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
+        verify(aclRepository, never()).createAndUpdatePlainAccessConfig(any());
+        verify(operationAuditService, never()).record(any(), any(), any(), 
any(), any(), any(), any());
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"*", "192.168.*.*", "192.168.1-100.*", 
"192.168.1.{1,2,3}",
+        "192.168.1.10,192.168.1.11", "2001:db8::1"})
+    void 
createAndUpdatePlainAccessConfigShouldAcceptValidWhiteRemoteAddress(String 
whiteRemoteAddress) {
+        PlainAccessConfigVO config = PlainAccessConfigVO.builder()
+                .accessKey("ak-1")
+                .whiteRemoteAddress(whiteRemoteAddress)
+                .build();
+        
when(aclRepository.createAndUpdatePlainAccessConfig(config)).thenReturn(config);
+
+        PlainAccessConfigVO result = 
aclService.createAndUpdatePlainAccessConfig(config);
+
+        assertThat(result).isSameAs(config);
+        verify(aclRepository).createAndUpdatePlainAccessConfig(config);
+    }
+
+    @Test
+    void 
createAndUpdatePlainAccessConfigShouldNormalizeWhiteRemoteAddressBeforePersistence()
 {
+        PlainAccessConfigVO config = PlainAccessConfigVO.builder()
+                .accessKey("ak-1")
+                .whiteRemoteAddress(" 192.168.1.10 ")
+                .build();
+        
when(aclRepository.createAndUpdatePlainAccessConfig(config)).thenReturn(config);
+
+        PlainAccessConfigVO result = 
aclService.createAndUpdatePlainAccessConfig(config);
+
+        assertThat(result.getWhiteRemoteAddress()).isEqualTo("192.168.1.10");
+        verify(aclRepository).createAndUpdatePlainAccessConfig(argThat(saved ->
+                "192.168.1.10".equals(saved.getWhiteRemoteAddress())));
+    }
+
+    @Test
+    void 
createAndUpdatePlainAccessConfigShouldNormalizeBlankWhiteRemoteAddressToNull() {
+        PlainAccessConfigVO config = PlainAccessConfigVO.builder()
+                .accessKey("ak-1")
+                .whiteRemoteAddress("   ")
+                .build();
+        
when(aclRepository.createAndUpdatePlainAccessConfig(config)).thenReturn(config);
+
+        PlainAccessConfigVO result = 
aclService.createAndUpdatePlainAccessConfig(config);
+
+        assertThat(result.getWhiteRemoteAddress()).isNull();
+        verify(aclRepository).createAndUpdatePlainAccessConfig(argThat(saved ->
+                saved.getWhiteRemoteAddress() == null));
+    }
+
     @Test
     void createAndUpdatePlainAccessConfigShouldDelegateToRepository() {
         PlainAccessConfigVO config = PlainAccessConfigVO.builder()
@@ -644,7 +708,7 @@ class AclServiceTest {
         PlainAccessConfigVO config = PlainAccessConfigVO.builder()
                 .accessKey("ak-sensitive")
                 .secretKey("secret-value")
-                .whiteRemoteAddress("10.0.0.0/8")
+                .whiteRemoteAddress("192.168.*.*")
                 .admin(true)
                 .build();
         
when(aclRepository.createAndUpdatePlainAccessConfig(config)).thenReturn(config);
@@ -654,7 +718,7 @@ class AclServiceTest {
         verify(operationAuditService).record(eq("UPSERT_PLAIN_ACCESS_CONFIG"), 
eq("ACL_USER"),
                 eq("ak-sensitive"), eq(null),
                 argThat(detail -> detail.equals("admin=true, 
whiteRemoteAddressConfigured=true")
-                        && !detail.contains("secret-value") && 
!detail.contains("10.0.0.0/8")),
+                        && !detail.contains("secret-value") && 
!detail.contains("192.168.*.*")),
                 eq("SUCCESS"), eq(null));
     }
 
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/acl/PlainAclRemoteAddressValidatorTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/PlainAclRemoteAddressValidatorTest.java
new file mode 100644
index 000000000..792c9851e
--- /dev/null
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/acl/PlainAclRemoteAddressValidatorTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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 org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class PlainAclRemoteAddressValidatorTest {
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+        "*",
+        "*.*.*.*",
+        "127.0.0.1",
+        "192.168.*.*",
+        "192.168.1-100.*",
+        "192.168.1.{1,2,3}",
+        "192.168.1.10,192.168.1.11",
+        "*:*:*:*:*:*:*:*",
+        "2001:db8::1",
+        "1050::0005:0600:300c:{1,2,3}",
+        "1050::0005:0600:300c:1-200",
+        "1050::0005:0600:300c:1-20:*"
+    })
+    void shouldAcceptRocketMqPlainAclExpressions(String expression) {
+        
assertThat(PlainAclRemoteAddressValidator.isValid(expression)).isTrue();
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+        "999.999.999.999",
+        "10.0.0.0/8",
+        "192.168.300.*",
+        "192.168.100-1.*",
+        "192.168.1.{}",
+        "192.168.1.{1,}",
+        "192.168.{1,2}.1",
+        "192.168.1.10,",
+        "192.168.1.10, 192.168.1.11",
+        "2001:db8::gggg",
+        "1050::0005:0600:300c:200-1",
+        "1050::0005:*:300c:1",
+        "not-an-address"
+    })
+    void shouldRejectExpressionsThePlainAclParserCannotUse(String expression) {
+        
assertThat(PlainAclRemoteAddressValidator.isValid(expression)).isFalse();
+    }
+}
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
index e2077d744..a59ee9927 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
@@ -35,6 +35,8 @@ import java.util.Locale;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
@@ -519,6 +521,64 @@ class AlertServiceTest {
         verify(alertRepository, never()).saveRule(any());
     }
 
+    @Test
+    void createRuleShouldRejectBlankName() {
+        AlertRuleVO input = AlertRuleVO.builder().name(" 
").metric("tps").build();
+
+        assertThatThrownBy(() -> alertService.createRule(input))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Alert rule name is required")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
+
+        verify(alertRepository, never()).saveRule(any());
+        verify(operationAuditService, never()).record(anyString(), 
anyString(), anyString(),
+                any(), any(), anyString(), any());
+    }
+
+    @Test
+    void updateRuleShouldRejectBlankNameBeforeRepositoryUpdate() {
+        AlertRuleVO update = AlertRuleVO.builder().id(1L).name(" 
").metric("tps").build();
+
+        assertThatThrownBy(() -> alertService.updateRule(update))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("Alert rule name is required")
+                .satisfies(ex -> assertThat(((BusinessException) 
ex).getCode()).isEqualTo(400));
+
+        verify(alertRepository, never()).replaceRule(any());
+        verify(operationAuditService, never()).record(anyString(), 
anyString(), anyString(),
+                any(), any(), anyString(), any());
+    }
+
+    @Test
+    void createRuleShouldNormalizeNameBeforeSavingAndAuditing() {
+        AlertRuleVO input = AlertRuleVO.builder().name(" CPU Alert 
").metric("tps").build();
+        
when(alertRepository.saveRule(any(AlertRuleVO.class))).thenAnswer(invocation -> 
invocation.getArgument(0));
+
+        AlertRuleVO result = alertService.createRule(input);
+
+        assertThat(result.getName()).isEqualTo("CPU Alert");
+        verify(alertRepository).saveRule(argThat(rule -> "CPU 
Alert".equals(rule.getName())));
+        verify(operationAuditService).record(eq("CREATE_ALERT_RULE"), 
eq("ALERT_RULE"), anyString(),
+                eq(null), eq("name=CPU Alert"), eq("SUCCESS"), eq(null));
+    }
+
+    @Test
+    void updateRuleShouldNormalizeNameBeforeReplacingAndAuditing() {
+        AlertRuleVO update = AlertRuleVO.builder()
+                .id(1L)
+                .name(" CPU Alert ")
+                .threshold(90.0)
+                .build();
+        
when(alertRepository.replaceRule(any(AlertRuleVO.class))).thenReturn(true);
+
+        AlertRuleVO result = alertService.updateRule(update);
+
+        assertThat(result.getName()).isEqualTo("CPU Alert");
+        verify(alertRepository).replaceRule(argThat(rule -> "CPU 
Alert".equals(rule.getName())));
+        verify(operationAuditService).record(eq("UPDATE_ALERT_RULE"), 
eq("ALERT_RULE"), eq("1"),
+                eq(null), eq("name=CPU Alert"), eq("SUCCESS"), eq(null));
+    }
+
     @Test
     void updateRuleShouldUpdateExistingRule() {
         AlertRuleVO update = AlertRuleVO.builder().id(1L).name("CPU 
Alert").threshold(90.0).build();
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
index b7e9f37e4..7609fa665 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/provider/apache/RocketMQDLQProviderTest.java
@@ -66,6 +66,7 @@ import static org.mockito.Mockito.mockConstruction;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
@@ -124,6 +125,72 @@ class RocketMQDLQProviderTest {
         });
     }
 
+
+    @Test
+    void resendMessagesShouldRejectInvertedTimeRangeBeforeCreatingConsumers() {
+        assertThatThrownBy(() -> provider.resendMessages("instance-a", 
"group-a", 200L, 100L, "target-topic"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("DLQ resend start time must be before end time")
+                .satisfies(error -> assertThat(((BusinessException) 
error).getCode()).isEqualTo(400));
+
+        verifyNoInteractions(runtimeAdminClientResolver);
+        verify(auditService, never()).record(anyString(), anyString(), 
anyString(), anyString());
+    }
+
+    @Test
+    void 
resendMessagesShouldRejectEqualStartAndEndTimeBeforeCreatingConsumers() {
+        assertThatThrownBy(() -> provider.resendMessages("instance-a", 
"group-a", 100L, 100L, "target-topic"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("DLQ resend start time must be before end time")
+                .satisfies(error -> assertThat(((BusinessException) 
error).getCode()).isEqualTo(400));
+
+        verifyNoInteractions(runtimeAdminClientResolver);
+        verify(auditService, never()).record(anyString(), anyString(), 
anyString(), anyString());
+    }
+
+    @Test
+    void resendMessagesShouldRejectBlankGroupNameBeforeResolvingEndpoint() {
+        assertThatThrownBy(() -> provider.resendMessages("instance-a", " ", 
100L, 200L, "target-topic"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("groupName is required for DLQ resend")
+                .satisfies(error -> assertThat(((BusinessException) 
error).getCode()).isEqualTo(400));
+
+        verify(runtimeAdminClientResolver, 
never()).resolveEndpoint(anyString());
+        verify(auditService, never()).record(anyString(), anyString(), 
anyString(), anyString());
+    }
+
+    @Test
+    void resendMessagesShouldRejectNullGroupNameBeforeResolvingEndpoint() {
+        assertThatThrownBy(() -> provider.resendMessages("instance-a", null, 
100L, 200L, "target-topic"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("groupName is required for DLQ resend")
+                .satisfies(error -> assertThat(((BusinessException) 
error).getCode()).isEqualTo(400));
+
+        verify(runtimeAdminClientResolver, 
never()).resolveEndpoint(anyString());
+        verify(auditService, never()).record(anyString(), anyString(), 
anyString(), anyString());
+    }
+
+    @Test
+    void 
resendMessagesShouldNormalizeGroupNameBeforeBuildingDlqTopicAndAuditing() 
throws Exception {
+        String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
+        try (MockedConstruction<DefaultMQPullConsumer> mockedConsumers =
+                     mockConstruction(DefaultMQPullConsumer.class, (consumer, 
context) -> {
+                         doNothing().when(consumer).start();
+                         
when(consumer.fetchSubscribeMessageQueues(dlqTopic)).thenReturn(null);
+                         doNothing().when(consumer).shutdown();
+                     });
+             MockedConstruction<DefaultMQProducer> mockedProducers =
+                     mockConstruction(DefaultMQProducer.class)) {
+            provider.resendMessages("instance-a", " group-a ", 100L, 200L, 
"target-topic");
+
+            assertThat(mockedConsumers.constructed()).hasSize(1);
+            
verify(mockedConsumers.constructed().get(0)).fetchSubscribeMessageQueues(dlqTopic);
+            assertThat(mockedProducers.constructed()).isEmpty();
+        }
+        verify(auditService).record(eq("RESEND_DLQ"), eq("group-a"),
+                contains("group=group-a, dlqTopic=%DLQ%group-a"), 
eq("NO_MESSAGES"));
+    }
+
     @Test
     void resendMessagesDoesNotPullWhenDlqQueueSetIsNull() throws Exception {
         String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";

Reply via email to