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