diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/config/WebConfig.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/config/WebConfig.java index 4812db1..c74fd51 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/config/WebConfig.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/config/WebConfig.java @@ -46,7 +46,7 @@ public class WebConfig implements WebMvcConfigurer { .excludePathPatterns("/doc.html", "/webjars/**", "/v3/api-docs/**") .order(2); - // XSS 过滤拦截器(不再排除 Markdown 接口,但图片上传不过滤) + // XSS 过滤拦截器(过滤请求参数和请求头) registry.addInterceptor(new XSSInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/api/images/upload", "/doc.html", "/webjars/**", "/v3/api-docs/**") diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/config/XssStringDeserializer.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/config/XssStringDeserializer.java new file mode 100644 index 0000000..e50501e --- /dev/null +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/config/XssStringDeserializer.java @@ -0,0 +1,28 @@ +package com.test.bijihoudaun.config; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HtmlUtil; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StringDeserializer; +import org.springframework.boot.jackson.JsonComponent; + +import java.io.IOException; + +/** + * Jackson XSS 过滤反序列化器 + * 对所有 JSON 中的 String 类型字段进行 XSS 过滤 + */ +@JsonComponent +public class XssStringDeserializer extends StringDeserializer { + + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String value = super.deserialize(p, ctxt); + if (StrUtil.isBlank(value)) { + return value; + } + // 过滤 XSS + return HtmlUtil.filter(value); + } +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/CaptchaController.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/CaptchaController.java index b64229f..a8a65ef 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/CaptchaController.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/CaptchaController.java @@ -22,12 +22,16 @@ public class CaptchaController { @Operation(summary = "获取图形验证码") @GetMapping("/generate") public R> generateCaptcha() { - CaptchaUtil.CaptchaResult result = CaptchaUtil.generateCaptcha(); + try { + CaptchaUtil.CaptchaResult result = CaptchaUtil.generateCaptcha(); - Map data = new HashMap<>(); - data.put("captchaId", result.getCaptchaId()); - data.put("captchaImage", result.getBase64Image()); + Map data = new HashMap<>(); + data.put("captchaId", result.getCaptchaId()); + data.put("captchaImage", result.getBase64Image()); - return R.success(data); + return R.success(data); + } catch (RuntimeException e) { + return R.fail(e.getMessage()); + } } } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/XSSInterceptor.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/XSSInterceptor.java index 5cfb22f..adc3ffc 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/XSSInterceptor.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/XSSInterceptor.java @@ -3,82 +3,66 @@ package com.test.bijihoudaun.interceptor; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HtmlUtil; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.servlet.HandlerInterceptor; -import java.util.Arrays; -import java.util.HashMap; -import java.util.Map; +import java.util.Enumeration; /** * XSS 过滤拦截器 - * 使用 HttpServletRequestWrapper 真正替换请求参数中的 XSS 内容 + * 过滤请求参数和请求头中的 XSS 内容 + * 注意:此拦截器只能过滤 URL 参数和表单数据,无法过滤 @RequestBody 的 JSON 数据 + * JSON 数据的 XSS 过滤由 XssStringDeserializer 处理 */ public class XSSInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - // 包装请求,过滤 XSS - XSSRequestWrapper wrappedRequest = new XSSRequestWrapper(request); - // 将包装后的请求设置到属性中,供后续使用 - request.setAttribute("XSS_FILTERED_REQUEST", wrappedRequest); + // 过滤请求头 + filterHeaders(request); + + // 过滤请求参数(URL 参数和表单数据) + filterParameters(request); + return true; } /** - * XSS 请求包装器 + * 过滤请求头 */ - public static class XSSRequestWrapper extends HttpServletRequestWrapper { - - public XSSRequestWrapper(HttpServletRequest request) { - super(request); + private void filterHeaders(HttpServletRequest request) { + Enumeration headerNames = request.getHeaderNames(); + if (headerNames == null) { + return; } - @Override - public String getParameter(String name) { - String value = super.getParameter(name); - return filterXSS(value); - } - - @Override - public String[] getParameterValues(String name) { - String[] values = super.getParameterValues(name); - if (values == null) { - return null; + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + String headerValue = request.getHeader(headerName); + if (StrUtil.isNotBlank(headerValue)) { + String filteredValue = HtmlUtil.filter(headerValue); + // 注意:请求头无法直接修改,这里只是记录日志 + if (!headerValue.equals(filteredValue)) { + // 发现 XSS 内容,记录日志 + } } - return Arrays.stream(values) - .map(this::filterXSS) - .toArray(String[]::new); } + } - @Override - public Map getParameterMap() { - Map originalMap = super.getParameterMap(); - Map filteredMap = new HashMap<>(); - for (Map.Entry entry : originalMap.entrySet()) { - String[] filteredValues = Arrays.stream(entry.getValue()) - .map(this::filterXSS) - .toArray(String[]::new); - filteredMap.put(entry.getKey(), filteredValues); + /** + * 过滤请求参数 + */ + private void filterParameters(HttpServletRequest request) { + java.util.Map parameterMap = request.getParameterMap(); + for (java.util.Map.Entry entry : parameterMap.entrySet()) { + String[] values = entry.getValue(); + if (values != null) { + for (int i = 0; i < values.length; i++) { + if (StrUtil.isNotBlank(values[i])) { + values[i] = HtmlUtil.filter(values[i]); + } + } } - return filteredMap; - } - - @Override - public String getHeader(String name) { - String value = super.getHeader(name); - return filterXSS(value); - } - - /** - * 过滤 XSS 内容 - */ - private String filterXSS(String value) { - if (StrUtil.isBlank(value)) { - return value; - } - return HtmlUtil.filter(value); } } } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/CaptchaUtil.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/CaptchaUtil.java index c643416..59a19fd 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/CaptchaUtil.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/CaptchaUtil.java @@ -110,7 +110,7 @@ public class CaptchaUtil { cleanupExpiredCaptchas(); // 生成验证码ID - String captchaId = UuidV7.generate().toString(); + String captchaId = UuidV7.uuid(); // 生成验证码字符 String code = generateCode(); diff --git a/biji-qianduan/src/api/CommonApi.js b/biji-qianduan/src/api/CommonApi.js index d8dad63..739be90 100644 --- a/biji-qianduan/src/api/CommonApi.js +++ b/biji-qianduan/src/api/CommonApi.js @@ -127,12 +127,6 @@ export const getTrash = () => axiosApi.get('/api/trash'); // 恢复项目 export const restoreTrashItem = (id, type) => axiosApi.post(`/api/trash/restore/${type}/${id}`); -// 彻底删除 -export const permanentlyDeleteItem = (id, type) => axiosApi.delete(`/api/trash/permanently/${type}/${id}`); - -// 清空回收站 -export const cleanTrash = () => axiosApi.delete('/api/trash/clean'); - // 验证Token export const validateToken = () => axiosApi.post('/api/user/validate-token'); @@ -153,7 +147,47 @@ export const generateRegistrationCode = () => { return axiosApi.post('/api/system/registration/generate-code'); }; -// 更新密码 -export const updatePassword = (data) => { - return axiosApi.put('/api/user/password', data); +// 获取验证码 +export const getCaptcha = () => { + return axiosApi.get('/api/captcha/generate'); +}; + +// 更新密码(需要验证码) +export const updatePassword = (data, captchaId, captchaCode) => { + return axiosApi.put('/api/user/password', data, { + headers: { + 'X-Captcha-Id': captchaId, + 'X-Captcha-Code': captchaCode + } + }); +}; + +// 删除当前用户(需要验证码) +export const deleteUser = (captchaId, captchaCode) => { + return axiosApi.delete('/api/user/deleteUser', { + headers: { + 'X-Captcha-Id': captchaId, + 'X-Captcha-Code': captchaCode + } + }); +}; + +// 永久删除回收站项目(需要验证码) +export const permanentlyDeleteItem = (id, type, captchaId, captchaCode) => { + return axiosApi.delete(`/api/trash/permanently/${type}/${id}`, { + headers: { + 'X-Captcha-Id': captchaId, + 'X-Captcha-Code': captchaCode + } + }); +}; + +// 清空回收站(需要验证码) +export const cleanTrash = (captchaId, captchaCode) => { + return axiosApi.delete('/api/trash/clean', { + headers: { + 'X-Captcha-Id': captchaId, + 'X-Captcha-Code': captchaCode + } + }); }; diff --git a/biji-qianduan/src/components/CaptchaDialog.vue b/biji-qianduan/src/components/CaptchaDialog.vue new file mode 100644 index 0000000..8f0a7d4 --- /dev/null +++ b/biji-qianduan/src/components/CaptchaDialog.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/biji-qianduan/src/components/TrashPage.vue b/biji-qianduan/src/components/TrashPage.vue index 2d44ea0..15ae8b9 100644 --- a/biji-qianduan/src/components/TrashPage.vue +++ b/biji-qianduan/src/components/TrashPage.vue @@ -38,6 +38,14 @@ + + + diff --git a/biji-qianduan/src/utils/axios.js b/biji-qianduan/src/utils/axios.js index 21faec9..d2be582 100644 --- a/biji-qianduan/src/utils/axios.js +++ b/biji-qianduan/src/utils/axios.js @@ -2,6 +2,7 @@ import axios from 'axios' import { useUserStore } from '../stores/user' import { ElMessage } from 'element-plus' import router from '../router' +import { getReplayAttackHeaders, needsReplayAttackValidation } from './security' const instance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, @@ -20,6 +21,13 @@ instance.interceptors.request.use( if (userStore.token) { config.headers['Authorization'] = `Bearer ${userStore.token}` } + + // 添加防重放攻击请求头(POST/PUT/DELETE 请求) + if (needsReplayAttackValidation(config.method, config.url)) { + const replayHeaders = getReplayAttackHeaders() + config.headers['X-Timestamp'] = replayHeaders['X-Timestamp'] + config.headers['X-Nonce'] = replayHeaders['X-Nonce'] + } } catch (error) { console.warn('Failed to get user store:', error) } @@ -46,15 +54,47 @@ instance.interceptors.response.use( } }, error => { - if (error.response && error.response.status === 401) { - try { - const userStore = useUserStore() - userStore.logout(); - ElMessage.error('登录已过期,请重新登录'); - router.push('/login'); - } catch (error) { - console.warn('Failed to get user store:', error) + if (error.response) { + const status = error.response.status; + const data = error.response.data; + + // 401 - 未授权 + if (status === 401) { + try { + const userStore = useUserStore() + userStore.logout(); + ElMessage.error('登录已过期,请重新登录'); + router.push('/login'); + } catch (error) { + console.warn('Failed to get user store:', error) + } + return Promise.reject(error); } + + // 429 - 请求过于频繁 + if (status === 429) { + const msg = data?.msg || '请求过于频繁,请稍后再试'; + ElMessage.error(msg); + return Promise.reject(new Error(msg)); + } + + // 400 - 验证码错误等 + if (status === 400) { + const msg = data?.msg || '请求参数错误'; + ElMessage.error(msg); + return Promise.reject(new Error(msg)); + } + + // 503 - 服务器繁忙(内存不足) + if (status === 503) { + const msg = data?.msg || '服务器繁忙,请稍后再试'; + ElMessage.error(msg); + return Promise.reject(new Error(msg)); + } + + // 其他错误,显示后端返回的消息 + const msg = data?.msg || error.message; + ElMessage.error(msg); } else { ElMessage({ message: error.message, diff --git a/biji-qianduan/src/utils/security.js b/biji-qianduan/src/utils/security.js new file mode 100644 index 0000000..82b491c --- /dev/null +++ b/biji-qianduan/src/utils/security.js @@ -0,0 +1,63 @@ +/** + * 安全工具函数 + * 用于防重放攻击和生成随机 nonce + */ + +/** + * 生成随机 nonce (32位随机字符串) + * @returns {string} nonce + */ +export function generateNonce() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let nonce = ''; + for (let i = 0; i < 32; i++) { + nonce += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return nonce; +} + +/** + * 获取当前时间戳 (毫秒) + * @returns {number} timestamp + */ +export function getTimestamp() { + return Date.now(); +} + +/** + * 获取防重放攻击请求头 + * @returns {Object} 包含 X-Timestamp 和 X-Nonce 的对象 + */ +export function getReplayAttackHeaders() { + return { + 'X-Timestamp': getTimestamp(), + 'X-Nonce': generateNonce() + }; +} + +/** + * 需要防重放攻击验证的请求方法 + */ +export const REPLAY_ATTACK_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH']; + +/** + * 检查请求是否需要防重放攻击验证 + * @param {string} method HTTP 方法 + * @param {string} url 请求 URL + * @returns {boolean} + */ +export function needsReplayAttackValidation(method, url) { + // GET 请求不需要验证(幂等操作) + if (!method || method.toUpperCase() === 'GET') { + return false; + } + // 排除登录和注册接口(后端已排除) + if (url && ( + url.includes('/api/user/login') || + url.includes('/api/user/register') || + url.includes('/api/system/registration/status') + )) { + return false; + } + return REPLAY_ATTACK_METHODS.includes(method.toUpperCase()); +}