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 96796aa54 fix(dlq): reject malformed Excel selections (#5417)
96796aa54 is described below

commit 96796aa54dd8a5841ed57e82ec16ba2f753aced5
Author: youngkermit8-coder <[email protected]>
AuthorDate: Fri Oct 9 11:38:11 2026 +0800

    fix(dlq): reject malformed Excel selections (#5417)
    
    `msgIds[]`, `msgIds.0` and a present-but-empty `msgIds` were all silently 
ignored by the DLQ Excel
    export, so a client that meant to export a selection received the whole 
window instead. These now
    return an explicit 400 before any broker call, and the api-spec entry says 
so.
    
    This tightens behaviour: a hand-written HTTP client sending `msgIds[]` used 
to get a full-window
    xlsx and now gets 400. The web UI never took that path - it sends a 
repeated `msgIds` parameter - so
    only non-UI callers are affected, which is the point of the change.
    
    Maintainer edit on top of the contribution: dropped two 
`when(dlqService.exportExcel(...))` stubs
    that could never be used, because the cases carrying them assert 
`verifyNoInteractions(dlqService)`.
---
 docs/api-spec.md                                   |  2 +-
 .../studio/instance/dlq/DLQController.java         | 11 ++-
 .../rocketmq/studio/instance/dlq/DLQService.java   |  3 +
 .../studio/instance/dlq/DLQControllerTest.java     | 83 ++++++++++++++++++++++
 .../studio/instance/dlq/DLQServiceTest.java        | 18 +++++
 5 files changed, 115 insertions(+), 2 deletions(-)

diff --git a/docs/api-spec.md b/docs/api-spec.md
index f0986a7a2..24ba69c4c 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -1816,7 +1816,7 @@ GET 
/api/dlq/export-excel?instanceId={instanceId}&groupName={groupName}&startTim
 
 **Response:** 不走统一 `Result` 外壳,直接返回 `.xlsx` 字节流,`Content-Type: 
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,`Content-Disposition:
 attachment; filename="dlq-{groupName}.xlsx"`。工作表名 `DLQ`,列为 `Message ID` / 
`Topic` / `Queue ID` / `Offset` / `Store Time` / `Reconsume Times` / `Keys` / 
`Body`,其中 `Store Time` 按服务端默认时区格式化为 `yyyy-MM-dd HH:mm:ss`。生成失败返回 `502 Failed to 
export DLQ messages as Excel`。
 
-`msgIds` 是重复同名参数(`msgIds=a&msgIds=b`),不是 `msgIds[]=a`:Spring 的 `@RequestParam 
List<String>` 不绑定方括号形式,写成方括号会静默退化为导出整个时间窗口。
+`msgIds` 使用重复同名参数(`msgIds=a&msgIds=b`)。省略 `msgIds` 
才会导出整个时间窗口;显式传入空值(`msgIds=`)会返回 `400`。方括号或点号索引形式(如 `msgIds[]=a`、`msgIds[0]=a` 或 
`msgIds.0=a`)也会返回 `400`,避免误将选中消息导出变成整个窗口导出。
 
 > `msgIds` 是在一次上限 `5000` 
 > 条的扫描之后再做过滤,因此选中的消息若不在该窗口内不会出现在导出结果里;`X-DLQ-Export-Limit` 恒为 `5000`,不随 
 > `msgIds` 条数变化。
 
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
index 63d368391..ec64d68f8 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQController.java
@@ -18,6 +18,7 @@ package org.apache.rocketmq.studio.instance.dlq;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
 import org.apache.rocketmq.studio.common.domain.PageResult;
 import org.apache.rocketmq.studio.common.domain.Result;
 import org.apache.rocketmq.studio.common.exception.BusinessException;
@@ -102,7 +103,15 @@ public class DLQController {
                                                  @RequestParam(required = 
false) Long endTime,
                                                  @Size(max = 
MAX_SELECTED_MESSAGES,
                                                          message = "At most 
100 msgIds are allowed per export")
-                                                 @RequestParam(required = 
false) List<String> msgIds) {
+                                                 @RequestParam(required = 
false) List<String> msgIds,
+                                                 HttpServletRequest request) {
+        if (request.getParameterMap().keySet().stream()
+                .anyMatch(name -> name.startsWith("msgIds[") || 
name.startsWith("msgIds."))) {
+            throw new BusinessException(400, "Use repeated msgIds parameters 
without indexes");
+        }
+        if (request.getParameterMap().containsKey("msgIds") && (msgIds == null 
|| msgIds.isEmpty())) {
+            throw new BusinessException(400, "msgIds must not be empty when 
provided");
+        }
         DLQExcelExportResultVO result = dlqService.exportExcel(instanceId, 
groupName, startTime, endTime, msgIds);
         return ResponseEntity.ok()
                 .header(HttpHeaders.CONTENT_DISPOSITION,
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
index 11babd853..7acd5c3e3 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/instance/dlq/DLQService.java
@@ -107,6 +107,9 @@ public class DLQService {
         requireApacheInstance(instanceId);
         String normalizedGroupName = requireGroupName(groupName);
         validateTimeRange(startTime, endTime);
+        if (msgIds != null && msgIds.isEmpty()) {
+            throw new BusinessException(400, "msgIds must not be empty when 
provided");
+        }
         if (msgIds != null && msgIds.size() > MAX_SELECTED_MESSAGES) {
             throw new BusinessException(400, "At most 100 msgIds are allowed 
per export");
         }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
index 7ed1d9b24..9a8f8c483 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQControllerTest.java
@@ -552,4 +552,87 @@ class DLQControllerTest extends WebMvcAuthTestSupport {
 
         verifyNoInteractions(dlqService);
     }
+
+    @Test
+    void exportDLQExcelShouldRejectBracketedSelectionTest() throws Exception {
+        mockMvc.perform(get("/api/dlq/export-excel")
+                        .param("instanceId", "instance-1")
+                        .param("groupName", "test-group")
+                        .param("msgIds[]", "msg-1"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400));
+
+        verifyNoInteractions(dlqService);
+    }
+
+    @Test
+    void exportDLQExcelShouldRejectIndexedSelectionTest() throws Exception {
+        mockMvc.perform(get("/api/dlq/export-excel")
+                        .param("instanceId", "instance-1")
+                        .param("groupName", "test-group")
+                        .param("msgIds[0]", "msg-1"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400));
+
+        verifyNoInteractions(dlqService);
+    }
+
+    @Test
+    void exportDLQExcelShouldRejectDottedSelectionTest() throws Exception {
+        mockMvc.perform(get("/api/dlq/export-excel")
+                        .param("instanceId", "instance-1")
+                        .param("groupName", "test-group")
+                        .param("msgIds.0", "msg-1"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400));
+
+        verifyNoInteractions(dlqService);
+    }
+
+    @Test
+    void exportDLQExcelShouldRejectMixedSelectionParametersTest() throws 
Exception {
+        mockMvc.perform(get("/api/dlq/export-excel")
+                        .param("instanceId", "instance-1")
+                        .param("groupName", "test-group")
+                        .param("msgIds", "msg-1")
+                        .param("msgIds[]", "msg-2"))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400));
+
+        verifyNoInteractions(dlqService);
+    }
+
+    @Test
+    void exportDLQExcelShouldRejectPresentButEmptySelectionTest() throws 
Exception {
+        mockMvc.perform(get("/api/dlq/export-excel")
+                        .param("instanceId", "instance-1")
+                        .param("groupName", "test-group")
+                        .param("msgIds", ""))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.code").value(400));
+
+        verifyNoInteractions(dlqService);
+    }
+
+    @Test
+    void exportDLQExcelShouldKeepRepeatedSelectionParametersTest() throws 
Exception {
+        when(dlqService.exportExcel(eq("instance-1"), eq("test-group"), 
isNull(), isNull(),
+                eq(List.of("msg-1", "msg-2"))))
+                .thenReturn(DLQExcelExportResultVO.builder()
+                        .data(new byte[] {1})
+                        .truncated(false)
+                        .failedQueueCount(0)
+                        .limit(5000)
+                        .build());
+
+        mockMvc.perform(get("/api/dlq/export-excel")
+                        .param("instanceId", "instance-1")
+                        .param("groupName", "test-group")
+                        .param("msgIds", "msg-1", "msg-2"))
+                .andExpect(status().isOk())
+                .andExpect(content().bytes(new byte[] {1}));
+
+        verify(dlqService).exportExcel("instance-1", "test-group", null, null,
+                List.of("msg-1", "msg-2"));
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQServiceTest.java
index 29964cfab..a08a31803 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/instance/dlq/DLQServiceTest.java
@@ -130,6 +130,24 @@ class DLQServiceTest {
         verifyNoInteractions(dlqProvider);
     }
 
+    @Test
+    void 
exportExcelShouldRejectEmptySelectionInsteadOfDelegatingWholeWindowTest() {
+        assertThatThrownBy(() -> dlqService.exportExcel(
+                "instance-1", "group-1", null, null, List.of()))
+                .isInstanceOf(BusinessException.class)
+                .hasMessage("msgIds must not be empty when provided")
+                .satisfies(error -> assertThat(((BusinessException) 
error).getCode()).isEqualTo(400));
+
+        verifyNoInteractions(dlqProvider);
+    }
+
+    @Test
+    void exportExcelShouldKeepAbsentSelectionAsWholeWindowTest() {
+        dlqService.exportExcel("instance-1", "group-1", null, null, null);
+
+        verify(dlqProvider).exportExcel("instance-1", "group-1", null, null, 
null);
+    }
+
     @Test
     void resendMessagesShouldAcceptNullTimeRange() {
         dlqService.resendMessages("instance-1", "group-1", null, null, 
"target-topic");

Reply via email to