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