You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@rocketmq.apache.org by li...@apache.org on 2026/09/04 04:04:42 UTC

(rocketmq-dashboard) branch rocketmq-studio updated: fix(dlq): validate the resend target topic before dispatching messages (#2835)

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: 烤化の初雪 <ya...@outlook.com>
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 <17...@users.noreply.github.com>
    Co-authored-by: unbridled-41 <17...@users.noreply.github.com>
---
 .../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);