SpringBoot+Vue3实现RSA接口加密与XSS防御的工程实践

SpringBoot+Vue3实现RSA接口加密与XSS防御的工程实践
1. 项目概述与核心价值最近在重构一个涉及敏感信息交互的后台管理系统前端是Vue3后端是SpringBoot。在联调接口时产品经理和安全团队同时提了要求一是所有敏感字段如手机号、身份证号在传输过程中必须加密防止被中间人窃取二是所有用户输入必须做严格过滤防止XSS攻击导致的管理后台被篡改。这两个要求听起来简单但真要落地你会发现光靠HTTPS和框架自带的参数校验是远远不够的。HTTPS解决了传输层的安全但数据到了日志系统、到了监控平台如果是明文依然存在泄露风险。而XSS防御如果只在后端做全局过滤又可能误伤一些合法的富文本内容。这个项目“SpringBoot整合RSA前后端加解密与XSS防御实现注解加解密与验参”就是为了系统性地解决这两个痛点。它的核心思路是在业务层面为敏感数据提供一道额外的、应用层的加密屏障同时为Web安全提供一道精准的、可定制的输入过滤防线。简单说就是通过RSA非对称加密确保关键数据“出了门就是密文”只有合法的接收方才能解密通过注解驱动的XSS参数校验确保所有进来的数据“先洗澡再进屋”把恶意脚本挡在业务逻辑之外。这套方案特别适合对数据安全有较高要求的中后台系统、金融或政务类应用的前后端分离架构。如果你正在为如何优雅地实现接口参数自动加解密而头疼或者觉得全局XSS过滤不够灵活那么接下来我分享的这套从原理到落地的完整实践应该能给你提供一个可直接复用的参考模板。2. 整体架构设计与技术选型考量2.1 为什么是RSA而非AES提到加密很多人第一反应是对称加密AES因为它快。但在前后端分离且前端代码暴露给用户的环境下使用对称加密有一个致命问题密钥如何安全地存储在前端如果把AES密钥硬编码在JS里等同于把钥匙放在了门口的地垫下。因此对于需要前端发起加密的场景非对称加密RSA成了更合适的选择。RSA的公钥可以放心地交给前端用于加密私钥则牢牢保管在后端用于解密。即便公钥被截获没有私钥也无法解密出原始数据这完美契合了前端加密、后端解密的场景。虽然RSA加密速度较慢且有数据长度限制例如1024位密钥最多加密117字节明文但我们只用它来加密最关键的小数据如密码、密钥本身大数据量的传输则可以采用“RSA加密AES密钥AES加密业务数据”的混合模式。本项目为简化演示聚焦于RSA直接加密核心字段。2.2 注解驱动兼顾优雅与灵活无论是加解密还是XSS过滤最笨的办法就是在每个Controller的方法里手动调用工具类。但这会导致代码重复、侵入性强、难以维护。我们的目标是业务代码无感知。因此我们采用注解驱动的方式Encrypt/Decrypt注解标记在Controller方法或参数上声明何处需要加解密。通过Spring的HandlerMethodArgumentResolver参数解析器和ResponseBodyAdvice响应体增强在请求处理的生命周期中自动介入。自定义校验注解如XssFilter结合Spring ValidationValidated和Hibernate Validator实现自定义约束注解。当方法参数被Validated标记时自动触发XSS脚本的过滤或校验。这样做的好处是业务开发人员只需要关注注解本身复杂的加解密和过滤逻辑由统一的切面或组件完成符合“关注点分离”的原则。2.3 前端Vue3的集成策略前端需要完成两件事使用后端下发的RSA公钥加密指定字段对用户输入进行初步的XSS过滤作为第二道防线。我们会在Vue3中封装一个通用的请求拦截器axios interceptor根据接口约定例如通过特定请求头或参数名约定自动识别需要加密的字段并进行处理。同时封装一个XSS过滤工具函数在表单提交前对输入值进行清洗。3. 后端核心实现SpringBoot整合RSA与XSS防御3.1 RSA密钥对的管理与配置首先我们需要生成RSA密钥对。在实际项目中私钥绝不能写在配置文件中而应存放在安全的密钥管理系统或硬件安全模块HSM中。为了方便演示我们使用Java的KeyPairGenerator生成并将公钥导出为Base64字符串供前端使用。Component public class RsaKeyHolder { private static final String KEY_ALGORITHM RSA; private static final int KEY_SIZE 2048; // 推荐2048位安全性更高 private PrivateKey privateKey; private PublicKey publicKey; private String publicKeyStr; PostConstruct public void init() throws NoSuchAlgorithmException { KeyPairGenerator keyPairGen KeyPairGenerator.getInstance(KEY_ALGORITHM); keyPairGen.initialize(KEY_SIZE); KeyPair keyPair keyPairGen.generateKeyPair(); this.privateKey keyPair.getPrivate(); this.publicKey keyPair.getPublic(); // 将公钥编码为Base64字符串方便前端获取 this.publicKeyStr Base64.getEncoder().encodeToString(publicKey.getEncoded()); } public PrivateKey getPrivateKey() { return privateKey; } public String getPublicKeyStr() { return publicKeyStr; } }注意上述代码在应用启动时生成密钥对这意味着每次重启服务公钥都会变。生产环境中你应该使用固定的密钥对并将私钥存储在环境变量或专业的密钥管理服务中而不是在代码中生成。3.2 实现Decrypt注解与参数解析器我们的目标是当某个RequestBody参数被Decrypt标记时在它被Jackson反序列化成Java对象之前先对其中的加密字段进行解密。定义注解Target({ElementType.PARAMETER, ElementType.FIELD}) Retention(RetentionPolicy.RUNTIME) public interface Decrypt { // 可以指定需要解密的字段名为空则处理整个对象 String[] value() default {}; }实现HandlerMethodArgumentResolver 这个接口允许我们自定义Controller方法参数的解析过程。Component public class DecryptArgumentResolver implements HandlerMethodArgumentResolver { Autowired private RsaKeyHolder rsaKeyHolder; Autowired private ObjectMapper objectMapper; // Jackson的ObjectMapper Override public boolean supportsParameter(MethodParameter parameter) { // 支持带有Decrypt注解的参数 return parameter.hasParameterAnnotation(Decrypt.class); } Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { HttpServletRequest request (HttpServletRequest) webRequest.getNativeRequest(); String body IOUtils.toString(request.getInputStream(), StandardCharsets.UTF_8); if (StringUtils.isBlank(body)) { return null; } // 1. 将请求体解析为JsonNode JsonNode rootNode objectMapper.readTree(body); // 2. 获取注解信息判断需要解密哪些字段 Decrypt decryptAnno parameter.getParameterAnnotation(Decrypt.class); // 3. 遍历JsonNode找到标记了Decrypt的字段或其内部的加密字段进行解密 decryptJsonNode(rootNode, decryptAnno); // 4. 将解密后的JsonNode转换回目标参数类型 return objectMapper.treeToValue(rootNode, parameter.getParameterType()); } private void decryptJsonNode(JsonNode node, Decrypt decryptAnno) { // 递归遍历JSON对象根据字段名或自定义逻辑识别密文例如字段名以“Encrypted”结尾 // 使用RsaKeyHolder中的私钥进行解密 // 将解密后的明文替换回JsonNode中 // 具体实现略核心是调用Cipher.getInstance(RSA/ECB/PKCS1Padding).doFinal(Base64.decode(密文)) } }注册解析器通过Configuration类实现WebMvcConfigurer将DecryptArgumentResolver添加到Spring MVC的解析器列表中。3.3 实现Encrypt注解与响应体增强对于响应加密我们使用ResponseBodyAdvice接口。它可以让我们在ResponseBody或ResponseEntity返回值被HttpMessageConverter写入响应体之前对其进行拦截处理。RestControllerAdvice(basePackages com.yourpackage.controller) public class EncryptResponseBodyAdvice implements ResponseBodyAdviceObject { Autowired private RsaKeyHolder rsaKeyHolder; Override public boolean supports(MethodParameter returnType, Class converterType) { // 只处理带有Encrypt注解的方法或类 return returnType.hasMethodAnnotation(Encrypt.class) || returnType.getContainingClass().isAnnotationPresent(Encrypt.class); } Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { if (body null) return null; // 1. 将body对象序列化为JsonNode // 2. 根据Encrypt注解的配置或默认规则如字段名识别需要加密的字段 // 3. 使用RsaKeyHolder中的公钥注意这里应该用后端的公钥吗不响应加密通常用前端的公钥这是一个关键设计点 // 更常见的场景是响应体加密用于服务间调用。如果要对前端响应加密前端需提供其公钥。 // 本例假设为简化场景加密字段仅用于内部流转或日志脱敏。 // 4. 将加密后的密文替换回JsonNode // 5. 返回处理后的对象 return encryptBody(body, returnType); } }实操心得响应加密的设计陷阱响应加密比请求解密更复杂因为加密的接收方是谁如果是浏览器你需要让前端生成一对RSA密钥并将公钥传给后端。这增加了复杂度。因此很多项目只做请求参数加密防传输泄露和响应脱敏防日志泄露而非全量响应加密。务必根据实际安全需求决定。3.4 实现XssFilter注解与校验器XSS防御的核心是过滤或转义用户输入中的HTML特殊字符如,,,,。我们将创建一个自定义约束注解。定义注解Target({ElementType.FIELD, ElementType.PARAMETER}) Retention(RetentionPolicy.RUNTIME) Constraint(validatedBy XssValidator.class) // 指定校验器 public interface XssFilter { String message() default 输入内容包含潜在的不安全脚本; Class?[] groups() default {}; Class? extends Payload[] payload() default {}; // 可添加策略属性如FilterPolicy.ESCAPE转义 或 FilterPolicy.REJECT拒绝 FilterPolicy policy() default FilterPolicy.ESCAPE; }实现校验器XssValidatorpublic class XssValidator implements ConstraintValidatorXssFilter, String { private FilterPolicy policy; private static final Pattern[] XSS_PATTERNS new Pattern[] { Pattern.compile(script(.*?)/script, Pattern.CASE_INSENSITIVE), Pattern.compile(javascript:, Pattern.CASE_INSENSITIVE), Pattern.compile(onload|onerror|onclick, Pattern.CASE_INSENSITIVE), // 更多XSS攻击模式... }; Override public void initialize(XssFilter constraintAnnotation) { this.policy constraintAnnotation.policy(); } Override public boolean isValid(String value, ConstraintValidatorContext context) { if (value null) { return true; } // 策略1拒绝包含恶意模式的输入 if (policy FilterPolicy.REJECT) { for (Pattern pattern : XSS_PATTERNS) { if (pattern.matcher(value).find()) { return false; // 校验失败Spring会抛出MethodArgumentNotValidException } } return true; } // 策略2转义校验器本身不修改值这里仅演示。实际过滤通常在后续的Binder或Converter中完成 // 更常见的做法是使用一个HttpServletRequestFilter或Jackson的Deserializer进行全局过滤。 return true; } }使用注解在接收参数的DTO字段上使用XssFilter。public class UserDTO { NotBlank XssFilter(policy FilterPolicy.REJECT) // 拒绝包含脚本的输入 private String username; NotBlank XssFilter(policy FilterPolicy.ESCAPE) // 对输入进行转义 private String comment; // getters and setters }在Controller中使用Validated触发校验PostMapping(/update) public Result updateUser(Validated RequestBody UserDTO userDTO) { // 如果userDTO.comment包含script根据策略可能被转义或导致校验失败。 return Result.success(); }注意事项XSS过滤的层次注解校验适合做校验和拒绝。对于转义更好的位置是在数据绑定阶段如自定义WebDataBinder或JSON反序列化阶段自定义Jackson的JsonDeserializer这样可以确保进入业务逻辑的数据已经是干净的。防御XSS的最佳实践是“输入校验输出编码”即在展示数据给HTML时进行编码如Thymeleaf、Vue默认已做而非单纯依赖输入过滤。4. 前端核心实现Vue3中的加密与过滤4.1 封装RSA加密工具与Axios拦截器首先我们需要一个前端RSA加密库。这里我们使用jsencrypt它是一个纯JavaScript实现的RSA加密库。安装依赖npm install jsencrypt封装加密工具// utils/rsaEncrypt.js import JSEncrypt from jsencrypt // 从后端接口获取公钥这里模拟一个获取公钥的函数 let publicKey export async function getPublicKey() { if (!publicKey) { const res await axios.get(/api/system/public-key) publicKey res.data.data // 假设返回格式为 { code:0, data: MIIBIjANBgkqh... } } return publicKey } export async function encryptData(data, publicKeyStr null) { const encryptor new JSEncrypt() const key publicKeyStr || await getPublicKey() encryptor.setPublicKey(key) // 注意RSA加密有长度限制通常用于加密密钥或短数据 // 如果data是对象需要先JSON.stringify const str typeof data string ? data : JSON.stringify(data) const encrypted encryptor.encrypt(str) if (!encrypted) { throw new Error(RSA加密失败请检查公钥格式或数据长度) } return encrypted }配置Axios请求拦截器实现自动加密 我们需要和后端约定一种方式来标识哪些请求的哪些字段需要加密。这里采用一个自定义请求头X-Encrypt-Fields其值是需要加密的字段名多个字段用逗号分隔。// utils/request.js import axios from axios import { encryptData } from ./rsaEncrypt const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use( async (config) { // 检查请求头中是否有需要加密的字段标识 const encryptFieldsHeader config.headers[X-Encrypt-Fields] if (encryptFieldsHeader config.data typeof config.data object) { const fields encryptFieldsHeader.split(,) const encryptedData { ...config.data } for (const field of fields) { if (encryptedData[field] ! undefined encryptedData[field] ! null) { try { encryptedData[field] await encryptData(encryptedData[field]) } catch (error) { console.error(加密字段[${field}]失败:, error) // 根据业务决定是抛出错误还是继续发送明文 // 安全要求高的场景应中断请求 return Promise.reject(new Error(参数加密失败: ${field})) } } } config.data encryptedData // 加密后可以移除这个自定义头避免泄露给下游 delete config.headers[X-Encrypt-Fields] } return config }, (error) { return Promise.reject(error) } ) export default service在组件中使用script setup import { ref } from vue import request from /utils/request const form ref({ username: , idCard: , // 假设这是需要加密的敏感字段 phone: }) const submitForm async () { try { // 在请求配置中指定需要加密的字段 await request.post(/user/sensitive-update, form.value, { headers: { X-Encrypt-Fields: idCard,phone // 告诉拦截器加密这两个字段 } }) // 提交成功... } catch (error) { console.error(提交失败, error) } } /script4.2 集成XSS过滤库作为前端防线前端进行XSS过滤可以作为第二道防线主要目的是防止用户输入在未到达后端前就在前端渲染时造成问题例如在单页应用内直接操作DOM。我们使用一个轻量级的库xss。安装依赖npm install xss封装过滤函数// utils/xssFilter.js import xss from xss // 配置白名单允许一些安全的HTML标签和属性根据业务需求调整 const options { whiteList: { a: [href, title, target], p: [], br: [], span: [class] // ... 其他允许的标签 }, stripIgnoreTagBody: [script, style, iframe] // 直接移除这些标签及其内容 } export function filterXss(html, customOptions {}) { const finalOptions { ...options, ...customOptions } return xss(html, finalOptions) } // 一个更严格的版本只保留纯文本 export function filterXssStrict(text) { return xss(text, { whiteList: {}, stripIgnoreTag: true }) }在表单提交或输入时使用script setup import { ref } from vue import { filterXssStrict } from /utils/xssFilter const comment ref() const handleInput (event) { // 实时过滤输入可能会影响输入体验慎用 // comment.value filterXssStrict(event.target.value) } const submitComment () { const cleanComment filterXssStrict(comment.value) // 将cleanComment发送到后端 } /script template textarea v-modelcomment inputhandleInput/textarea /template实操心得前端过滤的定位永远不要只依赖前端进行安全过滤前端的XSS过滤只是为了提升用户体验防止即时预览出问题和作为一道补充防线。真正的安全校验必须在后端完成因为攻击者可以完全绕过你的前端直接构造恶意请求发给API。5. 联调测试、常见问题与性能优化5.1 联调测试要点RSA加解密联调测试正常流程前端用公钥加密一个已知字符串如Hello123后端解密后比对是否一致。测试异常流程发送错误的密文如篡改几个字符后端应能捕获解密异常并返回友好的错误信息如“参数解密失败”而不是抛出堆栈信息。测试不加密直接发送明文后端注解是否生效预期应该是解密失败请求被拒绝。工具辅助使用在线RSA加解密工具或Postman的Pre-request Script用相同的公钥加密数据与前端加密结果对比快速定位是前端加密问题还是后端解密问题。XSS防御测试输入测试尝试提交包含scriptalert(xss)/script、javascript:alert(1)、onloadalert(1)等常见XSS Payload的字符串。验证策略如果注解策略是REJECT应收到400校验错误如果是ESCAPE查看存入数据库或返回的数据特殊字符应被转义如变成lt;。绕过测试尝试一些变形Payload如大小写混合、使用HTML实体编码、利用事件属性等检查过滤是否彻底。5.2 常见问题与排查技巧前端加密失败控制台报错“Message too long for RSA”原因RSA算法本身限制加密数据长度不能超过密钥长度单位位减去填充方案占用的位数。例如2048位密钥PKCS1Padding填充模式下最大加密明文长度是2048/8 - 11 245字节。解决检查加密的数据是否是过长的字符串如整个JSON对象。RSA只应用于加密关键字段或一个随机的AES密钥。如果必须加密较长数据采用混合加密前端生成一个随机的AES密钥用AES加密数据再用RSA公钥加密这个AES密钥将两者一起发送给后端。后端解密失败报错“javax.crypto.BadPaddingException: Decryption error”原因这是RSA解密最常见的错误。可能原因有密文传输过程中被修改网络问题或前端加密结果错误。使用的公私钥不配对。前端使用的加密填充方案与后端解密指定的填充方案不一致。排查核对密钥确保前端使用的公钥和后端用于解密的私钥是同一对。核对填充方案前后端必须使用相同的填充方案。Java端常用RSA/ECB/PKCS1Padding对应的在jsencrypt中默认就是PKCS1Padding。确保没有使用NoPadding等不安全的方案。Base64编码确保密文在传输前后Base64编码/解码正确。有时可能存在换行符、空格等问题。Decrypt或Encrypt注解不生效原因Spring的拦截器或解析器未正确注册或生效顺序有问题。排查检查自定义的HandlerMethodArgumentResolver或ResponseBodyAdvice是否被Component或Service注解标记并处于Spring的扫描路径下。检查WebMvcConfigurer配置类是否正确添加了解析器。在supports方法中打日志确认是否进入了你的自定义逻辑。注意RestControllerAdvice的basePackages是否包含了你的Controller包。XSS过滤后合法的富文本内容如带格式的评论被破坏了原因过滤策略白名单设置得过于严格。解决这是安全与功能的平衡。你需要根据业务场景细化白名单。对于普通的文本输入如用户名、搜索框使用最严格的白名单只保留纯文本。对于需要富文本编辑的区域如文章内容、评论回复使用一个更宽松但经过仔细审核的白名单如允许b,i,a,img等安全标签和属性。可以考虑引入专业的富文本编辑器如Quill、WangEditor它们通常内置了XSS过滤功能。5.3 性能优化与安全增强建议RSA性能优化缓存公钥前端不要每次加密都请求公钥应在应用初始化时获取一次并缓存。使用混合加密如前所述对于大量数据采用RSAAES混合加密。RSA只加密一个随机的AES密钥session key数据本身用AES加密性能会好很多。考虑SM2国密算法在一些对国产密码算法有要求的场景可以考虑使用SM2替代RSA。SM2在相同安全强度下密钥更短性能可能更有优势。前端可以使用sm-crypto库。安全增强密钥轮转定期如每季度更换RSA密钥对。更换时需要有一个新旧密钥并存的过渡期确保正在传输中的请求不会失败。请求重放攻击防御仅加密不能防御重放攻击。可以考虑加入时间戳timestamp和随机数nonce并将它们一起加密或签名。后端校验请求的时间戳是否在合理窗口内如5分钟并检查nonce是否已被使用过。更全面的XSS防护设置HTTP安全头如Content-Security-Policy (CSP)这是防御XSS的终极利器可以严格限制页面可以加载和执行哪些资源。确保所有用户输出点都进行了正确的编码在HTML上下文中用HTML编码在JavaScript上下文中用JS编码在URL中用URL编码。日志与监控脱敏日志确保在打印日志时被Encrypt注解的字段自动脱敏如显示为****或[ENCRYPTED]防止敏感信息泄露到日志系统。监控解密失败率突然升高的解密失败率可能意味着有攻击者在尝试发送伪造或探测数据。这套整合方案从设计到实现涵盖了从注解定义、前后端组件封装到联调测试的完整链路。它最大的价值在于将安全能力下沉为基础设施让业务开发者通过声明式的注解就能获得强大的安全保障从而更专注于业务逻辑本身。在实际项目中落地时建议先从核心的敏感接口开始试点逐步推广并配套完善的监控和密钥管理机制。安全是一个持续的过程而非一劳永逸的特性。