1. 项目概述:为什么后端开发者必须掌握SQL注入防御
在Java后端开发领域,尤其是涉及数据库操作的场景,SQL注入(SQL Injection)是一个老生常谈却又历久弥新的安全问题。无论是使用原生的MyBatis,还是其增强工具MyBatis-Plus,理解其如何从框架层面帮助我们规避SQL注入风险,是每一位合格开发者必须掌握的“内功”。这不仅仅是面试八股文里的一个考点,更是保障线上业务数据安全、避免重大生产事故的底线技能。
我见过太多因为不当的SQL拼接导致的数据库被拖库、用户信息泄露的案例。很多开发者,尤其是刚入行的朋友,可能会过度依赖框架,认为用了MyBatis或MyBatis-Plus就高枕无忧了。这种想法是危险的。框架提供了强大的防护机制,但如果你错误地使用它,防护墙就会形同虚设。本文将从原理层面拆解MyBatis和MyBatis-Plus的防注入机制,并结合大量实战代码和踩坑经验,让你不仅知道“怎么用”,更透彻理解“为什么这么用”,以及“在哪些边界情况下依然可能出问题”。无论你是正在准备面试,还是在日常开发中希望构建更健壮的系统,这篇内容都将提供直接的参考。
2. MyBatis 防止 SQL 注入的核心原理:预编译与参数化查询
要理解MyBatis如何防注入,首先得抛开框架,回到数据库访问的本质。Java操作数据库,最终都是通过JDBC驱动与数据库通信。而SQL注入发生的根本原因,在于将用户输入的数据与SQL语句的逻辑结构(指令)混淆在一起执行。
2.1 危险的字符串拼接:注入是如何发生的
假设我们有一个根据用户ID查询的简单需求。最原始、最危险的做法是字符串拼接:
String userId = request.getParameter("id"); // 用户输入,假设是 `1 OR 1=1` String sql = "SELECT * FROM user WHERE id = " + userId; Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(sql);当用户输入的id参数是1 OR 1=1时,最终执行的SQL变成了SELECT * FROM user WHERE id = 1 OR 1=1。WHERE条件永远为真,导致查询出所有用户数据,这就是一次典型的注入攻击。
为什么危险?因为数据库服务器将userId变量的值直接当成了SQL指令的一部分进行解析和执行。用户输入中的OR 1=1被解释为一个有效的逻辑表达式,改变了原SQL的语义。
2.2 JDBC的救赎:PreparedStatement 与参数化查询
JDBC提供了PreparedStatement接口来从根本上解决这个问题。其核心思想是预编译(Precompilation)和参数占位符。
String sql = "SELECT * FROM user WHERE id = ?"; // 使用 `?` 作为参数占位符 PreparedStatement pstmt = connection.prepareStatement(sql); // 预编译SQL模板 pstmt.setInt(1, Integer.parseInt(userId)); // 将参数值安全地设置进去 ResultSet rs = pstmt.executeQuery();这个过程分为两步:
- 预编译:数据库驱动先将SQL语句模板
SELECT * FROM user WHERE id = ?发送给数据库。数据库对其进行语法分析、编译和优化,生成一个执行计划。此时,SQL的结构(哪里是表名、哪里是列名、哪里是条件)已经固定,?只是一个等待填充的“空位”。 - 参数设置:通过
pstmt.setXxx()方法将具体的参数值传入。此时,无论传入的值是什么(即使是1 OR 1=1),数据库都会将其严格视为一个纯粹的字符串或数字值,而不会将其作为SQL语法进行解析。对于上面的例子,数据库会去查找id字段值等于**字符串“1 OR 1=1”**的记录,显然找不到,从而安全地返回空结果。
这就是参数化查询(Parameterized Query),它是防止SQL注入的黄金标准。MyBatis的防注入能力,正是建立在这一基石之上。
2.3 MyBatis 如何实现参数化查询
MyBatis在内部封装了JDBC操作。当你编写一个Mapper接口方法并配合XML或注解定义SQL时,MyBatis的核心工作就是帮你生成最终的PreparedStatement。
在XML映射文件中:
<select id="selectUserById" resultType="User"> SELECT * FROM user WHERE id = #{id} </select>这里的#{id}就是MyBatis的参数占位符。MyBatis在运行时,会将其转换为JDBC的?,并调用相应的setInt或setString方法安全地设置参数值。这是MyBatis默认的、安全的传参方式。
与之相对的危险方式是${}:
<select id="selectUserByOrder" resultType="User"> SELECT * FROM user ORDER BY ${orderByColumn} </select>${orderByColumn}是字符串替换。MyBatis在解析SQL时,会直接将传入的orderByColumn参数值替换到SQL字符串中,然后再发送给数据库。如果这个参数值来自不可信的用户输入,例如id; DROP TABLE user--,那么后果不堪设想。
关键心得:
#{}和${}的区别,是MyBatis面试和实战中最核心的安全考点。简单记:#{}是预编译参数(安全),${}是字符串拼接(危险,需谨慎)。${}仅在动态传入表名、列名等SQL本身的关键字,且这些关键字来自可信的、内部定义的枚举或常量时,才可考虑使用,并必须做严格的白名单校验。
3. MyBatis-Plus 在防注入上的增强与便利
MyBatis-Plus(简称MP)在MyBatis的基础上,提供了更强大的CRUD封装和条件构造器。它的防注入机制同样基于预编译,但在使用体验和安全性上做了更多封装。
3.1 条件构造器:自动化的安全查询构建
MP最常用的功能就是QueryWrapper或LambdaQueryWrapper。它们通过链式调用方法构建查询条件,底层会自动将这些条件转换为使用#{}的参数化SQL。
// 使用 QueryWrapper QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("name", userName) .gt("age", 18) .like("email", "@domain.com"); List<User> userList = userMapper.selectList(wrapper); // 使用 LambdaQueryWrapper (类型安全,推荐) LambdaQueryWrapper<User> lambdaWrapper = new LambdaQueryWrapper<>(); lambdaWrapper.eq(User::getName, userName) .gt(User::getAge, 18) .like(User::getEmail, "@domain.com"); List<User> userList = userMapper.selectList(lambdaWrapper);生成的SQL类似于:
SELECT * FROM user WHERE name = ? AND age > ? AND email LIKE ?参数userName,18,%@domain.com%都会被安全地设置为预编译参数。开发者几乎无需关心SQL拼接的细节,从而从根本上避免了手写SQL时可能出现的${}误用风险。
3.2 自定义SQL与条件构造器结合
有时我们需要编写复杂的自定义SQL,但查询条件又想用MP的Wrapper来安全地构建。MP提供了${ew.customSqlSegment}的用法。
<!-- 在Mapper XML中 --> <select id="selectUserPage" resultType="User"> SELECT u.*, d.dept_name FROM user u LEFT JOIN department d ON u.dept_id = d.id ${ew.customSqlSegment} <!-- 这里注入Wrapper生成的WHERE条件 --> </select>// 在Service中 LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getStatus, 1) .like(User::getName, "张") .orderByDesc(User::getCreateTime); IPage<User> page = new Page<>(1, 10); userMapper.selectUserPage(page, wrapper);这里的安全关键点:${ew.customSqlSegment}虽然使用了${}进行字符串替换,但替换进去的内容是Wrapper动态生成的条件表达式片段(如WHERE status = ? AND name LIKE ?),而其中的参数值(1,%张%)仍然是通过#{}预编译方式传递的。Wrapper内部已经确保了条件构建的安全性。
注意事项:
${ew.customSqlSegment}只能用于注入由MP的Wrapper生成的SQL片段。绝对不要用它来拼接用户输入的原始值。它的安全性依赖于Wrapper本身的正确使用。
3.3 插件机制与全局拦截
MP提供了丰富的插件接口,如PaginationInterceptor(分页插件)、TenantLineInnerInterceptor(多租户插件)等。这些插件在SQL执行前进行拦截和改写,其改写过程同样遵循参数化查询的原则。
例如,分页插件会自动将你的查询语句,改写成数据库方言对应的分页SQL(如LIMIT ?, ?),其中的分页参数(offset, limit)也是通过预编译设置的,从而避免了分页参数注入的风险。
4. 从实战场景看防注入的边界与深水区
掌握了基本原理,不代表在实际开发中就能万无一失。下面结合几个常见但易错的场景,分析防注入的边界。
4.1 场景一:IN查询的动态参数处理
这是一个高频需求:根据一个动态的ID列表查询用户。错误做法是直接在XML里拼接:
<!-- 危险! --> <select id="selectUsersByIds" resultType="User"> SELECT * FROM user WHERE id IN (${ids}) </select>如果ids是字符串"1,2,3) OR 1=1 --",注入就发生了。
安全方案一:MyBatis 动态 SQLforeach配合#{}
<select id="selectUsersByIds" resultType="User"> SELECT * FROM user WHERE id IN <foreach collection="list" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>MyBatis的foreach标签会遍历集合,为每个元素生成一个#{}占位符,最终SQL是WHERE id IN (?, ?, ?),安全。
安全方案二:MyBatis-Plus 的in方法
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.in(User::getId, idList); // idList 是 List<Long>这是最简单、最推荐的方式,MP会帮你安全地处理。
潜在问题与优化:当IN列表长度极大(比如上万),生成的SQL会包含大量占位符?,可能超出数据库限制或影响性能。此时,应考虑分批查询或使用临时表等方案。
4.2 场景二:动态排序与动态表名/列名
正如前文所述,ORDER BY、GROUP BY后面跟的列名,或者表名本身,如果需要动态传入,不能使用#{},因为#{}会给值加上引号(ORDER BY 'create_time'),导致语法错误。这时似乎只能使用${}。
安全实践:严格的白名单校验
// 定义一个允许排序的字段白名单 private static final Set<String> ALLOWED_ORDER_FIELDS = Set.of("create_time", "update_time", "name"); public String validateOrderField(String inputField) { if (!ALLOWED_ORDER_FIELDS.contains(inputField)) { // 默认使用一个安全字段,或者抛出业务异常 return "create_time"; } return inputField; } // 在Wrapper中使用 String safeOrderField = validateOrderField(userInputOrder); wrapper.orderBy(true, true, safeOrderField);在XML中使用${}前,也必须对传入的参数值进行同样的白名单过滤。永远不要将未经校验的用户输入直接用于${}替换。
4.3 场景三:Like查询的模糊匹配
模糊查询LIKE也需要特别注意,虽然用#{}是安全的,但参数格式要正确。
wrapper.like("name", "张"); // MP会自动在值两边加上 `%`,生成 `name LIKE '%张%'` wrapper.likeLeft("name", "张"); // `name LIKE '%张'` wrapper.likeRight("name", "张"); // `name LIKE '张%'`如果你需要自定义模式,比如查询中间包含特定字符,可以:
wrapper.apply("name LIKE CONCAT('%', #{pattern}, '%')", userInput);这里的#{pattern}仍然是预编译参数,CONCAT是数据库函数,整体是安全的。
一个经典陷阱:在XML中手动写LIKE时,错误的拼接方式。
<!-- 错误!`#{}`在引号内,会被当成普通字符串 --> <select id="selectLike" resultType="User"> SELECT * FROM user WHERE name LIKE '%#{keyword}%' </select> <!-- 正确做法1:使用`CONCAT`函数 --> <select id="selectLike" resultType="User"> SELECT * FROM user WHERE name LIKE CONCAT('%', #{keyword}, '%') </select> <!-- 正确做法2:在Java代码中拼接好模式(需注意数据库方言) -->String pattern = "%" + keyword + "%"; wrapper.like("name", pattern); // 推荐使用MP方法4.4 场景四:MyBatis注解开发中的陷阱
除了XML,MyBatis也支持注解开发。在注解中使用${}同样危险。
// 危险! @Select("SELECT * FROM user WHERE name = '${name}'") User findUserByName(@Param("name") String name); // 安全! @Select("SELECT * FROM user WHERE name = #{name}") User findUserByName(@Param("name") String name);注解开发时,由于SQL直接写在Java代码中,更容易因为疏忽而写出不安全的字符串拼接。务必坚持使用#{}。
5. 进阶:插件开发与SQL审计拦截
对于中大型项目,除了正确使用框架,我们还需要一道“安全审计”防线。可以开发一个MyBatis插件,拦截所有执行的SQL,检查其中是否包含不安全的${}使用(除了已知安全的场景如${ew.customSqlSegment})。
5.1 实现一个简单的SQL审计插件
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})}) public class SqlInjectionAuditInterceptor implements Interceptor { // 定义一些已知安全的、允许使用${}的片段模式(正则) private static final Set<Pattern> SAFE_PATTERNS = Set.of( Pattern.compile("\\$\\{ew\\.customSqlSegment\\}"), Pattern.compile("\\$\\{.*?wrapper.*?\\}") // 根据项目规范自定义 ); @Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler = (StatementHandler) invocation.getTarget(); BoundSql boundSql = handler.getBoundSql(); String sql = boundSql.getSql(); // 检查SQL中是否包含${} Pattern pattern = Pattern.compile("\\$\\{[^}]+\\}"); Matcher matcher = pattern.matcher(sql); while (matcher.find()) { String matched = matcher.group(); boolean isSafe = SAFE_PATTERNS.stream().anyMatch(p -> p.matcher(matched).matches()); if (!isSafe) { // 记录告警日志,甚至抛出异常阻断执行 log.warn("检测到潜在不安全的SQL字符串替换: {}, 完整SQL: {}", matched, sql); // 生产环境可以考虑抛出运行时异常,强制代码审查 // throw new RuntimeException("发现潜在SQL注入风险,请检查SQL语句: " + matched); } } return invocation.proceed(); } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { } }将这个插件配置到MyBatis的SqlSessionFactory中,它会在每次SQL预编译前进行检查,为你的项目增加一层运行时防护。
5.2 MyBatis-Plus 的 TenantLineInnerInterceptor 原理借鉴
MP的多租户插件是一个很好的安全设计范例。它通过拦截器,自动在所有涉及租户表的SQL的WHERE条件中追加tenant_id = ?条件。这个追加的过程,也是通过改写SQL语句,并安全地添加预编译参数实现的,而不是简单的字符串拼接。研究其源码(TenantLineInnerInterceptor类)可以加深对MyBatis插件机制和安全SQL构建的理解。
6. 常见问题排查与安全加固清单
即使理解了原理,在实际开发和排查问题时,仍可能遇到困惑。下面是一些常见问题的实录。
6.1 问题一:日志里看到的SQL有参数值,是不是被注入了?
现象:开启MyBatis的SQL日志后,看到控制台打印的SQL语句是带具体参数值的完整语句,例如SELECT * FROM user WHERE id = 1 AND name = 'test'。这让人担心:参数值是不是被直接拼接进SQL了?
分析与排查:这通常是日志框架(如Logback、Log4j2)配置了mybatis.configuration.log-impl为StdOutImpl或Slf4jImpl,并且日志级别为DEBUG。此时打印的“完整SQL”是驱动(如P6Spy)或MyBatis自身为了便于调试,在日志层面将预编译SQL和参数值合并后模拟输出的,并不是真正发送给数据库的SQL。真正发送给数据库的,仍然是带?的预编译语句和独立的参数包。
验证方法:开启数据库层面的通用日志(如MySQL的general_log),查看接收到的真实SQL语句,你会发现是带?的。所以,只要你的代码中正确使用了#{}或MP的条件构造器,就不用担心。
6.2 问题二:使用了#{},但程序还是报SQL语法错误?
可能原因1:参数类型不匹配。例如,数据库字段是datetime,但你传入了一个字符串#{createTime},而字符串内容格式不对。#{}只能防止注入,不能保证数据格式正确。解决方案是确保传入参数的类型与数据库字段类型兼容,或使用JSR-310的LocalDateTime等类型,由MyBatis-TypeHandler处理。
可能原因2:#{}出现在了SQL关键字位置。如ORDER BY #{field},这会导致ORDER BY 'create_time',语法错误。此处应使用${}+白名单校验,或使用MP的orderBy方法。
可能原因3:动态SQL标签使用错误。例如在<if>标签内,条件判断写成了test="name != null and name != ''#{name}'",这会造成解析混乱。应写为test="name != null and name != ''",参数传递在标签体内用#{name}。
6.3 问题三:MyBatis-Plus的update或delete方法如何防注入?
MP的updateById、deleteById等方法,其WHERE条件是基于主键的,主键值通过#{}传递,是安全的。而使用UpdateWrapper或LambdaUpdateWrapper进行更新时,其set和where条件构建机制与QueryWrapper类似,都是参数化安全的。
LambdaUpdateWrapper<User> updateWrapper = new LambdaUpdateWrapper<>(); updateWrapper.eq(User::getName, "旧名字") .set(User::getName, "新名字"); // `set`的值也是通过预编译参数传递 userMapper.update(null, updateWrapper);生成的SQL是UPDATE user SET name = ? WHERE name = ?,安全。
6.4 安全加固自查清单
在代码审查或项目上线前,可以对照以下清单进行快速自查:
| 检查项 | 安全做法 | 危险做法 |
|---|---|---|
| 基础传参 | 一律使用#{param} | 使用${param}传递业务数据值 |
| 动态表/列名 | 使用${}+严格白名单校验 | 直接使用用户输入拼接${columnName} |
| IN 查询 | 使用<foreach>或 MP的in()方法 | 使用IN (${ids})直接拼接字符串 |
| LIKE 查询 | 使用CONCAT('%', #{k}, '%')或 MP的like()方法 | 在SQL中写LIKE '%${k}%' |
| ORDER BY | 使用${}+白名单校验或 MP的orderBy()方法 | ORDER BY #{field}或ORDER BY ${userInput} |
| 注解开发 | @Select("... #{value} ...") | @Select("... '${value}' ...") |
| Wrapper使用 | 优先使用LambdaQueryWrapper | 在Wrapper的apply()方法中拼接不可信字符串 |
| 插件/拦截器 | 审查所有自定义插件,确保其SQL改写使用参数化 | 插件内直接拼接SQL字符串 |
7. 总结与个人实战体会
回顾整个MyBatis和MyBatis-Plus的防注入机制,其核心万变不离其宗:充分利用数据库的预编译(PreparedStatement)功能,严格区分SQL指令(结构)和用户数据(参数)。
在实际项目开发中,我的体会是:
- 框架不是银弹:MyBatis-Plus极大地提升了开发效率和安全基线,但它只是一个工具。最大的风险往往来自于开发者对工具的错误使用,比如为了图一时方便,在动态排序时直接拼接用户输入。建立团队内的代码规范和定期的安全代码评审至关重要。
- “默认安全”原则:在团队内推行“默认使用
#{}”和“默认使用LambdaQueryWrapper”的规范。只有当确实需要动态SQL关键字(表名、列名、排序字段)时,才允许使用${},并且必须配套一个显式的、可审查的白名单校验函数。这样能将安全风险控制在极小的、可见的范围内。 - 防御深度:不要只依赖框架一层。结合Web应用防火墙(WAF)对输入进行过滤,在DAO层进行参数校验,在数据库层面使用最小权限原则(应用数据库用户只拥有必要的CRUD权限,而非
DROP、ALTER等),形成纵深防御体系。 - 持续学习与测试:安全是一个持续的过程。可以定期使用SQL注入漏洞扫描工具(如SQLMap,仅用于授权测试自己的测试环境)对应用接口进行扫描。同时,在单元测试和集成测试中,加入针对边界情况和异常输入的测试用例,验证系统的健壮性。
最后,再分享一个排查SQL注入相关性能问题的小技巧:如果你发现某个使用IN查询或复杂动态条件的接口突然变慢,除了检查索引,别忘了去数据库查看慢查询日志。有时,不安全的SQL拼接或错误的动态SQL生成,可能会导致数据库无法有效使用索引(例如,对参数进行函数操作WHERE DATE(create_time) = ?),从而引发性能问题,而性能问题往往是安全问题的先兆或伴生现象。保持对SQL的敬畏之心,是后端开发者的重要素养。