Spring Boot安全实战:防范路由暴露、SQL注入与Thymeleaf SSTI三大核心漏洞
1. 项目概述:为什么JavaEE安全实战绕不开Spring Boot的这三大“坑”?
干了这么多年Java后端开发,我越来越觉得,安全这东西,真不是靠几篇“最佳实践”就能搞定的。尤其是现在Spring Boot一统江湖,开发效率是上去了,但很多兄弟在快速迭代业务时,往往把安全当成了“锦上添花”的选修课。结果呢?项目上线后,轻则数据泄露,重则服务瘫痪,回头一看,问题都出在一些最基础的组件上。今天,我就结合自己踩过的坑和救过的火,跟你聊聊Spring Boot项目里三个最常见也最容易被忽视的安全风险点:路由不当暴露、MyBatis动态SQL注入,以及Thymeleaf模板的SSTI(服务器端模板注入)。
这仨问题,几乎涵盖了从Web层到数据访问层,再到视图层的核心链路。路由问题可能导致内部接口、管理后台甚至Swagger文档被公网直接访问;MyBatis用不好,一个${}就可能让数据库门户大开;而Thymeleaf,这个看似人畜无害的模板引擎,配置不当就是SSTI的温床。很多面试官喜欢问MyBatis的#{}和${}区别,但实际开发中,滥用${}的情况依然屡见不鲜。至于Thymeleaf SSTI,很多团队甚至都没意识到它的存在。
这篇文章,我会从一个实战开发者的角度,带你亲手复现这些漏洞的成因,更重要的是,告诉你如何从架构设计、编码习惯到配置层面,系统地堵上这些漏洞。目标很明确:让你在享受Spring Boot开发便利的同时,也能构建出真正“抗揍”的应用。无论你是刚入门的新手,还是有一定经验的老鸟,相信这些从真实项目里总结出的“血泪教训”,都能给你带来实实在在的帮助。
2. 第一道防线:Spring Boot路由安全配置与常见隐患
路由,是请求进入我们应用的第一道关卡。Spring Boot的自动配置和约定大于配置的理念,在带来便利的同时,也埋下了一些安全隐患。很多开发者只关心@RequestMapping映射对不对,却忽略了整个路由体系的暴露面。
2.1 Actuator端点与Swagger API文档的暴露风险
Spring Boot Actuator是个好东西,它提供了监控、健康检查、指标收集等一系列生产级功能。但默认情况下,像/actuator/env(显示所有环境变量)、/actuator/heapdump(下载堆转储文件)这样的敏感端点,如果暴露在公网,无异于把服务器的“后门钥匙”挂在门口。
错误配置示例(application.yml):
management: endpoints: web: exposure: include: "*" # 危险!暴露所有端点 endpoint: health: show-details: always env: enabled: true上面这个配置,在开发环境图方便可能这么写,但一旦打包部署到生产环境,就是重大安全事故。攻击者可以通过/actuator/env直接获取数据库密码、API密钥等敏感信息;通过/actuator/heapdump分析内存数据,可能找到会话信息甚至加密密钥。
安全配置实践:
- 严格区分环境:在
application-prod.yml中,必须关闭或严格限制Actuator端点的访问。management: endpoints: web: exposure: include: "health,info,metrics" # 只暴露必要的、不敏感的端点 base-path: /internal/actuator # 修改默认路径,增加一点隐蔽性 endpoint: health: show-details: never # 生产环境不显示详情 shutdown: enabled: false # 务必禁用远程关闭功能 env: enabled: false heapdump: enabled: false - 结合Spring Security进行IP/角色限制:即使只暴露了
health,也最好通过安全框架限制访问来源。@Configuration public class ActuatorSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers("/internal/actuator/**").hasIpAddress("127.0.0.1") // 仅允许本地访问 .antMatchers("/internal/actuator/health", "/internal/actuator/info").permitAll() // 健康检查可公开 .anyRequest().authenticated() .and() .httpBasic(); // 使用HTTP Basic认证保护其他管理端点 } }
Swagger UI(/swagger-ui.html,/v2/api-docs等)也是同理。它虽然方便了前后端联调和测试,但公开的API文档相当于给攻击者提供了一份详尽的“攻击地图”。生产环境一定要通过配置springfox.swagger2.enabled=false或使用@Profile("dev")注解来禁用。
注意:仅仅通过前端隐藏或禁用Swagger的URL并不保险,因为相关的Jar包依然在类路径下。最彻底的方式是在生产环境的构建阶段,通过Maven/Gradle的Profile,将
springfox-swagger-ui等依赖的scope设置为provided或直接排除。
2.2 静态资源与未授权接口的访问控制
Spring Boot默认会将/static、/public、/resources、/META-INF/resources目录下的文件作为静态资源暴露。这可能导致你无意中将包含敏感信息的配置文件(如application-dev.yml备份)、SQL脚本或日志文件放在了这些目录下,从而被直接下载。
隐患场景:项目里有一个/public/docs目录,里面存放了数据库设计文档db_schema.md,其中包含了表结构说明和部分测试数据。开发者本意是内部查看,但忘记做访问控制,导致通过http://yourdomain.com/docs/db_schema.md即可直接访问。
解决方案:
- 自定义静态资源路径与规则:在
WebMvcConfigurer中精确控制。@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 明确指定可公开访问的静态资源路径 registry.addResourceHandler("/assets/**") .addResourceLocations("classpath:/static/assets/"); // 对于管理后台的静态资源,可以添加拦截器或通过Security控制 registry.addResourceHandler("/admin/**") .addResourceLocations("classpath:/static/admin/") .resourceChain(true) .addResolver(new PathResourceResolver() { @Override protected Resource getResource(String resourcePath, Resource location) throws IOException { // 这里可以添加自定义的校验逻辑 Resource requestedResource = location.createRelative(resourcePath); return requestedResource.exists() && requestedResource.isReadable() ? requestedResource : null; } }); } } - 警惕默认错误页面信息泄露:Spring Boot默认的
Whitelabel Error Page或某些自定义错误页面,可能会在异常堆栈中泄露内部路径、SQL语句片段或类名信息。务必在生产环境定制通用的错误页面,并配置server.error.include-stacktrace=never。
2.3 路由遍历(Path Traversal)与接口权限细粒度控制
即使接口需要认证,也可能存在水平越权或垂直越权问题。例如,用户只能访问自己的订单,但通过修改URL中的ID参数/api/orders/123为/api/orders/456,就能访问到别人的订单,这就是典型的IDOR(不安全的直接对象引用)漏洞。
防御之道在于“纵深检查”:
- 在Controller层进行基础权限校验(如
@PreAuthorize("hasRole('USER')"))。 - 在Service层进行业务逻辑级权限校验。这是最关键的一环,必须确保传入的业务ID与当前用户身份绑定。
@Service public class OrderService { public Order getOrderById(Long orderId, Long currentUserId) { Order order = orderMapper.selectById(orderId); if (order == null) { throw new ResourceNotFoundException("订单不存在"); } // 核心校验:订单是否属于当前用户 if (!order.getUserId().equals(currentUserId)) { throw new AccessDeniedException("无权访问此订单"); } return order; } } - 使用安全的随机标识符:避免使用自增ID作为资源标识,改用UUID或雪花算法生成的ID,能增加攻击者猜测的难度,但这不能替代权限校验。
路由安全是整体安全体系的基石,它要求我们对应用的每一个对外暴露的端点都保持警惕,遵循“最小权限原则”,默认拒绝,按需开放。
3. 数据层的“隐形炸弹”:MyBatis SQL注入原理与根治方案
说到MyBatis的安全,十个开发者有九个会立刻想到“#{}防注入,${}有风险”。道理都懂,但为什么项目中还是时不时能看到${}的身影?因为动态SQL的需求是真实存在的,比如动态表名、动态排序字段。而危险,往往就藏在“图省事”和“不理解原理”之中。
3.1#{}与${}的本质区别与底层机制
很多人把#{}理解为“防注入”,把${}理解为“字符串替换”,这个理解对了一半,但不够深刻。
#{}(参数占位符):MyBatis在处理#{}时,会将其转换为JDBC的PreparedStatement中的占位符?。数据库驱动会对其进行预编译,传入的参数在编译后被视作数据,而非SQL指令的一部分。即使参数中包含' OR '1'='1这样的SQL片段,也会被整体当作一个字符串值来处理,无法改变原SQL语句的结构。<!-- SQL最终被编译为:SELECT * FROM users WHERE username = ? --> <select id="findByUsername" resultType="User"> SELECT * FROM users WHERE username = #{username} </select>预编译机制是数据库层面提供的安全特性,从根本上避免了SQL注入。
${}(字符串替换):MyBatis在处理${}时,会直接在SQL编译阶段,将参数值以字符串的形式拼接到SQL语句中。这相当于在Java代码里做字符串拼接("SELECT * FROM users WHERE username = '" + username + "'")。<!-- 如果传入username为 `admin' -- `,SQL会变成: SELECT * FROM users WHERE username = 'admin' -- ' 注释掉了后面的单引号,可能造成注入 --> <select id="findByUsernameUnsafe" resultType="User"> SELECT * FROM users WHERE username = '${username}' </select>${}的风险不仅在于注入,还在于破坏SQL语法。如果参数值本身包含SQL关键字或特殊符号,很可能导致SQL语法错误,使服务不可用。
3.2 动态SQL中的高危场景与安全写法
需求:“根据用户选择的不同字段进行排序”。
危险写法:
<select id="findUsers" resultType="User"> SELECT * FROM users ORDER BY ${sortField} ${sortOrder} </select>攻击者可以传入sortField=id; DROP TABLE users --,后果不堪设想。即使不注入,传入一个不存在的字段名也会导致数据库错误。
安全方案一:白名单校验(推荐)在Java代码层面对传入的字段名进行严格校验。
@Service public class UserService { private static final Set<String> ALLOWED_SORT_FIELDS = Set.of("id", "username", "create_time"); public List<User> findUsers(String sortField, String sortOrder) { if (!ALLOWED_SORT_FIELDS.contains(sortField)) { sortField = "id"; // 或抛出异常 } if (!"asc".equalsIgnoreCase(sortOrder) && !"desc".equalsIgnoreCase(sortOrder)) { sortOrder = "asc"; } return userMapper.findUsers(sortField, sortOrder); } }<!-- Mapper中依然可以使用${},因为参数已经过清洗 --> <select id="findUsers" resultType="User"> SELECT * FROM users ORDER BY ${sortField} ${sortOrder} </select>安全方案二:使用MyBatis的<choose>或<if>标签进行静态枚举
<select id="findUsers" resultType="User"> SELECT * FROM users ORDER BY <choose> <when test="sortField == 'username'">username</when> <when test="sortField == 'createTime'">create_time</when> <otherwise>id</otherwise> </choose> ${sortOrder} <!-- sortOrder仍需校验,但风险已降低 --> </select>动态表名/列名场景:这是${}最常被“原谅”使用的场景。但安全原则不变:所有动态部分必须来源于可信的白名单,而非用户直接输入。通常,这些动态名称来自代码内部的枚举、配置项,或经过严格业务逻辑校验后的结果。
3.3 MyBatis插件(Interceptor)进行全局SQL防护与审计
除了在编码时注意,我们还可以通过MyBatis的插件机制,在运行时进行最后一层防护和审计。我们可以编写一个插件,拦截所有执行的SQL语句,检查其中是否包含非法的${}使用(针对非白名单场景),或者记录所有SQL日志用于事后审计。
示例:一个简单的SQL语句检查插件
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})}) @Component @Slf4j public class SqlInjectionInterceptor implements Interceptor { // 定义允许使用${}的动态字段白名单(可从配置中心读取) private static final Set<String> ALLOWED_DOLLAR_PLACEHOLDERS = Set.of("allowedTableName", "allowedColumnName"); @Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); MetaObject metaObject = SystemMetaObject.forObject(statementHandler); MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement"); // 获取BoundSql,里面包含了最终的SQL和参数 BoundSql boundSql = statementHandler.getBoundSql(); String sql = boundSql.getSql(); // 简单的检测逻辑:在实际项目中,这里需要更复杂的解析来判断${}的内容是否安全 // 此处仅为演示,检测SQL中是否包含明显的`${`且不在白名单内(这是一个复杂问题,此处简化) if (sql.contains("${") && !isSqlPlaceholderSafe(sql)) { log.warn("检测到可能不安全的SQL拼接,MappedStatement ID: {}", mappedStatement.getId()); log.warn("SQL: {}", sql); // 在生产环境中,这里可以选择抛出异常、告警或记录到安全审计日志 // throw new RuntimeException("检测到不安全的SQL语句,已阻断"); } // 记录审计日志(注意脱敏) log.info("执行SQL [ID:{}]: {}", mappedStatement.getId(), sql.replaceAll("\\s+", " ")); return invocation.proceed(); } private boolean isSqlPlaceholderSafe(String sql) { // 实现复杂的逻辑,解析出${}中的内容,并与白名单对比 // 此处省略具体实现,实际中可能需要使用SQL解析器 return false; // 默认返回false,表示需要人工审查 } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { } }这个插件会在每次SQL执行前被调用。请注意,在运行时精确判断一个${}是否安全极其困难,因此此插件的主要目的应是审计和告警,而非直接阻断。真正的安全防线,必须建立在开发阶段对${}的零容忍(白名单除外)和严格的代码审查上。
4. 视图层的“温柔陷阱”:Thymeleaf SSTI漏洞深度解析与防御
模板引擎让前后端分离更顺畅,但Thymeleaf的某些特性,如果被滥用,会导致服务器端模板注入(SSTI)。攻击者可以向模板中注入恶意表达式,当模板渲染时,这些表达式会在服务器端执行,可能导致远程代码执行(RCE),危害极大。
4.1 Thymeleaf SSTI是如何发生的?
Thymeleaf的表达式语言(SpringEL、OGNL等)功能强大,可以访问Spring上下文中的Bean、调用方法。漏洞产生的根本原因是:将用户可控的数据,未经任何处理就直接拼接进模板表达式,并交给了th:*属性去解析执行。
一个经典的错误案例:
@Controller public class UnsafeController { @GetMapping("/greet") public String greet(@RequestParam String name, Model model) { // 致命错误:将用户输入的name直接放入模板片段 String templateFragment = "<p th:text=\"'Hello, ' + ${" + name + "} + '!'\"></p>"; model.addAttribute("fragment", templateFragment); return "page"; } }在page.html中:
<div th:utext="${fragment}"></div> <!-- th:utext 会解析HTML和Thymeleaf表达式 -->如果攻击者访问/greet?name=__${T(java.lang.Runtime).getRuntime().exec('calc')}__,那么templateFragment就会变成:<p th:text="'Hello, ' + ${__${T(java.lang.Runtime).getRuntime().exec('calc')}__} + '!'"></p>Thymeleaf在渲染时,会先解析外层的${...},发现里面嵌套了表达式T(java.lang.Runtime).getRuntime().exec('calc'),从而执行系统命令。
4.2 高危方法:th:utext、th:inline与模板片段拼接
th:utext:这是SSTI的重灾区。它会解析传入字符串中的Thymeleaf表达式和HTML标签。任何用户输入如果直接通过th:utext渲染,都极其危险。th:inline:th:inline="javascript"允许在JavaScript块中内联Thymeleaf表达式。如果表达式内容用户可控,同样存在风险。- 动态模板名/片段名:使用用户输入来动态决定渲染哪个模板文件(
return username + "/dashboard")或片段(th:replace="${userTemplate}"),可能导致路径遍历或渲染恶意模板。
4.3 铁律:输入净化、输出转义与安全配置
1. 绝对禁止用户输入参与模板表达式拼接这是根本原则。像上面例子中的String templateFragment = "<p th:text=\"'Hello, ' + ${" + name + "} + '!'\"></p>";这种代码,在任何情况下都不应该出现。
2. 正确使用上下文变量传递数据安全做法是将用户输入作为数据(字符串)传递给模板,由模板引擎自己来安全地渲染。
@Controller public class SafeController { @GetMapping("/greet") public String greet(@RequestParam String name, Model model) { // 将name作为普通字符串属性放入模型 model.addAttribute("username", name); // name可能是"<script>alert(1)</script>" return "safePage"; } }在safePage.html中:
<!-- th:text 会自动进行HTML转义,输出纯文本 --> <p th:text="'Hello, ' + ${username}"></p> <!-- 最终渲染为:Hello, <script>alert(1)</script> --> <!-- 如果确实需要显示为HTML(且内容可信),使用th:utext,但前提是username必须经过净化 --> <p th:utext="${sanitizedHtmlContent}"></p>3. 对必须渲染为HTML的内容进行严格净化如果业务确实需要将用户输入的富文本(如博客内容)渲染为HTML,必须使用专业的HTML净化库,如OWASP Java HTML Sanitizer,而不是简单的转义。
import org.owasp.html.PolicyFactory; import org.owasp.html.Sanitizers; @Service public class ContentService { private static final PolicyFactory HTML_SANITIZER = Sanitizers.FORMATTING .and(Sanitizers.LINKS) .and(Sanitizers.IMAGES) .and(Sanitizers.BLOCKS); public String sanitizeUserHtml(String rawHtml) { if (rawHtml == null) return ""; // 只允许特定的标签和属性,过滤掉script、onerror等危险内容 return HTML_SANITIZER.sanitize(rawHtml); } }在Controller中:
model.addAttribute("postContent", contentService.sanitizeUserHtml(rawContent));在模板中:
<div th:utext="${postContent}"></div> <!-- 此时使用utext才是相对安全的 -->4. 限制Thymeleaf的表达式解析能力在Spring Boot配置中,可以限制Thymeleaf的模板解析模式,但这不是银弹,核心还是代码安全。
spring: thymeleaf: mode: HTML # 使用严格的HTML模式,而非过于宽松的LEGACYHTML5等 # 启用缓存(生产环境必须开启),也能一定程度上增加攻击复杂度 cache: true5. 代码审计与依赖检查定期使用SAST(静态应用安全测试)工具扫描代码,查找th:utext、th:inline、字符串拼接+与${组合等危险模式。同时,保持Thymeleaf及相关依赖库的版本更新,及时修复已知安全漏洞。
Thymeleaf SSTI的防御,总结起来就是一句话:永远将用户输入视为数据,而非代码。让数据和视图逻辑清晰地分离,是杜绝这类漏洞的关键。
5. 构建纵深防御体系:从编码到部署的全局安全实践
单点防御是脆弱的。一个健壮的Spring Boot应用安全体系,需要将上述点状的安全措施串联起来,形成从编码规范、依赖管理、运行时防护到安全监控的纵深防御。
5.1 安全编码规范与自动化检查
1. 制定并强制执行团队安全编码规范
- MyBatis规约:明确禁止在XML中使用
${},除非是经过严格白名单校验的动态表名/列名场景,并要求在代码审查中重点检查。 - Thymeleaf规约:禁止在Java代码中拼接模板字符串;明确
th:utext的使用场景,必须搭配HTML净化。 - API设计规约:所有API接口默认需要认证;接口权限注解(
@PreAuthorize)必须与业务逻辑校验同时存在。
2. 集成SAST工具到CI/CD流程在代码提交或合并时,自动进行安全扫描。可以使用SonarQube(配合FindSecBugs插件)、SpotBugs等工具。
- 示例:在Maven中集成SpotBugs
配置相应的规则,让其能够检测出潜在的SQL拼接(通过字符串模式匹配)和不安全的反射调用等。<build> <plugins> <plugin> <groupId>com.github.spotbugs</groupId> <artifactId>spotbugs-maven-plugin</artifactId> <version>4.7.3.0</version> <configuration> <effort>Max</effort> <threshold>Low</threshold> <!-- 设置低阈值以发现更多问题 --> <xmlOutput>true</xmlOutput> </configuration> <executions> <execution> <goals> <goal>check</goal> <!-- check目标会在发现BUG时使构建失败 --> </goals> </execution> </executions> </plugin> </plugins> </build>
5.2 依赖管理与漏洞扫描
Spring Boot项目依赖众多,一个底层库的漏洞就可能危及整个应用。
- 使用Maven Enforcer插件:禁止引入存在已知严重漏洞的依赖版本。
- 集成OWASP Dependency-Check:在构建时自动分析项目依赖,生成漏洞报告。
<plugin> <groupId>org.owasp</groupId> <artifactId>dependency-check-maven</artifactId> <version>8.4.2</version> <executions> <execution> <goals> <goal>check</goal> </goals> </execution> </executions> <configuration> <failBuildOnCVSS>7</failBuildOnCVSS> <!-- CVSS评分大于7的漏洞会导致构建失败 --> <suppressionFile>dependency-check-suppressions.xml</suppressionFile> </configuration> </plugin> - 定期更新依赖:制定计划,定期将Spring Boot、MyBatis、Thymeleaf等核心依赖升级到安全版本。
5.3 运行时的WAF与安全头注入
即使应用代码没有漏洞,也需要防范通用的Web攻击(如大规模扫描、CC攻击、未知的0day漏洞利用尝试)。
- 部署Web应用防火墙(WAF):在应用前端(如Nginx)或云服务商层面配置WAF,可以拦截常见的SQL注入、XSS、路径遍历等攻击payload。
- 利用Spring Security配置安全响应头:增加攻击难度。
@Configuration public class SecurityHeaderConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .headers() .contentSecurityPolicy("default-src 'self'; script-src 'self' https://trusted.cdn.com;") // 防止XSS .and() .httpStrictTransportSecurity() // 强制HTTPS .includeSubDomains(true) .maxAgeInSeconds(31536000) .and() .frameOptions().sameOrigin() // 防止点击劫持 .and() .xssProtection().block(true); // 启用浏览器XSS过滤 } }
5.4 日志审计与入侵检测
完善的日志是事后追溯和发现异常行为的唯一依据。
- 结构化日志记录:使用Logback或Log4j2,以JSON格式输出日志,方便接入ELK等日志分析系统。关键日志点包括:
- 用户登录(成功/失败)。
- 敏感操作(数据删除、权限变更)。
- 所有SQL执行(经过脱敏处理)。
- 访问所有受保护的管理端点(如Actuator)。
- 定义告警规则:在日志平台设置规则,例如:
- 同一IP短时间内大量登录失败。
- 出现包含
union select、<script>、${等关键字的请求(需注意误报)。 - 访问了不存在的敏感路径(如
/admin,/actuator/heapdump)。
安全是一个持续的过程,而不是一次性的任务。从写下第一行代码时对${}的警惕,到部署前对依赖的扫描,再到运行中对异常流量的监控,每一个环节的疏忽都可能成为突破口。把这些实践融入到日常开发和运维习惯中,才能让你的Spring Boot应用在复杂的网络环境中真正地稳如磐石。