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 ea0915623 fix(dlq): validate the resend target topic before 
dispatching messages (#2835)
ea0915623 is described below

commit ea0915623d235d4832bb0184614e38fdba926a50
Author: 烤化の初雪 <[email protected]>
AuthorDate: Fri Sep 4 12:04:36 2026 +0800

    fix(dlq): validate the resend target topic before dispatching messages 
(#2835)
    
    Signed-off-by: unbridled-41 
<[email protected]>
    Co-authored-by: unbridled-41 
<[email protected]>
---
 .../provider/apache/RocketMQDLQProvider.java       |  44 +++++++++
 .../provider/apache/RocketMQDLQProviderTest.java   | 110 +++++++++++++++++++++
 2 files changed, 154 insertions(+)

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 e6dd8f42a..acb5922ed 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
@@ -27,6 +27,7 @@ import org.apache.rocketmq.common.message.Message;
 import org.apache.rocketmq.common.message.MessageConst;
 import org.apache.rocketmq.common.message.MessageExt;
 import org.apache.rocketmq.common.message.MessageQueue;
+import org.apache.rocketmq.common.topic.TopicValidator;
 import org.apache.rocketmq.remoting.protocol.admin.TopicOffset;
 import org.apache.rocketmq.remoting.protocol.admin.TopicStatsTable;
 import org.apache.rocketmq.remoting.protocol.body.TopicList;
@@ -34,6 +35,7 @@ import 
org.apache.rocketmq.studio.cluster.broker.RuntimeAdminClientResolver;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
 import org.apache.rocketmq.studio.common.util.Pagination;
+import org.apache.rocketmq.studio.common.util.SystemTopicFilter;
 import org.apache.rocketmq.studio.instance.dlq.DLQExcelExportResultVO;
 import org.apache.rocketmq.studio.instance.dlq.DLQExportResultVO;
 import org.apache.rocketmq.studio.instance.dlq.DLQGroupVO;
@@ -178,6 +180,9 @@ public class RocketMQDLQProvider implements DLQProvider {
         if (begin >= end) {
             throw new BusinessException(400, "DLQ resend start time must be 
before end time");
         }
+        if (StringUtils.hasText(targetTopic)) {
+            validateResendTargetTopic(instanceId, targetTopic);
+        }
 
         String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
 
@@ -251,6 +256,9 @@ public class RocketMQDLQProvider implements DLQProvider {
         if (selected.isEmpty()) {
             throw new BusinessException(400, "At least one valid msgId is 
required for selected DLQ resend");
         }
+        if (StringUtils.hasText(targetTopic)) {
+            validateResendTargetTopic(instanceId, targetTopic);
+        }
 
         String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + groupName;
 
@@ -548,6 +556,42 @@ public class RocketMQDLQProvider implements DLQProvider {
         }
     }
 
+    /**
+     * An explicit resend target is a powerful override: without validation it 
can feed dead
+     * letters back into their own DLQ or retry topic, poison broker system 
topics, or silently
+     * create new topics on clusters with autoCreateTopicEnable. Restrict it 
to valid, existing,
+     * non-system topics on the selected instance.
+     */
+    private void validateResendTargetTopic(String instanceId, String 
targetTopic) {
+        TopicValidator.ValidateResult validity = 
TopicValidator.validateTopic(targetTopic);
+        if (!validity.isValid()) {
+            throw new BusinessException(400, "targetTopic is not a valid 
RocketMQ topic name: "
+                    + targetTopic);
+        }
+        if (SystemTopicFilter.isSystem(targetTopic)) {
+            throw new BusinessException(400,
+                    "targetTopic must not be a RocketMQ system, retry or DLQ 
topic: " + targetTopic);
+        }
+        boolean exists;
+        try {
+            exists = 
Boolean.TRUE.equals(runtimeAdminClientResolver.execute(instanceId, admin -> {
+                TopicList topics = admin.fetchAllTopicList();
+                return topics != null && topics.getTopicList() != null
+                        && topics.getTopicList().contains(targetTopic);
+            }));
+        } catch (BusinessException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new BusinessException(502, "Failed to verify targetTopic on 
the selected instance: "
+                    + e.getMessage());
+        }
+        if (!exists) {
+            throw new BusinessException(400,
+                    "targetTopic does not exist on the selected instance; 
create the topic before resending: "
+                            + targetTopic);
+        }
+    }
+
     private String resolveTargetTopic(MessageExt deadLetter, String 
targetTopic) {
         if (StringUtils.hasText(targetTopic)) {
             return targetTopic;
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 12dc1a026..a842d271f 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
@@ -236,10 +236,87 @@ class RocketMQDLQProviderTest {
         verify(auditService, never()).record(anyString(), anyString(), 
anyString(), anyString());
     }
 
+    @Test
+    void resendMessagesRejectsRetryAndDlqTopicsAsTarget() throws Exception {
+        assertThatThrownBy(() -> provider.resendMessages(
+                "instance-a", "group-a", 100L, 200L, "%DLQ%group-a"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("must not be a RocketMQ system, retry or 
DLQ topic")
+                .satisfies(error -> assertThat(((BusinessException) 
error).getCode()).isEqualTo(400));
+
+        verify(runtimeAdminClientResolver, 
never()).executeProducer(anyString(), any());
+        verify(adminExt, never()).fetchAllTopicList();
+    }
+
+    @Test
+    void resendMessagesRejectsSystemTopicsAsTarget() {
+        assertThatThrownBy(() -> provider.resendMessages(
+                "instance-a", "group-a", 100L, 200L, "RMQ_SYS_TRACE_TOPIC"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("must not be a RocketMQ system, retry or 
DLQ topic");
+
+        verify(runtimeAdminClientResolver, 
never()).executeProducer(anyString(), any());
+    }
+
+    @Test
+    void resendMessagesRejectsInvalidTargetTopicName() throws Exception {
+        assertThatThrownBy(() -> provider.resendMessages(
+                "instance-a", "group-a", 100L, 200L, "not a valid topic"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("not a valid RocketMQ topic name");
+
+        verify(runtimeAdminClientResolver, 
never()).executeProducer(anyString(), any());
+        verify(adminExt, never()).fetchAllTopicList();
+    }
+
+    @Test
+    void resendMessagesRejectsTargetTopicMissingFromInstance() throws 
Exception {
+        TopicList otherTopics = new TopicList();
+        otherTopics.setTopicList(Set.of("unrelated-topic"));
+        when(adminExt.fetchAllTopicList()).thenReturn(otherTopics);
+
+        assertThatThrownBy(() -> provider.resendMessages(
+                "instance-a", "group-a", 100L, 200L, "target-topic"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("targetTopic does not exist on the 
selected instance");
+
+        verify(runtimeAdminClientResolver, 
never()).executeProducer(anyString(), any());
+        verify(pullConsumer, never()).pull(any(MessageQueue.class), 
anyString(), anyLong(), anyInt());
+    }
+
+    @Test
+    void resendSelectedMessagesRejectsSystemTopicAsTarget() throws Exception {
+        assertThatThrownBy(() -> provider.resendMessages(
+                "instance-a", "group-a", List.of("msg-1"), "%DLQ%group-a"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("must not be a RocketMQ system, retry or 
DLQ topic");
+
+        verify(runtimeAdminClientResolver, 
never()).executeProducer(anyString(), any());
+        verify(adminExt, never()).fetchAllTopicList();
+        verify(pullConsumer, never()).pull(any(MessageQueue.class), 
anyString(), anyLong(), anyInt());
+    }
+
+    @Test
+    void resendMessagesFailsGracefullyWhenTopicListCannotBeRead() throws 
Exception {
+        when(adminExt.fetchAllTopicList()).thenThrow(new 
IllegalStateException("nameserver unreachable"));
+
+        assertThatThrownBy(() -> provider.resendMessages(
+                "instance-a", "group-a", 100L, 200L, "target-topic"))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("Failed to verify targetTopic on the 
selected instance")
+                .satisfies(error -> assertThat(((BusinessException) 
error).getCode()).isEqualTo(502));
+
+        verify(runtimeAdminClientResolver, 
never()).executeProducer(anyString(), any());
+        verify(pullConsumer, never()).pull(any(MessageQueue.class), 
anyString(), anyLong(), anyInt());
+    }
+
     @Test
     void 
resendMessagesShouldNormalizeGroupNameBeforeBuildingDlqTopicAndAuditing() 
throws Exception {
         String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
         
when(pullConsumer.fetchSubscribeMessageQueues(dlqTopic)).thenReturn(null);
+        TopicList existingTargets = new TopicList();
+        existingTargets.setTopicList(Set.of("target-topic"));
+        when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
         provider.resendMessages("instance-a", " group-a ", 100L, 200L, 
"target-topic");
 
         
verify(runtimeAdminClientResolver).executePullConsumer(eq("instance-a"), any());
@@ -253,6 +330,9 @@ class RocketMQDLQProviderTest {
     void resendMessagesDoesNotPullWhenDlqQueueSetIsNull() throws Exception {
         String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
         
when(pullConsumer.fetchSubscribeMessageQueues(dlqTopic)).thenReturn(null);
+        TopicList existingTargets = new TopicList();
+        existingTargets.setTopicList(Set.of("target-topic"));
+        when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
         provider.resendMessages("instance-a", "group-a", 100L, 200L, 
"target-topic");
 
         
verify(runtimeAdminClientResolver).executePullConsumer(eq("instance-a"), any());
@@ -285,6 +365,9 @@ class RocketMQDLQProviderTest {
         when(pullConsumer.searchOffset(queue, 200L)).thenReturn(0L);
         when(pullConsumer.pull(queue, "*", 0L, 32)).thenReturn(pullResult);
         when(dlqProducer.send(any(Message.class))).thenReturn(sendResult);
+        TopicList existingTargets = new TopicList();
+        existingTargets.setTopicList(Set.of("target-topic"));
+        when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
         provider.resendMessages("instance-a", "group-a", 100L, 200L, 
"target-topic");
 
         
verify(runtimeAdminClientResolver).executePullConsumer(eq("instance-a"), any());
@@ -296,6 +379,9 @@ class RocketMQDLQProviderTest {
         String dlqTopic = MixAll.DLQ_GROUP_TOPIC_PREFIX + "group-a";
         when(pullConsumer.fetchSubscribeMessageQueues(dlqTopic))
                 .thenThrow(new IllegalStateException("broker unavailable"));
+        TopicList existingTargets = new TopicList();
+        existingTargets.setTopicList(Set.of("target-topic"));
+        when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
         assertThatThrownBy(() -> provider.resendMessages("instance-a", 
"group-a", 100L, 200L, "target-topic"))
                 .isInstanceOf(BusinessException.class)
                 .hasMessageContaining("Failed to scan DLQ topic " + dlqTopic)
@@ -322,6 +408,9 @@ class RocketMQDLQProviderTest {
                 .thenThrow(new IllegalStateException("broker unavailable"));
         when(pullConsumer.searchOffset(eq(emptyQueue), 
anyLong())).thenReturn(0L);
         when(pullConsumer.pull(eq(emptyQueue), eq("*"), eq(0L), 
eq(32))).thenReturn(emptyResult);
+        TopicList existingTargets = new TopicList();
+        existingTargets.setTopicList(Set.of("target-topic"));
+        when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
         assertThat(provider.resendMessages("instance-a", "group-a", 100L, 
200L, "target-topic"))
                 .extracting("matched", "resent", "failed", "outcome", 
"scanIncomplete", "failedQueueCount")
                 .containsExactly(0, 0, 0, "PARTIAL", true, 1);
@@ -345,6 +434,9 @@ class RocketMQDLQProviderTest {
         
when(pullConsumer.fetchSubscribeMessageQueues(dlqTopic)).thenReturn(Set.of(queue));
         when(pullConsumer.searchOffset(eq(queue), anyLong())).thenReturn(10L);
         when(pullConsumer.pull(eq(queue), eq("*"), eq(10L), 
eq(32))).thenReturn(stalledResult);
+        TopicList existingTargets = new TopicList();
+        existingTargets.setTopicList(Set.of("target-topic"));
+        when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
         provider.resendMessages("instance-a", "group-a", 100L, 200L, 
"target-topic");
 
         verify(pullConsumer, times(1)).pull(queue, "*", 10L, 32);
@@ -373,6 +465,9 @@ class RocketMQDLQProviderTest {
         when(pullConsumer.pull(queue, "*", 20L, 
32)).thenReturn(foundAfterCorrection);
         when(pullConsumer.pull(queue, "*", 40L, 32)).thenReturn(endOfQueue);
         when(dlqProducer.send(any(Message.class))).thenReturn(sendResult);
+        TopicList existingTargets = new TopicList();
+        existingTargets.setTopicList(Set.of("orders"));
+        when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
         assertThat(provider.resendMessages("instance-a", "group-a", 100L, 
200L, "orders"))
                 .extracting("matched", "resent", "failed", "outcome")
                 .containsExactly(1, 1, 0, "SUCCESS");
@@ -409,6 +504,9 @@ class RocketMQDLQProviderTest {
         when(pullConsumer.searchOffset(queue, 200L)).thenReturn(0L);
         when(pullConsumer.pull(queue, "*", 0L, 32)).thenReturn(pullResult);
         when(dlqProducer.send(any(Message.class))).thenReturn(sendResult);
+        TopicList existingTargets = new TopicList();
+        existingTargets.setTopicList(Set.of("target-topic"));
+        when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
         assertThat(provider.resendMessages("instance-a", "group-a", 100L, 
200L, "target-topic"))
                 .extracting("matched", "resent", "failed", "outcome")
                 .containsExactly(1, 0, 1, "FAILED");
@@ -446,6 +544,9 @@ class RocketMQDLQProviderTest {
         when(pullConsumer.searchOffset(queue, 200L)).thenReturn(0L);
         when(pullConsumer.pull(queue, "*", 0L, 32)).thenReturn(pullResult);
         when(dlqProducer.send(any(Message.class))).thenReturn(sendResult);
+        TopicList existingTargets = new TopicList();
+        existingTargets.setTopicList(Set.of("target-topic"));
+        when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
         assertThat(provider.resendMessages("instance-a", "group-a", 100L, 
200L, "target-topic"))
                 .extracting("matched", "resent", "failed", "outcome")
                 .containsExactly(1, 1, 0, "SUCCESS");
@@ -474,6 +575,9 @@ class RocketMQDLQProviderTest {
 
         when(adminExt.viewMessage(dlqTopic, 
"old-msg")).thenReturn(oldDeadLetter);
         when(dlqProducer.send(any(Message.class))).thenReturn(sendResult);
+        TopicList existingTargets = new TopicList();
+        existingTargets.setTopicList(Set.of("target-topic"));
+        when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
 
         assertThat(provider.resendMessages(
                 "instance-a", "group-a", List.of("old-msg"), "target-topic"))
@@ -498,6 +602,9 @@ class RocketMQDLQProviderTest {
                 .thenThrow(new IllegalStateException("message not found"));
         when(adminExt.viewMessage(dlqTopic, "found-msg")).thenReturn(found);
         when(dlqProducer.send(any(Message.class))).thenReturn(sendResult);
+        TopicList existingTargets = new TopicList();
+        existingTargets.setTopicList(Set.of("target-topic"));
+        when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
 
         assertThat(provider.resendMessages(
                 "instance-a", "group-a", List.of("missing-msg", "found-msg"), 
"target-topic"))
@@ -531,6 +638,9 @@ class RocketMQDLQProviderTest {
         when(pullConsumer.searchOffset(queue, 200L)).thenReturn(5001L);
         when(pullConsumer.pull(queue, "*", 0L, 32)).thenReturn(pullResult);
         when(dlqProducer.send(any(Message.class))).thenReturn(sendResult);
+        TopicList existingTargets = new TopicList();
+        existingTargets.setTopicList(Set.of("target-topic"));
+        when(adminExt.fetchAllTopicList()).thenReturn(existingTargets);
         assertThat(provider.resendMessages("instance-a", "group-a", 100L, 
200L, "target-topic"))
                 .extracting("matched", "resent", "failed", "outcome", 
"scanIncomplete", "failedQueueCount")
                 .containsExactly(5000, 5000, 0, "PARTIAL", true, 0);

Reply via email to