Spring Boot 项目跑了大半年,业务倒是稳得很,直到某天安全扫描报告甩到眼前——SQL注入、敏感信息明文传输、越权访问,一个个红字标得刺眼。说是"修复漏洞",其实背后牵扯出的是一整套安全检查项:接口设计、鉴权模型、依赖版本、甚至运维部署习惯都得重新过一遍。这篇博文把我在Spring Boot后端开发里做安全漏洞修复的全过程梳理出来,从漏洞分类、原理拆解到具体修法、碰到过的翻车现场,一次性讲透。不管你是刚接手老项目的新手,还是要给现有系统做安全加固的负责人,照着这套思路走一遍,心里基本就有底了。
1. 安全漏洞修复的整体思路:先盘点再动手
1.1 后端项目里最常见的五类安全漏洞
我在实际工作中接触过的Spring Boot项目,不管是企业内部管理系统还是对外开放的API服务,安全漏洞基本集中在下面几类:参数注入类(SQL注入、命令注入、SpEL注入)、跨站脚本(XSS)、请求伪造与跨域问题(CSRF、CORS配置不当)、越权访问(水平越权、垂直越权)、敏感信息泄露(硬编码密钥、明文传输、日志泄漏)。这几类漏洞占了日常修复工作量的八成以上。
其中SQL注入是"老牌经典",尤其常见于使用MyBatis的项目里。很多团队习惯用${}做字符串拼接,一旦参数被外部控制,后果直接就是拖库。XSS则容易被当成"前端的事",但后端如果不做输出编码和过滤,攻击者照样可以通过接口把恶意脚本喂给其他用户。越权问题更隐蔽,接口明明鉴权了,但数据归属校验没做,A用户传个B用户的订单ID就能查走别人的数据。
1.2 修复优先级怎么排:风险和成本怎么权衡
安全漏洞修起来往往牵一发动全身,所以不能拿到报告就乱改。我的习惯是先按两个维度做评估:危害程度和修复成本。危害程度看的是攻击者利用这个漏洞能拿到什么——是能拖库、能控制服务器,还是只能偷看点不太重要的数据。修复成本看的是要改多少代码、会不会影响现有业务逻辑、需不需要上线窗口。
按这个评估逻辑,SQL注入和硬编码密钥这类问题需要立即处理,因为攻击路径清晰、自动化工具一打就中。越权问题视业务重要性决定优先级,涉及订单、支付、用户隐私数据的接口必须优先修。XSS和CSRF则根据系统使用场景灵活安排,如果是内部后台系统,紧迫性可以适当降低;如果是面向C端的公开站点,就得在最短时间内修复上线。
我建议任何团队都先把漏洞清单拉出来,逐个打标分类,再排修复计划。不要一股脑地改代码,更不要抱着"反正没被攻击就不用管"的心态。安全这件事,永远是亡羊补牢的成本远高于未雨绸缪。
2. 核心漏洞修复实操:从注入到越权
2.1 SQL注入:MyBatis场景下最容易翻车的三个点
MyBatis项目里的SQL注入,绝大多数不是出在大段XML映射文件上,而是出在三个不起眼的地方。第一个是动态排序字段,XML里写ORDER BY ${sortField},前后端把排序字段名当参数传进来,直接拼进SQL。第二个是模糊查询的拼接写法,有些老代码会写WHERE name LIKE '%${keyword}%',这属于最粗暴的注入点。第三个是in语句和动态表名,IN (${ids})看着方便,一旦ids里有恶意内容就直接翻车。
MyBatis的#{}为什么安全?因为它底层走的是PreparedStatement的占位符机制,参数值由驱动转义处理,不参与SQL语句结构组装。而${}是纯字符串替换,参数内容原样拼进SQL语句,等于把语法结构控制权交给了调用方。很多刚入门的同学不清楚这个区别,反正能用就一直用${},最后扫描报告一片红。
修起来也不复杂。排序字段和白名单映射绑定,前端传什么先经过一层字典转换,匹配不到就返回默认值。模糊查询统一改#{},或者用concat('%', #{keyword}, '%')。in语句改成遍历生成占位符,比如:
List<Long> ids = Arrays.asList(1L, 2L, 3L); StringBuilder placeholders = new StringBuilder(); for (int i = 0; i < ids.size(); i++) { placeholders.append("#{idList[").append(i).append("]}"); if (i < ids.size() - 1) { placeholders.append(","); } } String sql = "SELECT * FROM orders WHERE id IN (" + placeholders + ")";这里占位符的编号是动态拼出来的,但值本身全部走PreparedStatement,攻击者无论传什么内容都只会被当参数处理。动态表名建议直接做白名单枚举,从业务层面限制候选值,而不是把用户输入拼进表名。
2.2 XSS防护:后端不能只靠前端过滤
XSS在我接触的项目里有个明显的认知误区。很多后端同学认为前端框架(Vue、React)自带转义就够了,后端只要管好接口数据。但现实是:微信内置浏览器的老内核、部分App的WebView、甚至前端工程师自己用v-html注入的地方,都可能把后端返回的字符串当成HTML直接渲染。后端不做输出编码,等于把所有防御压在一条不可控的线上。
后端的XSS修复要从两个维度做。第一是输入侧过滤,对用户提交的内容做白名单字符校验,把<script>、javascript:、data:text/html这些危险模式直接拦掉。第二是输出侧编码,接口返回非富文本字段时,把HTML敏感字符转义成实体(<变<)。Spring Boot项目里可以在全局JSON序列化层配置HtmlMapper,或者给字段加@JsonSerialize注解统一处理。
富文本内容比较特殊,直接转义会把正常排版破坏掉。我的做法是引入Jsoup做白名单清洗,只保留p、span、img、a这类安全标签,并且强制校验href属性必须以http或https开头。还可以在响应头里加X-Content-Type-Options: nosniff和Content-Security-Policy,双保险。注意一点,攻击者经常利用Unicode全角字符、HTML数字实体做变种绕过,所以过滤规则一定要覆盖解码后的内容。
2.3 CSRF与请求伪造:接口幂等和来源校验
CSRF的修复要看系统的交互场景。传统后端渲染页面的项目,表单提交天然需要CSRF Token机制来防跨站请求伪造。前后端分离的项目反而容易翻车在CORS配置上——很多团队为了开发方便,直接allowedOrigins("*")加allowCredentials(true),这等于把站点的所有接口暴露给任意第三方网页调用。
Spring Boot 3.x里使用Spring Security 6.x,配置方式已经和旧版不一样了。CSRF防护默认是开启的,但在无状态Token鉴权的API服务里通常要显式关闭,否则前端每次请求都得带Token,反而容易被绕过。我建议的做法是:内部后台管理系统开启CSRF防护,使用CookieCsrfTokenRepository并配合前端读取X-XSRF-TOKEN头;对外开放的纯API服务则关掉CSRF,但必须在跨域配置上做严格限制。
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .ignoringRequestMatchers("/api/public/**")) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .build(); }跨域这块,生产环境严格设置允许的来源域名列表,不要用通配符。还要注意HTTP方法和自定义头部的校验,比如限制allowedMethods只开放实际用到的GET、POST、PUT、DELETE,多余的OPTIONS预检请求行为会影响安全策略,但也不能粗暴禁止。
2.4 越权漏洞:数据归属校验比接口鉴权更关键
越权分两种。水平越权是同级用户之间的越限访问,A用户通过改ID查B用户数据;垂直越权是低权限用户调用高权限接口,比如普通员工后台给自己加个管理员角色。Spring Boot项目里Spring Security做得再完善,解决的也只是"你有没有登录、角色是什么"的问题,而"你能访问谁的资源"必须靠业务层的对象归属校验。
水平越权的典型修复方案是引入数据权限维度。查询订单详情时,不仅校验登录状态,还要校验订单的userId是否等于当前登录用户的ID。常见的做法是在BaseEntity里设计tenantId或userId字段,所有查询SQL强制追加归属条件。这里有一个实战细节:MyBatis的拦截器可以在执行前自动拼接数据权限SQL,避免每个Mapper人工改一遍。拦截器里解析@DataScope注解,动态注入当前用户的权限范围条件,既省事又不会漏改。
垂直越权主要靠服务端接口的权限注解兜底。Spring Security配合@PreAuthorize可以精确到方法的角色控制,比如:
@PreAuthorize("hasRole('ADMIN')") public void deleteUser(Long userId) { // 仅管理员可调用 }但注解仅仅是第一道门,我见过太多因为配置漏了@EnableMethodSecurity导致注解失效的情况。更保险的做法是做一个权限切面,扫描接口路径和HTTP方法,匹配当前用户角色是否在允许列表里,双保险才不容易被绕过。另外,接口返回列表数据时也要做越权过滤,不能只在单条查询上做校验,否则攻击者用遍历方式批量捞数据照样出事。
3. 工程化防护配置:靠框架不如靠规范
3.1 Spring Security集成与配置要点
Spring Boot项目接Spring Security并不难,难的是配置方法和版本适配。Spring Boot 3.x的Security配置已经从WebSecurityConfigurerAdapter改成基于SecurityFilterChain的Lambda风格写法,很多网上旧教程还在用WebSecurityConfigurerAdapter,直接编译都过不了。新写法核心是定义一个SecurityFilterChainBean,在Lambda里配置各种规则。
配置过程中最容易踩的坑有三个。第一是静态资源放行配置,比如Swagger UI、上传文件目录,处理不当会出现"接口能访问但页面被拦截"的诡异现象。第二是登录接口的放行路径和Token生成时机没对齐,导致登录成功后拿不到Token。第三是过滤器顺序问题,自己加的自定义Filter如果注册在Spring Security过滤器链之前,就绕过了认证逻辑,等于给攻击者开了一扇门。
我实际项目里的做法:认证走LoginFilter解析JWT Token,校验通过后把用户信息塞进SecurityContext。同时配置ExceptionTranslationFilter的统一异常处理,保证Token过期、签名错误、权限不足分别返回401和403。还要注意一处细节——Spring Security的默认登录页和默认用户(user+ 随机密码)只在未配置时生效,一旦你自定义了UserDetailsService,默认配置就失效了,千万别以为集成完了就能直接用。
3.2 敏感信息保护:密钥、数据库密码和日志泄漏
我见过不少代码把数据库密码、Redis密码、第三方接口密钥直接写在application.yml里,还堂而皇之地提交到Git仓库。哪怕仓库是私有的,只要员工离职、代码备份外泄,这些敏感信息就全暴露了。修复这个问题,不是简单把明文改成环境变量就行,而是要做一套配置管理规范。
基于常见实践,我推荐组合方案:本地开发用.env文件加spring.profiles.active=dev区分环境,生产环境密钥通过K8s Secret或云厂商的密钥管理服务注入,Spring Boot侧用@Value读取环境变量。需要加密时,可以采用Jasypt对配置文件中的敏感字段做加密处理,运行环境设置解密密钥。这里有个教训:Jasypt加密后的密文在日志里是不能打印的,否则等于白加密。
日志泄漏是容易被忽略的盲区。用户手机号、身份证号、银行卡号一旦打进日志,安全扫描就抓个正着。修复方法包括:统一Logback配置里增加脱敏规则,对mobile、idCard、password等字段做掩码输出;接口返回值用@JsonIgnore或@JsonProperty(access = WRITE_ONLY)控制敏感字段不回传;全局异常处理时不要把SQL异常堆栈直接返回给前端。
3.3 依赖漏洞排查:版本升级的正确姿势
Spring Boot的依赖数量庞大,任何一个间接依赖存在已知漏洞,都会波及整个项目。扫描报告里经常出现的CVE编号,比如Log4j2的远程代码执行、Spring框架的某些序列化漏洞,往往靠升级版本就能解掉。Maven项目可以用OWASP Dependency-Check插件做依赖漏洞扫描,在pom.xml里配置好,集成到CI流水线里每次构建自动跑。
<plugin> <groupId>org.owasp</groupId> <artifactId>dependency-check-maven</artifactId> <version>8.4.3</version> <executions> <execution> <goals> <goal>check</goal> </goals> </execution> </executions> </plugin>执行mvn verify时如果扫描到高危漏洞,构建会直接失败,报告会生成在当前模块的target目录。这种方式能强制团队在合入代码前就解决漏洞,而不是等安全报告下来再补救。版本升级不能无脑升到最新版,要注意Spring Boot 2.3.x到2.6.x,再到Spring Boot 3.x,中间涉及javax包名切换为jakarta、Spring Security配置废弃等重大变更。升级前务必看官方迁移指南,我经历过一次大版本升级,因为忽略了javax.servlet改成jakarta.servlet,整个项目编译都过不了。
4. 常见问题与排查技巧实录
4.1 修复漏洞引发的经典翻车现场
修代码不可怕,怕的是修复漏洞把功能修挂了。说起翻车现场,我第一个想到的就是在系统里引入Spring Security后,前端所有请求突然返回401。排查半天发现是前端请求没带Token,而所有接口都被拦截器拦了。白名单里加/api/v1/login和/api/v1/refresh后发现还是不行,最后定位到是自定义Filter里对Authorization头的解析逻辑跟Security的过滤器链顺序冲突了。调整Filter注册顺序后问题解决。
第二个经典翻车是SQL注入修复改成#{}后,动态in查询直接报语法错误。原因是我在MyBatis的XML里写了IN (#{ids}),传过来的是逗号拼接的字符串,结果整个in子句的内容被当成一个完整的参数值了。正确姿势还是上面说的遍历生成占位符,或者用<foreach>标签处理集合参数。
第三个很容易踩坑的是越权修复加数据权限条件后,后台管理端的超级管理员查不到任何数据。因为拦截器里默认附加了userId = 当前用户的条件,但管理员查看所有订单是正常功能,被强制加白反倒把业务逻辑打破了。正确的做法是拦截器里区分角色:超管不加数据权限条件,普通用户才加归属过滤。
4.2 一套可落地的漏洞自查清单
每次安全修复完毕,我都会照着自查清单过一遍,防止漏项和回归。清单仅供参考,实际使用可根据团队技术栈和业务特点调整:
| 检查项 | 具体检查点 | 验证方式 |
|---|---|---|
| SQL注入 | 所有SQL是否使用#{}而非${},动态排序是否白名单化 | 代码审计 + 手工注入尝试 |
| XSS | 用户输入是否过滤,接口输出是否转义,富文本是否白名单 | 输入<script>字符串验证回显 |
| CSRF/CORS | 跨域来源是否严格配置,CSRF Token是否有效 | 浏览器控制台模拟跨域请求 |
| 越权访问 | 数据归属ID是否校验,角色权限注解是否生效 | 用两个账号互相访问对方资源 |
| 敏感信息 | 密钥是否环境变量化,日志是否脱敏,密码是否加密存储 | 扫描日志和配置文件 |
| 依赖漏洞 | 是否跑过Dependency-Check,存在高危CVE是否升级 | Maven插件扫描结果 |
另外还要检查几件事:上传文件类型是否做校验(防止上传恶意JSP或木马文件)、接口是否做限流防刷(防止暴力破解)、HTTPS证书是否过期(明文传输等于裸奔)。这些虽然不直接归在漏洞类别里,但安全扫描同样会盯上。
4.3 排查工具和辅助手段的取舍
工具不是越多越好,关键是能融入开发流程。SonarQube做静态代码扫描,能抓出SQL注入、XSS等常见代码模式,适合集成到CI里每次提交自动跑。SpotBugs补充检查字节码层面的一些问题,比如无效的空值判断、反射调用漏洞。运行时的安全监控我建议用Spring Boot Admin辅助观察端点健康状态,它本身不直接防攻击,但可以发现异常请求模式——比如同一IP高频访问敏感接口,这时候就该考虑加请求频率限制(Rate Limiting)了。
排查时有个容易忽略的点:安全日志必须单独落盘保存,不能和应用日志混在一起。安全日志要记录登录失败尝试、越权访问拦截、Token校验失败等关键事件,方便事后回溯攻击路径。日志内容注意别把请求参数里的密码和Token打进去,否则日志文件泄露等于二次出事。
5. 写在最后:安全修复没有"一次到位"
我始终觉得,安全漏洞修复不是做完一次扫描就结束的工作,更像是在持续维护的安全卫生习惯。但这篇博文的核心,是希望你能抓住几个关键动作:把SQL注入的写法规范卡死、把越权校验的规则补齐、把敏感信息的存储方式升级、把依赖漏洞的扫描挂在CI上。只要这四件事落地,项目被常见漏洞打穿的概率已经降了九成。
最后再分享一个小技巧:每次修复完漏洞,把修复方案、绕过尝试方式、回归测试结果记录成文档,等积累多了一份,你就能提炼出团队自己的安全编码规范。规范这种东西,正式场合喊再多遍都没用,从真实漏洞里总结出来的才是团队真正会去遵守的底线。