From 8037cfcf052d70c4182359154e0c1c90cb4ef07d Mon Sep 17 00:00:00 2001 From: Crazylychee <110229037+Crazylychee@users.noreply.github.com> Date: Sat, 9 Aug 2025 16:04:52 +0800 Subject: [PATCH] [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 --- frontend-new/src/api/remoteApi/remoteApi.js | 100 +++++++++++++----- .../components/consumer/ClientInfoModal.jsx | 1 + frontend-new/src/pages/Consumer/consumer.jsx | 2 +- .../src/store/context/ThemeContext.js | 1 - .../src/store/reducers/themeReducer.js | 1 - pom.xml | 5 + .../config/AuthWebMVCConfigurerAdapter.java | 12 --- .../dashboard/config/SecurityConfig.java | 74 +++++++++++++ .../controller/CsrfTokenController.java | 45 ++++++++ .../dashboard/controller/LoginController.java | 3 +- .../interceptor/AuthInterceptor.java | 6 ++ .../service/impl/ConsumerServiceImpl.java | 7 +- src/main/resources/application.yml | 12 +++ 13 files changed, 221 insertions(+), 48 deletions(-) create mode 100644 src/main/java/org/apache/rocketmq/dashboard/config/SecurityConfig.java create mode 100644 src/main/java/org/apache/rocketmq/dashboard/controller/CsrfTokenController.java diff --git a/frontend-new/src/api/remoteApi/remoteApi.js b/frontend-new/src/api/remoteApi/remoteApi.js index 329628d..c727f55 100644 --- a/frontend-new/src/api/remoteApi/remoteApi.js +++ b/frontend-new/src/api/remoteApi/remoteApi.js @@ -14,6 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + const appConfig = { apiBaseUrl: 'http://localhost:8082' }; @@ -33,29 +34,73 @@ const remoteApi = { return `${appConfig.apiBaseUrl}/${endpoint}`; }, - _fetch: async (url, options) => { + + 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 { - // 在 options 中添加 credentials: 'include' const response = await fetch(url, { - ...options, // 保留原有的 options - credentials: 'include' // 关键改动:允许发送 Cookie + ...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('DLQ 导出内容'); newWindow.document.write('

DLQ 导出 JSON 内容

'); - // 使用
 标签保持格式,并使用 JSON.stringify 格式化 JSON 以便于阅读
             newWindow.document.write('
' + JSON.stringify(data, null, 2) + '
'); newWindow.document.write(''); - 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) { diff --git a/frontend-new/src/components/consumer/ClientInfoModal.jsx b/frontend-new/src/components/consumer/ClientInfoModal.jsx index 21d21a5..3974a51 100644 --- a/frontend-new/src/components/consumer/ClientInfoModal.jsx +++ b/frontend-new/src/components/consumer/ClientInfoModal.jsx @@ -38,6 +38,7 @@ const ClientInfoModal = ({visible, group, address, onCancel, messageApi}) => { setConnectionData(connResponse.data); }else{ messageApi.error(connResponse.errMsg); + setConnectionData(null); } } finally { setLoading(false); diff --git a/frontend-new/src/pages/Consumer/consumer.jsx b/frontend-new/src/pages/Consumer/consumer.jsx index 3377e7a..e2c1be2 100644 --- a/frontend-new/src/pages/Consumer/consumer.jsx +++ b/frontend-new/src/pages/Consumer/consumer.jsx @@ -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) { diff --git a/frontend-new/src/store/context/ThemeContext.js b/frontend-new/src/store/context/ThemeContext.js index 7249876..5975179 100644 --- a/frontend-new/src/store/context/ThemeContext.js +++ b/frontend-new/src/store/context/ThemeContext.js @@ -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(); diff --git a/frontend-new/src/store/reducers/themeReducer.js b/frontend-new/src/store/reducers/themeReducer.js index 5601f94..463bb1e 100644 --- a/frontend-new/src/store/reducers/themeReducer.js +++ b/frontend-new/src/store/reducers/themeReducer.js @@ -28,7 +28,6 @@ const initialState = { const themeReducer = (state = initialState, action) => { switch (action.type) { case SET_THEME: - // 注意:reducer 应该返回新的状态对象,而不是直接修改旧状态 return { ...state, currentThemeName: action.payload, diff --git a/pom.xml b/pom.xml index 065d065..c24d964 100644 --- a/pom.xml +++ b/pom.xml @@ -149,6 +149,11 @@ spring-boot-starter-validation ${spring.boot.version} + + org.springframework.boot + spring-boot-starter-security + ${spring.boot.version} + commons-collections commons-collections diff --git a/src/main/java/org/apache/rocketmq/dashboard/config/AuthWebMVCConfigurerAdapter.java b/src/main/java/org/apache/rocketmq/dashboard/config/AuthWebMVCConfigurerAdapter.java index b902575..1e65ef0 100644 --- a/src/main/java/org/apache/rocketmq/dashboard/config/AuthWebMVCConfigurerAdapter.java +++ b/src/main/java/org/apache/rocketmq/dashboard/config/AuthWebMVCConfigurerAdapter.java @@ -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 diff --git a/src/main/java/org/apache/rocketmq/dashboard/config/SecurityConfig.java b/src/main/java/org/apache/rocketmq/dashboard/config/SecurityConfig.java new file mode 100644 index 0000000..f729f94 --- /dev/null +++ b/src/main/java/org/apache/rocketmq/dashboard/config/SecurityConfig.java @@ -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(); + } +} diff --git a/src/main/java/org/apache/rocketmq/dashboard/controller/CsrfTokenController.java b/src/main/java/org/apache/rocketmq/dashboard/controller/CsrfTokenController.java new file mode 100644 index 0000000..725e40c --- /dev/null +++ b/src/main/java/org/apache/rocketmq/dashboard/controller/CsrfTokenController.java @@ -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; + } +} diff --git a/src/main/java/org/apache/rocketmq/dashboard/controller/LoginController.java b/src/main/java/org/apache/rocketmq/dashboard/controller/LoginController.java index 9345acc..df4077d 100644 --- a/src/main/java/org/apache/rocketmq/dashboard/controller/LoginController.java +++ b/src/main/java/org/apache/rocketmq/dashboard/controller/LoginController.java @@ -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()); diff --git a/src/main/java/org/apache/rocketmq/dashboard/interceptor/AuthInterceptor.java b/src/main/java/org/apache/rocketmq/dashboard/interceptor/AuthInterceptor.java index b85c4a2..3abab56 100644 --- a/src/main/java/org/apache/rocketmq/dashboard/interceptor/AuthInterceptor.java +++ b/src/main/java/org/apache/rocketmq/dashboard/interceptor/AuthInterceptor.java @@ -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); } diff --git a/src/main/java/org/apache/rocketmq/dashboard/service/impl/ConsumerServiceImpl.java b/src/main/java/org/apache/rocketmq/dashboard/service/impl/ConsumerServiceImpl.java index 160da1a..58fb5f5 100644 --- a/src/main/java/org/apache/rocketmq/dashboard/service/impl/ConsumerServiceImpl.java +++ b/src/main/java/org/apache/rocketmq/dashboard/service/impl/ConsumerServiceImpl.java @@ -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"); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 47117c7..2e891d5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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