
1. 项目概述为什么我们需要构建双重防御体系在Web应用开发中安全从来不是一个“可选项”而是与业务功能同等重要的“必选项”。我见过太多项目前期为了赶进度把安全防护往后放结果上线没多久就因漏洞被攻击轻则数据泄露、服务中断重则公司声誉受损甚至面临法律风险。SpringBoot以其快速开发的特性深受喜爱但“快”并不意味着可以牺牲安全。SpringSecurity作为Spring生态中事实上的安全标准为我们提供了强大的认证授权能力但很多人忽略了它在通用Web安全防护上的潜力。这个项目的核心就是利用SpringSecurity在SpringBoot应用中构建一个针对XSS跨站脚本攻击和SQL注入这两大最常见、最危险的Web漏洞的主动防御层。为什么是双重防御因为这两种攻击的入口和原理截然不同。XSS攻击利用的是浏览器对用户输入中JavaScript代码的信任攻击发生在客户端而SQL注入则是利用应用程序对数据库查询语句拼接的信任攻击发生在服务端。一个成功的防御体系必须能同时在这两个层面进行拦截和净化。传统的做法可能是在每个Controller里对参数进行过滤或者在MyBatis的Mapper里小心使用#{}但这些是分散的、易遗漏的。我们的目标是构建一个集中式的、声明式的防御体系让安全逻辑与业务逻辑解耦。通过这个项目你将学会如何配置SpringSecurity的过滤器链来拦截和清洗请求如何与现有数据访问层如MyBatis-Plus协同工作以及如何编写测试用例来验证防御的有效性。无论你是正在开发一个新项目还是需要加固一个已有系统这套方案都能提供即插即用的保护。2. 防御体系整体架构与设计思路构建一个有效的防御体系首先要理解攻击是如何发生的然后才能设计针对性的防御节点。我们的架构核心思想是在HTTP请求到达业务逻辑之前就完成恶意内容的检测与过滤。2.1 攻击路径分析与防御点设计XSS攻击的Payload通常通过URL参数、POST表单数据、HTTP头部如User-Agent、Referer以及Cookie传入。SQL注入的Payload则主要隐藏在查询参数、表单字段中试图改变后端SQL语句的原始逻辑。因此我们的防御点必须覆盖这些入口。我设计的防御体系主要包含三个层次边缘过滤层利用SpringSecurity Filter这是第一道也是最关键的一道防线。我们通过自定义过滤器对HttpServletRequest中的参数进行全局过滤。这一层的目标是进行通用的、基于规则的清洗比如转义HTML特殊字符、过滤常见的SQL关键字等。数据访问层ORM框架配置这是针对SQL注入的专项防御。无论前端过滤多么完善都必须假设有些恶意请求可能绕过第一层。因此在数据持久化层我们必须使用参数化查询PreparedStatement这是防止SQL注入的终极手段。我们将重点展示如何在MyBatis-Plus中确保这一点。视图渲染层模板引擎配置这是针对XSS的专项兜底。即使有恶意脚本逃过了请求层的过滤我们还可以在将数据渲染到HTML页面时进行转义。Thymeleaf等现代模板引擎默认就有不错的转义功能但我们需要正确配置以确保其生效。这个三层架构确保了纵深防御攻击者需要连续突破三道关卡才能成功极大地提高了攻击成本。SpringSecurity在其中扮演了“交通枢纽”和“第一道闸门”的角色。2.2 技术栈选型与考量SpringBoot 2.7.18选择这个相对稳定的版本而非最新的3.x主要是出于企业环境稳定性和生态成熟度的考虑。许多公司的中间件和内部库可能还未完全适配3.x。SpringSecurity不单单用于登录认证。我们将深度利用其FilterChainProxy机制插入我们自定义的安全过滤器并且会用到其提供的CorsFilter、CsrfFilter等构建完整的安全上下文。MyBatis-Plus国内项目的高频选择。我们需要确保在享受其CRUD便利的同时不引入SQL注入风险。重点会讲解Param注解的使用和XML中#{}与${}的区别。Thymeleaf作为视图层其自动HTML转义特性是我们防御XSS的重要一环。我们会配置ThymeleafAutoConfiguration相关的属性。Jackson处理JSON序列化/反序列化。这里有一个隐藏的风险点如果API直接返回包含用户输入的对象而前端又直接将其插入DOM也可能导致XSS。我们会配置Jackson的序列化器进行全局转义。注意不要试图自己编写复杂的正则表达式来检测所有XSS或SQL注入变种。这是一场打地鼠的游戏且极易产生误判。我们的策略是“默认转义”和“参数化查询”即不管输入是什么都按照安全的方式进行处理而不是尝试识别恶意内容。3. 利用SpringSecurity过滤器构建全局请求清洗层这是整个防御体系中最具创新性的一环。我们将编写两个自定义过滤器一个用于防御XSS一个用于防御SQL注入并将它们插入到SpringSecurity的过滤器链中合适的位置。3.1 创建XSS请求过滤过滤器XSS过滤的核心是对请求参数中的HTML特殊字符进行转义。我们创建一个XssFilter它继承自OncePerRequestFilter确保每个请求只被处理一次。Component Order(Ordered.HIGHEST_PRECEDENCE) // 设置高优先级尽早执行 public class XssFilter extends OncePerRequestFilter { // 使用Apache Commons Text提供的转义工具 private static final HtmlCharacterEscapes HTML_ESCAPES new HtmlCharacterEscapes(); Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 使用我们自定义的包装类替换原始Request XssHttpServletRequestWrapper wrappedRequest new XssHttpServletRequestWrapper(request); filterChain.doFilter(wrappedRequest, response); } }关键点在于XssHttpServletRequestWrapper类。它需要重写getParameter,getParameterValues,getHeader等方法在返回值前进行转义。public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { public XssHttpServletRequestWrapper(HttpServletRequest request) { super(request); } Override public String getParameter(String name) { String value super.getParameter(name); return escapeHtml(value); // 对值进行转义 } Override public String[] getParameterValues(String name) { String[] values super.getParameterValues(name); if (values null) return null; return Arrays.stream(values).map(this::escapeHtml).toArray(String[]::new); } Override public String getHeader(String name) { String value super.getHeader(name); return escapeHtml(value); } private String escapeHtml(String input) { if (input null || input.isEmpty()) { return input; } // 将 , , , , 等字符转换为HTML实体 return StringEscapeUtils.escapeHtml4(input); } }实操心得不要转义所有请求。对于Content-Type为application/json的API请求其参数在请求体中不会被getParameter方法获取。这类请求的清洗应在Jackson反序列化时或业务逻辑中处理。我们可以在XssFilter中根据请求类型跳过处理。转义可能会破坏合法数据。例如一个内容管理系统的富文本编辑器其提交的内容本身就包含HTML标签。对于这种情况不能一刀切。解决方案是要么在白名单路径如/admin/editor/**跳过过滤要么对特定字段通过参数名判断采用更宽松的过滤策略如只转义script标签。3.2 创建SQL注入请求过滤过滤器SQL注入过滤的思路与XSS类似但更需谨慎。我们无法完全依赖请求层来杜绝SQL注入但可以过滤掉一些明显恶意的模式作为一道预警和辅助防线。Component Order(Ordered.HIGHEST_PRECEDENCE 1) // 在XssFilter之后执行 public class SqlInjectionFilter extends OncePerRequestFilter { private final Pattern sqlPattern Pattern.compile( ((|[^])*)|(;)|(\\b(select|insert|update|delete|drop|union|exec|execute|truncate)\\b), Pattern.CASE_INSENSITIVE ); Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { boolean isSafe true; // 检查查询参数 EnumerationString paramNames request.getParameterNames(); while (paramNames.hasMoreElements() isSafe) { String paramName paramNames.nextElement(); String[] paramValues request.getParameterValues(paramName); for (String value : paramValues) { if (value ! null sqlPattern.matcher(value).find()) { isSafe false; break; } } } // 检查请求头可选但User-Agent等字段也可能被利用 EnumerationString headerNames request.getHeaderNames(); while (headerNames.hasMoreElements() isSafe) { String headerValue request.getHeader(headerNames.nextElement()); if (headerValue ! null sqlPattern.matcher(headerValue).find()) { isSafe false; break; } } if (!isSafe) { // 记录日志并返回错误响应而不是继续处理请求 log.warn(检测到潜在的SQL注入攻击请求URI: {}, request.getRequestURI()); response.setStatus(HttpStatus.BAD_REQUEST.value()); response.getWriter().write(Invalid request parameters.); return; } filterChain.doFilter(request, response); } }重要警告这个过滤器绝不能作为防御SQL注入的主要手段它存在很高的误报率比如用户正当的描述中包含“select”这个词和漏报率攻击者可以使用编码、注释拆分等技巧绕过正则。它的主要作用是日志记录和早期预警。真正的防御必须在数据访问层通过参数化查询实现。3.3 将自定义过滤器集成到SpringSecurity链中仅仅创建Component过滤器还不够我们需要明确告诉SpringSecurity把它放在过滤器链的哪个位置。通常在CsrfFilter之后UsernamePasswordAuthenticationFilter之前是一个比较合适的位置这样既能处理清洗后的参数又不影响认证逻辑。Configuration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Autowired private XssFilter xssFilter; Autowired private SqlInjectionFilter sqlInjectionFilter; Override protected void configure(HttpSecurity http) throws Exception { http .cors().and() // 处理跨域 .csrf().disable() // 根据API设计决定是否禁用CSRF前后端分离项目通常禁用 .authorizeRequests() .antMatchers(/api/public/**).permitAll() .anyRequest().authenticated() .and() .formLogin().disable() // 通常前后端分离用不上表单登录 .httpBasic().disable() // 禁用Basic认证 .addFilterBefore(xssFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(sqlInjectionFilter, UsernamePasswordAuthenticationFilter.class) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 无状态会话适合JWT } }4. 数据访问层加固MyBatis-Plus与参数化查询实战请求层的过滤是辅助数据访问层的安全才是根本。SQL注入之所以发生是因为程序将用户输入直接拼接到了SQL语句中改变了其语法结构。参数化查询Prepared Statement将SQL语句的“结构”与“数据”分开数据库引擎会先编译带占位符的SQL结构再将用户输入的数据当作纯数据处理从根本上杜绝了注入的可能。4.1 MyBatis/MyBatis-Plus中的安全编码实践1. 永远使用#{}避免使用${}这是MyBatis中最重要的安全准则。#{}是预编译占位符。MyBatis会将其转换为?然后使用PreparedStatement进行参数设置。这是安全的。${}是字符串替换。MyBatis会直接将参数值替换到SQL语句中。如果这个值来自用户输入就会导致SQL注入。!-- 安全示例 -- select idselectUserByName resultTypeUser SELECT * FROM user WHERE name #{name} /select !-- 危险示例 -- select idselectUserByNameUnsafe resultTypeUser SELECT * FROM user WHERE name ${name} /select如果name传入 OR 11第一个SQL会变成SELECT * FROM user WHERE name ?参数安全绑定。第二个则会变成SELECT * FROM user WHERE name OR 11导致条件永远为真。2. 使用Param注解明确参数名在Mapper接口的方法中使用Param注解可以避免参数绑定错误也让XML中的引用更清晰。public interface UserMapper extends BaseMapperUser { // 安全且清晰 User selectByUsernameAndEmail(Param(username) String username, Param(email) String email); }对应的XMLselect idselectByUsernameAndEmail resultTypeUser SELECT * FROM user WHERE username #{username} AND email #{email} /select3. 动态SQL中的安全注意事项在使用if,choose,foreach等动态SQL标签时内部的变量引用也必须使用#{}。select idselectUsersByCondition resultTypeUser SELECT * FROM user where if testusername ! null and username ! AND username #{username} !-- 正确 -- /if if teststatusList ! null and statusList.size 0 AND status IN foreach collectionstatusList itemstatus open( separator, close) #{status} !-- 正确即使遍历列表每个item也用#{} -- /foreach /if /where /select4.2 使用MyBatis-Plus内置方法的安全优势MyBatis-Plus的BaseMapper和Service层封装提供了大量开箱即用的CRUD方法这些方法内部生成的SQL都使用了参数化查询是安全的。// 以下操作都是安全的 userMapper.selectById(1L); userMapper.selectOne(new QueryWrapperUser().eq(username, admin)); userMapper.selectList(new QueryWrapperUser().like(name, 张)); // LambdaQueryWrapper 类型安全同样安全 userMapper.selectList(new LambdaQueryWrapperUser() .eq(User::getUsername, inputUsername) .gt(User::getAge, 18));踩坑记录QueryWrapper的apply方法需要极度小心apply方法允许你拼接自定义的SQL片段这个片段会直接以${}的方式拼接非常危险。// 极度危险的写法如果columnName和sortOrder来自用户输入就是注入漏洞 wrapper.apply(order by {0} {1}, columnName, sortOrder); // 相对安全的替代方案将排序逻辑限制在有限的选项内 String validColumn Arrays.asList(id, create_time).contains(columnName) ? columnName : id; String validOrder ASC.equalsIgnoreCase(sortOrder) || DESC.equalsIgnoreCase(sortOrder) ? sortOrder : ASC; wrapper.orderBy(true, ASC.equalsIgnoreCase(validOrder), validColumn);5. 视图层与响应处理Thymeleaf与Jackson的XSS转义配置即使请求参数被清洗如果我们在将数据输出到HTML页面或JSON响应时处理不当XSS风险依然存在。因此我们需要在“出口”也做好防护。5.1 配置Thymeleaf自动转义Thymeleaf默认是开启HTML转义的。在模板中使用th:text属性输出的内容都会被自动转义。!-- 假设后端传来 model.addAttribute(userInput, scriptalert(xss)/script) -- p th:text${userInput}/p !-- 渲染结果为plt;scriptgt;alert(#39;xss#39;)lt;/scriptgt;/p -- !-- 脚本被转义为纯文本不会执行 --但是有时我们需要输出富文本比如博客内容其中包含合法的HTML标签。这时使用th:text会把所有标签都转义掉。Thymeleaf提供了th:utextUnescaped Text来输出原始HTML。!-- 危险如果userInput来自不可信源会导致XSS -- p th:utext${userInput}/p安全实践对于需要展示富文本的场景必须在数据入库前进行严格的净化Sanitize而不是在输出时禁用转义。可以使用像Jsoup这样的库来定义一个允许的HTML标签和属性白名单过滤掉script、onerror等危险元素。// 在Service层对富文本内容进行净化后再存入数据库 import org.jsoup.Jsoup; import org.jsoup.safety.Safelist; public class ContentService { private static final Safelist SAFE_LIST Safelist.relaxed() // 宽松的白名单允许大部分HTML .addTags(section, article) // 添加自定义允许的标签 .removeTags(script, iframe) // 移除危险的标签 .addAttributes(a, target, rel) // 允许a标签的target和rel属性 .addProtocols(a, href, http, https); // 只允许http/https链接 public String sanitizeHtml(String dirtyHtml) { if (dirtyHtml null) return null; return Jsoup.clean(dirtyHtml, SAFE_LIST); } }这样存入数据库的是经过净化的“安全”HTML前端模板就可以放心地使用th:utext来渲染了。5.2 配置Jackson进行JSON序列化转义对于前后端分离的项目后端通过API返回JSON数据。如果JSON中的字符串字段包含未转义的HTML/JS代码而前端又直接使用innerHTML或v-htmlVue等方式将其插入DOM同样会触发XSS。我们需要配置Jackson的ObjectMapper使其在序列化字符串时自动进行HTML转义。Configuration public class JacksonConfig { Bean Primary // 声明为主要Bean覆盖SpringBoot的默认配置 public ObjectMapper objectMapper() { ObjectMapper mapper new ObjectMapper(); // 注册一个自定义的序列化模块 SimpleModule module new SimpleModule(); // 为String类型添加一个自定义的序列化器 module.addSerializer(String.class, new JsonHtmlXssSerializer()); mapper.registerModule(module); // 其他配置... mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); mapper.setDateFormat(new SimpleDateFormat(yyyy-MM-dd HH:mm:ss)); return mapper; } /** * 自定义的字符串序列化器用于HTML转义 */ public static class JsonHtmlXssSerializer extends JsonSerializerString { Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value ! null) { // 使用相同的转义库进行转义 String escaped StringEscapeUtils.escapeHtml4(value); gen.writeString(escaped); } else { gen.writeNull(); } } } }注意事项这种全局转义同样会影响富文本字段。解决方案是对于明确需要传输原始HTML的字段如文章详情可以将其类型声明为自定义的HtmlString类型并为这个类型配置一个不转义的序列化器或者使用JsonRawValue注解。public class ArticleDTO { private String title; // 普通标题会被转义 JsonRawValue // 该注解告诉Jackson这个字段的值已经是JSON不要再次转义 private String content; // 富文本内容已在服务端净化前端直接渲染 // getters and setters }6. 防御体系测试与验证从单元测试到渗透自检构建好防御体系后必须进行严格的测试。安全功能如果未经测试其可靠性就是零。6.1 编写单元测试与集成测试1. 测试XSS过滤器模拟发送包含XSS Payload的请求断言响应中该Payload已被转义。SpringBootTest AutoConfigureMockMvc class XssFilterTest { Autowired private MockMvc mockMvc; Test void testXssFilterInParameter() throws Exception { String maliciousInput scriptalert(xss)/script; mockMvc.perform(get(/api/test/echo) .param(input, maliciousInput)) .andExpect(status().isOk()) .andExpect(content().string(containsString(lt;scriptgt;alert(#39;xss#39;)lt;/scriptgt;))); // 断言返回的内容是转义后的而不是原始脚本 } Test void testJsonResponseEscaped() throws Exception { TestObject obj new TestObject(); obj.setMessage(Hello img srcx onerroralert(1)); mockMvc.perform(post(/api/test/json) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(obj))) .andExpect(status().isOk()) .andExpect(jsonPath($.message).value(Hello lt;img srcx onerroralert(1)gt;)); } }2. 测试SQL注入防御主要测试MyBatis的#{}是否能有效防御。可以尝试传入典型的注入Payload如 OR 11断言查询结果符合预期例如查不到数据或返回特定错误而不是返回所有数据。Test void testSqlInjectionDefense() { UserMapper userMapper ...; // 尝试注入 ListUser users userMapper.selectList(new QueryWrapperUser() .eq(username, admin --)); // 断言因为使用了#{}这个查询会寻找用户名为字面值 admin -- 的用户而不是注释掉后面部分。 // 所以结果应该为空如果没有这个用户的话 assertTrue(users.isEmpty()); // 测试正常查询 ListUser normalUsers userMapper.selectList(new QueryWrapperUser() .eq(username, admin)); // 正常查询应该能查到 assertFalse(normalUsers.isEmpty()); }6.2 使用DVWA/Pikachu等靶场进行手动渗透自检单元测试覆盖的是已知的、预期的攻击。要发现未知的漏洞需要进行手动安全测试。搭建本地靶场在本地部署一个DVWA或Pikachu靶场。不要在生产环境或连接真实数据库的环境做测试模拟攻击反射型XSS测试在搜索框、URL参数处输入scriptalert(document.cookie)/script观察弹窗是否出现。在我们的防御下应该看不到弹窗页面显示的是转义后的字符串。存储型XSS测试在留言板、个人信息等会保存到数据库的字段输入XSS Payload然后查看该内容的页面。同样应该看不到脚本执行。SQL注入测试在登录框输入admin OR 11密码随意。在我们的防御下这个输入会被当作完整的用户名去查询登录会失败。也可以使用SQLMap等工具对接口进行自动化探测观察其返回结果是否包含数据库错误信息理想情况下不应该有。审查日志检查我们SqlInjectionFilter中记录的预警日志看是否准确捕获到了测试流量。这有助于我们调整过滤规则的敏感度。6.3 常见问题排查与性能考量问题1过滤器导致POST请求的JSON Body丢失这是因为HttpServletRequest的输入流默认只能读取一次。我们的XssHttpServletRequestWrapper重写了getParameter等方法但未处理getInputStream()。对于JSON请求参数在body中需要通过流读取。我们需要同时重写getInputStream()方法将流内容缓存下来供多次读取。可以使用Spring提供的ContentCachingRequestWrapper作为基础进行改造。问题2全局转义影响了正常的文件上传或二进制流接口是的。我们的过滤器基于OncePerRequestFilter会对所有请求生效。对于multipart/form-data的文件上传请求其参数解析方式不同直接进行字符串转义会破坏文件二进制流。解决方案是在过滤器中根据Content-Type排除对文件上传请求的处理。Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String contentType request.getContentType(); if (contentType ! null contentType.toLowerCase().startsWith(multipart/)) { // 跳过文件上传请求 filterChain.doFilter(request, response); return; } // ... 原有的过滤逻辑 }问题3性能影响大吗添加过滤器、进行字符串转义和正则匹配肯定会有性能开销但在绝大多数Web应用中这个开销相对于网络I/O和数据库操作来说是微不足道的。可以通过以下方式优化在SqlInjectionFilter中对于已知的安全路径如静态资源路径/css/,/js/可以跳过检查。使用编译好的Pattern对象避免每次请求都编译正则表达式。对于转义操作可以考虑使用性能更好的库如org.unbescape:unbescape。问题4如何应对编码绕过攻击者可能会对Payload进行URL编码、HTML实体编码等来绕过简单的字符串匹配。例如script可能被编码为%3Cscript%3E或lt;scriptgt;。我们的防御策略在每一层都有所应对过滤器层HttpServletRequest会自动对URL参数进行一次解码。我们的过滤发生在解码之后。对于多重编码需要在过滤逻辑中加入解码步骤但需注意不要破坏合法数据。持久层参数化查询不关心你传入的数据是什么编码它只当作数据因此编码绕过对其无效。视图层Thymeleaf的th:text转义是在输出阶段无论输入是什么编码的最终输出时都会被转义成lt;。构建这样一个双重防御体系其价值不在于追求100%绝对的安全这不存在而在于通过层层设防将最常见的、自动化的攻击挡在门外同时为发现和响应更复杂的攻击赢得时间。在实际项目中这套体系需要与定期的安全扫描、依赖库漏洞检查以及团队成员的安全意识培训结合起来才能形成一个立体的、可持续的安全防护能力。从我个人的经验来看在项目初期就花时间搭建这样的基础安全框架远比在出事后再进行修补要划算得多它带来的是一种安心和专注业务开发的底气。