Compare commits

...

3 Commits

Author SHA1 Message Date
Crazylychee
ce8306a602 [ISSUE #359] Fix fail test in LoginControllerTest and add .env (#363)
* commit

* commit

* commit

* commit
2025-08-30 19:25:05 +08:00
Crazylychee
8037cfcf05 [ISSUE #353] fix Actuator vulnerability issues (#354)
* [ISSUE #348] fix Some interaction issues with the consumer interface

* commit

* [ISSUE #353] fix Actuator vulnerability issues

* [ISSUE #353] fix Actuator vulnerability issues

* commit
2025-08-09 16:04:52 +08:00
RongtongJin
bd9f3e6b39 [maven-release-plugin] prepare for next development iteration 2025-08-01 16:48:17 +08:00
17 changed files with 236 additions and 66 deletions

View File

@@ -0,0 +1 @@
REACT_APP_API_BASE_URL=http://localhost:8082

View File

@@ -0,0 +1 @@
REACT_APP_API_BASE_URL=

View File

@@ -14,8 +14,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const appConfig = {
apiBaseUrl: 'http://localhost:8082'
apiBaseUrl: process.env.REACT_APP_API_BASE_URL || window.location.origin
};
let _redirectHandler = null;
@@ -33,29 +34,73 @@ const remoteApi = {
return `${appConfig.apiBaseUrl}/${endpoint}`;
},
_fetch: async (url, options) => {
try {
// 在 options 中添加 credentials: 'include'
const response = await fetch(url, {
...options, // 保留原有的 options
credentials: 'include' // 关键改动:允许发送 Cookie
async getCsrfToken() {
const csrfToken = this.getCookie();
if (csrfToken) {
return csrfToken;
}
const response = await fetch(remoteApi.buildUrl("/rocketmq-dashboard/csrf-token"), {
method: 'GET',
credentials: 'include'
});
const newCsrfToken = this.getCookie();
if (!newCsrfToken) {
console.error("Failed to get CSRF Token");
throw new Error("CSRF Token not available");
}
return newCsrfToken;
},
getCookie() {
return document.cookie.replace(/(?:(?:^|.*;\s*)XSRF-TOKEN\s*\=\s*([^;]*).*$)|^.*$/, '$1')
},
_fetch: async (url, options = {}) => {
const headers = {
...options.headers,
'Content-Type': 'application/json',
};
const csrfToken = await remoteApi.getCsrfToken();
console.log(csrfToken)
if (!csrfToken) {
console.warn('CSRF Token not found');
}else{
headers["X-XSRF-TOKEN"] = csrfToken;
}
console.log(csrfToken)
try {
const response = await fetch(url, {
...options,
headers,
credentials: 'include',
});
// 检查响应是否被重定向,并且最终的 URL 包含了登录页的路径。
// 这是会话过期或需要认证时后端重定向到登录页的常见模式。
// 注意fetch 会自动跟随 GET 请求的 3xx 重定向,所以我们检查的是 response.redirected。
if (response.redirected) {
if (_redirectHandler) {
_redirectHandler(); // 如果设置了重定向处理函数,则调用它
_redirectHandler();
}
return {__isRedirectHandled: true};
}
if(response.status == 403){
window.localStorage.removeItem("csrfToken");
console.log(111)
await remoteApi.getCsrfToken()
}
return response;
} catch (error) {
console.error("Fetch 请求出错:", error);
throw error;
console.error('fetch error:', error);
window.localStorage.removeItem("csrfToken");
console.log(111)
await remoteApi.getCsrfToken()
}
},
@@ -232,24 +277,19 @@ const remoteApi = {
throw new Error(`HTTP error! status: ${response.status}`);
}
// 假设服务器总是返回 JSON
const data = await response.json();
// 1. 打开一个新的空白窗口
const newWindow = window.open('', '_blank');
if (!newWindow) {
// 浏览器可能会阻止弹窗,需要用户允许
return {status: 1, errMsg: "Failed to open new window. Please allow pop-ups for this site."};
}
// 2. 将 JSON 数据格式化后写入新窗口
newWindow.document.write('<html><head><title>DLQ 导出内容</title></head><body>');
newWindow.document.write('<h1>DLQ 导出 JSON 内容</h1>');
// 使用 <pre> 标签保持格式,并使用 JSON.stringify 格式化 JSON 以便于阅读
newWindow.document.write('<pre>' + JSON.stringify(data, null, 2) + '</pre>');
newWindow.document.write('</body></html>');
newWindow.document.close(); // 关闭文档流,确保内容显示
newWindow.document.close();
return {status: 0, msg: "导出请求成功,内容已在新页面显示"};
} catch (error) {
@@ -382,6 +422,9 @@ const remoteApi = {
},
queryConsumerGroupList: async (skipSysGroup, address) => {
if (address === undefined) {
address = ""
}
try {
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/groupList.query?skipSysGroup=${skipSysGroup}&address=${address}`));
const data = await response.json();
@@ -404,9 +447,12 @@ const remoteApi = {
}
},
refreshAllConsumerGroup: async () => {
refreshAllConsumerGroup: async (address) => {
if (address === undefined) {
address = ""
}
try {
const response = await remoteApi._fetch(remoteApi.buildUrl("/consumer/group.refresh.all"));
const response = await remoteApi._fetch(remoteApi.buildUrl(`/consumer/group.refresh.all?address=${address}`));
const data = await response.json();
return data;
} catch (error) {
@@ -875,21 +921,17 @@ const remoteApi = {
login: async (username, password) => {
try {
// 2. 发送请求,注意 body 可以是空字符串或 null或者直接省略 body
// 这里使用 GET 方法,因为参数在 URL 上
const response = await remoteApi._fetch(remoteApi.buildUrl("/login/login.do"), {
method: 'POST',
body: JSON.stringify({
username: username,
password: password
}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded' // 这个 header 可能不再需要,或者需要调整
},
body: new URLSearchParams({
username: username, // 假设 username 是变量名
password: password // 假设 password 是变量名
}).toString()
'Content-Type': 'application/json'
}
});
// 3. 处理响应
const data = await response.json();
return data;
} catch (error) {
@@ -912,21 +954,18 @@ const remoteApi = {
};
const tools = {
// 适配新的数据结构
dashboardRefreshTime: 5000,
generateBrokerMap: (brokerServer, clusterAddrTable, brokerAddrTable) => {
const clusterMap = {}; // 最终存储 { clusterName: [brokerInstance1, brokerInstance2, ...] }
const clusterMap = {};
Object.entries(clusterAddrTable).forEach(([clusterName, brokerNamesInCluster]) => {
clusterMap[clusterName] = []; // 初始化当前集群的 broker 列表
clusterMap[clusterName] = [];
brokerNamesInCluster.forEach(brokerName => {
// 从 brokerAddrTable 获取当前 brokerName 下的所有 brokerId 及其地址
const brokerAddrs = brokerAddrTable[brokerName]?.brokerAddrs; // 确保 brokerAddrs 存在
const brokerAddrs = brokerAddrTable[brokerName]?.brokerAddrs;
if (brokerAddrs) {
Object.entries(brokerAddrs).forEach(([brokerIdStr, address]) => {
const brokerId = parseInt(brokerIdStr); // brokerId 是字符串,转为数字
// 从 brokerServer 获取当前 brokerName 和 brokerId 对应的详细信息
const brokerId = parseInt(brokerIdStr);
const detail = brokerServer[brokerName]?.[brokerIdStr];
if (detail) {

View File

@@ -38,6 +38,7 @@ const ClientInfoModal = ({visible, group, address, onCancel, messageApi}) => {
setConnectionData(connResponse.data);
}else{
messageApi.error(connResponse.errMsg);
setConnectionData(null);
}
} finally {
setLoading(false);

View File

@@ -270,7 +270,7 @@ const ConsumerGroupList = () => {
const handleRefreshConsumerData = async () => {
setLoading(true);
const refreshResult = await remoteApi.refreshAllConsumerGroup();
const refreshResult = await remoteApi.refreshAllConsumerGroup(selectedProxy);
setLoading(false);
if (refreshResult && refreshResult.status === 0) {

View File

@@ -20,7 +20,6 @@ import {defaultTheme, themes} from '../../assets/styles/theme';
import {setTheme} from '../actions/themeActions';
export const useTheme = () => {
// 从 Redux store 中取出 currentThemeName
const currentThemeName = useSelector(state => state.theme.currentThemeName);
const dispatch = useDispatch();

View File

@@ -28,7 +28,6 @@ const initialState = {
const themeReducer = (state = initialState, action) => {
switch (action.type) {
case SET_THEME:
// 注意reducer 应该返回新的状态对象,而不是直接修改旧状态
return {
...state,
currentThemeName: action.payload,

View File

@@ -28,14 +28,14 @@
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-dashboard</artifactId>
<packaging>jar</packaging>
<version>2.1.0</version>
<version>2.1.1-SNAPSHOT</version>
<name>rocketmq-dashboard</name>
<scm>
<url>git@github.com:apache/rocketmq-dashboard.git</url>
<connection>scm:git:git@github.com:apache/rocketmq-dashboard.git</connection>
<developerConnection>scm:git:git@github.com:apache/rocketmq-dashboard.git</developerConnection>
<tag>rocketmq-dashboard-2.1.0</tag>
<tag>1.0.0</tag>
</scm>
<mailingLists>
@@ -149,6 +149,11 @@
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>

View File

@@ -15,7 +15,7 @@
# limitations under the License.
#
FROM java:8
FROM eclipse-temurin:17.0.16_8-jre-ubi9-minimal
VOLUME /tmp
ADD rocketmq-dashboard-*.jar rocketmq-dashboard.jar
RUN sh -c 'touch /rocketmq-dashboard.jar'

View File

@@ -31,7 +31,6 @@ import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -90,17 +89,6 @@ public class AuthWebMVCConfigurerAdapter implements WebMvcConfigurer {
});
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("http://localhost:3003")
.allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS")
.maxAge(3600)
.allowCredentials(true)
.allowedHeaders("content-type", "Authorization", "X-Requested-With", "Origin", "Accept")
.exposedHeaders("authorization");
}
@Override

View File

@@ -0,0 +1,74 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.rocketmq.dashboard.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(withDefaults())
.csrf(csrf -> csrf
.ignoringRequestMatchers("/actuator/**")
.ignoringRequestMatchers("/rocketmq-dashboard/csrf-token")
.csrfTokenRepository(csrfTokenRepository())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/**").hasRole("ADMIN")
.anyRequest().permitAll()
)
.httpBasic(withDefaults());
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3003"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("content-type", "Authorization", "X-Requested-With", "Origin", "Accept", "X-XSRF-TOKEN"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public CsrfTokenRepository csrfTokenRepository() {
return CookieCsrfTokenRepository.withHttpOnlyFalse();
}
}

View File

@@ -0,0 +1,45 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.rocketmq.dashboard.controller;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(value = "/rocketmq-dashboard")
public class CsrfTokenController {
@Autowired
private CsrfTokenRepository csrfTokenRepository;
@RequestMapping(value = "/csrf-token", method = RequestMethod.GET)
@ResponseBody
public Object getCsrfToken(HttpServletRequest request, HttpServletResponse response) {
CsrfToken token = csrfTokenRepository.generateToken(request);
csrfTokenRepository.saveToken(token, request, response);
return token;
}
}

View File

@@ -33,6 +33,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
@@ -65,7 +66,7 @@ public class LoginController {
@RequestMapping(value = "/login.do", method = RequestMethod.POST)
@ResponseBody
public Object login(org.apache.rocketmq.remoting.protocol.body.UserInfo userInfoRequest,
public Object login(@RequestBody org.apache.rocketmq.remoting.protocol.body.UserInfo userInfoRequest,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
logger.info("user:{} login", userInfoRequest.getUsername());

View File

@@ -33,6 +33,12 @@ public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
if (request.getRequestURL().toString().contains("/rocketmq-dashboard/csrf-token")) {
return true;
}
return loginService.login(request, response);
}

View File

@@ -135,6 +135,7 @@ public class ConsumerServiceImpl extends AbstractCommonService implements Consum
SYSTEM_GROUP_SET.add(MixAll.CID_ONSAPI_PERMISSION_GROUP);
SYSTEM_GROUP_SET.add(MixAll.CID_ONSAPI_OWNER_GROUP);
SYSTEM_GROUP_SET.add(MixAll.CID_SYS_RMQ_TRANS);
SYSTEM_GROUP_SET.add("CID_DefaultHeartBeatSyncerTopic");
}
@Override
@@ -147,7 +148,7 @@ public class ConsumerServiceImpl extends AbstractCommonService implements Consum
if (cacheConsumeInfoList.isEmpty() && !isCacheBeingBuilt) {
isCacheBeingBuilt = true;
try {
makeGroupListCache();
makeGroupListCache(address);
} finally {
isCacheBeingBuilt = false;
}
@@ -173,7 +174,7 @@ public class ConsumerServiceImpl extends AbstractCommonService implements Consum
}
public void makeGroupListCache() {
public void makeGroupListCache(String address) {
SubscriptionGroupWrapper subscriptionGroupWrapper = null;
try {
ClusterInfo clusterInfo = clusterInfoService.get();
@@ -205,7 +206,7 @@ public class ConsumerServiceImpl extends AbstractCommonService implements Consum
String consumerGroup = entry.getKey();
executorService.submit(() -> {
try {
GroupConsumeInfo consumeInfo = queryGroup(consumerGroup, "");
GroupConsumeInfo consumeInfo = queryGroup(consumerGroup, address);
consumeInfo.setAddress(entry.getValue());
if (SYSTEM_GROUP_SET.contains(consumerGroup)) {
consumeInfo.setSubGroupType("SYSTEM");

View File

@@ -33,6 +33,18 @@ spring:
application:
name: rocketmq-dashboard
security:
user:
name: rocketmq
password: 1234567
roles: ADMIN
management:
endpoints:
web:
exposure:
include: "*"
logging:
config: classpath:logback.xml

View File

@@ -28,6 +28,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.util.ReflectionUtils;
@@ -87,15 +88,14 @@ public class LoginControllerTest extends BaseControllerTest {
final String rightPwd = "admin";
final String wrongPwd = "rocketmq";
// 模拟 userService.queryByName 方法返回一个用户
User user = new User("admin", "admin", 1);
user.setPassword(rightPwd);
// 1、login fail
perform = mockMvc.perform(post(url)
.param("username", username)
.param("password", wrongPwd));
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"" + username + "\",\"password\":\"" + wrongPwd + "\"}"));
perform.andExpect(status().isOk())
.andExpect(jsonPath("$.data").doesNotExist())
.andExpect(jsonPath("$.status").value(-1))
@@ -105,10 +105,8 @@ public class LoginControllerTest extends BaseControllerTest {
// 2、login success
perform = mockMvc.perform(post(url)
.param("username", username)
.param("password", rightPwd));
perform.andExpect(status().isOk())
.andExpect(jsonPath("$.contextPath").value(contextPath));
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"" + username + "\",\"password\":\"" + rightPwd + "\"}"));
}