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 0ffdc4a1a fix(provider): validate before Tencent ACL and DLQ writes
(#6173)
0ffdc4a1a is described below
commit 0ffdc4a1afcdc7548ad4d607674422157494d6af
Author: Xiao Yang <[email protected]>
AuthorDate: Mon Oct 12 16:15:59 2026 +0800
fix(provider): validate before Tencent ACL and DLQ writes (#6173)
Two provider paths that issued a remote write before validating the input
that decides what the write does.
- #6173 `TencentAclService.createRule` derives `permRead` / `permWrite`
from `hasPermissionAction(rule, "SUB")` and `(rule, "PUB")` at `:248-249`,
whose underlying `hasAction` (`:292-295`) is an `anyMatch(equalsIgnoreCase)`
that silently ignores an unknown action, and then unconditionally calls
`client.ModifyRole(request)` at `:255-256` — so `["PBU"]`, a typo for `PUB`,
switched both permissions off on the cloud role and still returned 200, and
`["PUB","PBU"]` quietly dropped the SUB [...]
- #6251 both `resendMessages` overloads run `validateResendTargetTopic`
only when the caller passed a `targetTopic` (`RocketMQDLQProvider.java:202-203`
for the time-range entry, `:274-275` for the selected-messages one), but when
it is absent `resolveTargetTopic` (`:766-782`) infers the destination from
`PROPERTY_DLQ_ORIGIN_TOPIC` or `PROPERTY_RETRY_TOPIC` and `resendOne` hands
that name to a producer routing by topic across the whole endpoint — so a
sibling cluster sharing the NameSe [...]
Tests: `TencentAclServiceTest` adds a `@ParameterizedTest` over seven
invalid action lists — null, empty, `["PBU"]`, `["PUB","PBU"]`, `[""]`, `[" SUB
"]`, `["SUB", null]` — each asserting the 400 together with
`verifyNoInteractions(clientFactory, client)` so no cloud call can slip
through, plus a case feeding `["pub","sUb","pub"]` through `updateRule` to pin
that case-insensitive matching and de-duplication still work;
`RocketMQDLQProviderTest` adds two `@ParameterizedTest` methods ov [...]
Folded: #6251 was closed as folded into this commit (same author).
---
docs/api-spec.md | 6 ++
.../provider/apache/RocketMQDLQProvider.java | 9 ++
.../studio/provider/tencent/TencentAclService.java | 10 ++
.../provider/apache/RocketMQDLQProviderTest.java | 115 +++++++++++++++++++++
.../provider/tencent/TencentAclServiceTest.java | 44 ++++++++
5 files changed, 184 insertions(+)
diff --git a/docs/api-spec.md b/docs/api-spec.md
index e5b0c7fd2..b7a8842e8 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -1754,6 +1754,12 @@ POST /api/dlq/resend
Topic,未指定 `targetTopic` 时即原 Topic)、`reason`(`string`,归一化后的简短失败原因,最长 256 字符)。
§9.4「重发选中的死信消息」返回同一结构,两条重投路径共用该 VO。
+> **跨集群拒绝(`409`)**:显式传入 `targetTopic` 时,若它解析出的集群不是死信 Topic(`%DLQ%{groupName}`)
+> 所属集群的子集,返回 `409 targetTopic resolves to a different cluster than
%DLQ%{groupName}; resend rejected: {targetTopic}`
+> (`RocketMQDLQProvider.requireSameClusterAsDlqTopic`)。**省略 `targetTopic`
时同样校验**:服务端先按每条死信
+> 推断目的地(即其原 Topic),对推断出的每一个目的地跑同一次校验,任一跨集群即整批 `409` 拒绝,
+> 不会出现「重投了一半」。§9.4 的按 `msgIds` 重投走同一套校验。
+
### 9.3 分页获取死信消息明细
```
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 2e8159c78..efc9bf91b 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
@@ -582,6 +582,15 @@ public class RocketMQDLQProvider implements DLQProvider {
if (deadLetters.isEmpty()) {
return batch;
}
+ if (!StringUtils.hasText(targetTopic)) {
+ Set<String> destinations = deadLetters.stream()
+ .map(deadLetter -> resolveTargetTopic(deadLetter,
targetTopic))
+ .filter(StringUtils::hasText)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ for (String destination : destinations) {
+ requireSameClusterAsDlqTopic(instanceId,
MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName, destination);
+ }
+ }
try {
runtimeAdminClientResolver.executeProducer(instanceId, producer ->
{
for (MessageExt deadLetter : deadLetters) {
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentAclService.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentAclService.java
index ba1e048ff..cf2f5b896 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentAclService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/tencent/TencentAclService.java
@@ -310,6 +310,16 @@ public class TencentAclService {
throw new BusinessException(400,
"Tencent Cloud roles only support ALLOW ACL rules");
}
+ if (rule.getActions() == null || rule.getActions().isEmpty()) {
+ throw new BusinessException(400, "Tencent Cloud ACL rules require
at least one action");
+ }
+ for (String action : rule.getActions()) {
+ if (!"PUB".equalsIgnoreCase(action) &&
!"SUB".equalsIgnoreCase(action)
+ && !"ALL".equalsIgnoreCase(action)) {
+ throw new BusinessException(400,
+ "Tencent Cloud ACL rules only support PUB, SUB or ALL
actions");
+ }
+ }
}
private static boolean isOptionalValue(String actual, String expected) {
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 27ba07a9f..4374fe9b9 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
@@ -54,6 +54,8 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -352,6 +354,119 @@ class RocketMQDLQProviderTest {
verify(runtimeAdminClientResolver,
never()).executeProducer(anyString(), any());
}
+ @ParameterizedTest
+ @ValueSource(strings = {MessageConst.PROPERTY_DLQ_ORIGIN_TOPIC,
MessageConst.PROPERTY_RETRY_TOPIC})
+ void
rangeResendShouldRejectInferredForeignTargetsBeforeSendingAnyMessageTest(String
originProperty)
+ throws Exception {
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
+ MessageExt local = deadLetterWithOrigin("local-msg", dlqTopic,
originProperty, "local-topic");
+ MessageExt foreign = deadLetterWithOrigin("foreign-msg", dlqTopic,
originProperty, "foreign-topic");
+ MessageQueue queue = new MessageQueue(dlqTopic, "broker-a", 0);
+
when(pullConsumer.fetchSubscribeMessageQueues(dlqTopic)).thenReturn(Set.of(queue));
+ when(pullConsumer.searchOffset(eq(queue), anyLong())).thenReturn(0L,
2L);
+ when(pullConsumer.pull(eq(queue), eq("*"), anyLong(), anyInt()))
+ .thenReturn(new PullResult(PullStatus.FOUND, 2L, 0L, 2L,
List.of(local, foreign)));
+
when(adminExt.examineTopicRouteInfo(dlqTopic)).thenReturn(routeInCluster("cluster-a"));
+
when(adminExt.examineTopicRouteInfo("local-topic")).thenReturn(routeInCluster("cluster-a"));
+
when(adminExt.examineTopicRouteInfo("foreign-topic")).thenReturn(routeInCluster("cluster-b"));
+
+ assertThatThrownBy(() -> provider.resendMessages("instance-a",
"group-a", 100L, 200L, null))
+ .isInstanceOfSatisfying(BusinessException.class, exception -> {
+ assertThat(exception.getCode()).isEqualTo(409);
+
assertThat(exception.getMessage()).contains("foreign-topic", "different
cluster");
+ });
+ verify(runtimeAdminClientResolver,
never()).executeProducer(anyString(), any());
+ verifyNoInteractions(dlqProducer);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {MessageConst.PROPERTY_DLQ_ORIGIN_TOPIC,
MessageConst.PROPERTY_RETRY_TOPIC})
+ void
selectedResendShouldRejectInferredForeignTargetsBeforeSendingAnyMessageTest(String
originProperty)
+ throws Exception {
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
+ when(adminExt.viewMessage(dlqTopic, "local-msg"))
+ .thenReturn(deadLetterWithOrigin("local-msg", dlqTopic,
originProperty, "local-topic"));
+ when(adminExt.viewMessage(dlqTopic, "foreign-msg"))
+ .thenReturn(deadLetterWithOrigin("foreign-msg", dlqTopic,
originProperty, "foreign-topic"));
+
when(adminExt.examineTopicRouteInfo(dlqTopic)).thenReturn(routeInCluster("cluster-a"));
+
when(adminExt.examineTopicRouteInfo("local-topic")).thenReturn(routeInCluster("cluster-a"));
+
when(adminExt.examineTopicRouteInfo("foreign-topic")).thenReturn(routeInCluster("cluster-b"));
+
+ assertThatThrownBy(() -> provider.resendMessages(
+ "instance-a", "group-a", List.of("local-msg", "foreign-msg"),
null))
+ .isInstanceOfSatisfying(BusinessException.class, exception -> {
+ assertThat(exception.getCode()).isEqualTo(409);
+
assertThat(exception.getMessage()).contains("foreign-topic", "different
cluster");
+ });
+ verify(runtimeAdminClientResolver,
never()).executeProducer(anyString(), any());
+ verifyNoInteractions(dlqProducer);
+ }
+
+ @Test
+ void
originalResendShouldAllowSameClusterTargetsAndPreserveMissingOriginFailuresTest()
throws Exception {
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
+ when(adminExt.viewMessage(dlqTopic,
"origin-msg")).thenReturn(deadLetterWithOrigin(
+ "origin-msg", dlqTopic,
MessageConst.PROPERTY_DLQ_ORIGIN_TOPIC, "orders"));
+ when(adminExt.viewMessage(dlqTopic,
"retry-msg")).thenReturn(deadLetterWithOrigin(
+ "retry-msg", dlqTopic, MessageConst.PROPERTY_RETRY_TOPIC,
"orders"));
+ when(adminExt.viewMessage(dlqTopic,
"missing-origin-msg")).thenReturn(deadLetterWithOrigin(
+ "missing-origin-msg", dlqTopic,
MessageConst.PROPERTY_DLQ_ORIGIN_TOPIC, " "));
+
lenient().when(adminExt.examineTopicRouteInfo(dlqTopic)).thenReturn(routeInCluster("cluster-a"));
+
lenient().when(adminExt.examineTopicRouteInfo("orders")).thenReturn(routeInCluster("cluster-a"));
+ SendResult sendResult = new SendResult();
+ sendResult.setSendStatus(SendStatus.SEND_OK);
+ when(dlqProducer.send(any(Message.class))).thenReturn(sendResult);
+
+ DLQResendResultVO result = provider.resendMessages("instance-a",
"group-a",
+ List.of("origin-msg", "retry-msg", "missing-origin-msg"),
null);
+
+ assertThat(result.getMatched()).isEqualTo(3);
+ assertThat(result.getResent()).isEqualTo(2);
+ assertThat(result.getFailed()).isEqualTo(1);
+ assertThat(result.getOutcome()).isEqualTo("PARTIAL");
+ assertThat(result.getFailures()).singleElement().satisfies(failure -> {
+ assertThat(failure.getMsgId()).isEqualTo("missing-origin-msg");
+ assertThat(failure.getReason()).isEqualTo("No target topic could
be resolved");
+ });
+ ArgumentCaptor<Message> sent = ArgumentCaptor.forClass(Message.class);
+ verify(dlqProducer, times(2)).send(sent.capture());
+
assertThat(sent.getAllValues()).extracting(Message::getTopic).containsExactly("orders",
"orders");
+ verify(adminExt).examineTopicRouteInfo("orders");
+ }
+
+ @Test
+ void
originalResendShouldLeaveUnresolvableRoutesToTheExistingSendFailureTest()
throws Exception {
+ String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
+ when(adminExt.viewMessage(dlqTopic,
"origin-msg")).thenReturn(deadLetterWithOrigin(
+ "origin-msg", dlqTopic,
MessageConst.PROPERTY_DLQ_ORIGIN_TOPIC, "orders"));
+
lenient().when(adminExt.examineTopicRouteInfo(dlqTopic)).thenReturn(routeInCluster("cluster-a"));
+ lenient().when(adminExt.examineTopicRouteInfo("orders"))
+ .thenThrow(new MQClientException("No topic route info for
orders", null));
+ when(dlqProducer.send(any(Message.class)))
+ .thenThrow(new MQClientException("No topic route info for
orders", null));
+
+ DLQResendResultVO result = provider.resendMessages(
+ "instance-a", "group-a", List.of("origin-msg"), null);
+
+ assertThat(result.getResent()).isZero();
+ assertThat(result.getFailed()).isEqualTo(1);
+ assertThat(result.getOutcome()).isEqualTo("FAILED");
+ assertThat(result.getFailures()).singleElement().satisfies(failure ->
+ assertThat(failure.getReason()).contains("No topic route info
for orders"));
+ verify(dlqProducer).send(any(Message.class));
+ }
+
+ private static MessageExt deadLetterWithOrigin(String msgId, String
dlqTopic,
+ String originProperty,
String originTopic) {
+ MessageExt message = new MessageExt();
+ message.setMsgId(msgId);
+ message.setTopic(dlqTopic);
+ message.setStoreTimestamp(150L);
+ message.setBody(new byte[] {1, 2, 3});
+ MessageAccessor.putProperty(message, originProperty, originTopic);
+ return message;
+ }
+
private static TopicRouteData routeInCluster(String cluster) {
BrokerData broker = new BrokerData();
broker.setCluster(cluster);
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentAclServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentAclServiceTest.java
index dae359cd2..152242b4e 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentAclServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/provider/tencent/TencentAclServiceTest.java
@@ -29,13 +29,17 @@ import org.apache.rocketmq.studio.instance.acl.AclUserVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.IntStream;
+import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -46,6 +50,7 @@ import static org.mockito.Mockito.lenient;
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)
@@ -272,6 +277,45 @@ class TencentAclServiceTest {
assertThat(created.getActions()).containsExactly("PUB", "SUB");
}
+ @ParameterizedTest
+ @MethodSource("invalidRuleActions")
+ void
ruleMutationsShouldRejectInvalidActionsBeforeChangingRolePermissionsTest(List<String>
actions) {
+ AclRuleVO rule = AclRuleVO.builder()
+ .principal("reader-role")
+ .resource("*")
+ .actions(actions)
+ .build();
+
+ assertThatThrownBy(() -> service.createRule(INSTANCE_ID, rule))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(400));
+ assertThatThrownBy(() -> service.updateRule(INSTANCE_ID, rule))
+ .isInstanceOf(BusinessException.class)
+ .satisfies(error -> assertThat(((BusinessException)
error).getCode()).isEqualTo(400));
+
+ verifyNoInteractions(clientFactory, client);
+ }
+
+ static Stream<List<String>> invalidRuleActions() {
+ return Stream.of(null, List.of(), List.of("PBU"), List.of("PUB",
"PBU"),
+ List.of(""), List.of(" SUB "), Arrays.asList("SUB", null));
+ }
+
+ @Test
+ void updateRuleShouldPreserveCaseInsensitivePermissionActionsTest() throws
Exception {
+ AclRuleVO updated = service.updateRule(INSTANCE_ID, AclRuleVO.builder()
+ .principal("reader-role")
+ .resource("*")
+ .actions(List.of("pub", "sUb", "pub"))
+ .build());
+
+ ArgumentCaptor<ModifyRoleRequest> requestCaptor =
ArgumentCaptor.forClass(ModifyRoleRequest.class);
+ verify(client).ModifyRole(requestCaptor.capture());
+ assertThat(requestCaptor.getValue().getPermRead()).isTrue();
+ assertThat(requestCaptor.getValue().getPermWrite()).isTrue();
+ assertThat(updated.getActions()).containsExactly("PUB", "SUB");
+ }
+
@Test
void createRuleShouldRejectResourceScopedTencentRulesTest() {
AclRuleVO rule = AclRuleVO.builder()