MyBatis-Plus的条件构造器(Wrapper)本身就是为了防止SQL注入而设计的核心工具。它通过预编译参数绑定的方式,将用户输入的值作为参数传递而非直接拼接到SQL语句中,从根本上杜绝了SQL注入的风险。具体来说,使用QueryWrapper、LambdaQueryWrapper、UpdateWrapper等条件构造器时,所有传入的字段值都会以占位符(?)的形式出现在最终SQL中,数据库驱动会对这些参数进行安全转义处理。但需要注意的是,如果你在使用Wrapper时仍然手动拼接SQL片段(比如用apply方法写原生SQL),那防注入的保障就会被打破。所以核心结论是:用Wrapper的标准API方法是安全的,但任何绕过Wrapper直接拼接字符串的操作都可能引入注入漏洞。
一、为什么MyBatis-Plus的Wrapper能防SQL注入
要理解Wrapper为什么安全,先要明白SQL注入是怎么发生的。SQL注入的本质是攻击者把恶意SQL代码混在用户输入里,然后被程序直接拼进SQL语句执行。比如用户输入"1 OR 1=1",如果你直接把这个值拼到WHERE条件里,就会变成"WHERE id = 1 OR 1=1",整个表的数据都会被查出来。
MyBatis-Plus的Wrapper采用的是参数化查询机制。当你写如下代码时:
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.eq("username", userInput);
List<User> users = userMapper.selectList(wrapper);最终生成的SQL是这样的:
SELECT * FROM user WHERE username = ?
这里的问号是参数占位符,userInput的值不会直接出现在SQL文本里,而是作为参数单独传给数据库驱动。数据库驱动会对这个参数做严格的类型检查和转义,任何特殊字符都会被当作普通字符串处理,不会被解析成SQL语法。这就是预编译的力量,也是Wrapper防注入的底层原理。
二、Wrapper的各类条件方法及其安全性详解
MyBatis-Plus提供了非常丰富的条件构造方法,几乎覆盖了所有常见的查询场景,而且每一个方法都是参数化的、安全的。下面逐一说明:
1. 等值条件(eq / ne)
eq方法用于等值查询,ne用于不等值查询。这两个方法都是安全的,因为传入的值会被绑定为参数。
wrapper.eq("status", 1);
wrapper.ne("deleted", 0);生成SQL:WHERE status = ? AND deleted <> ?
2. 范围条件(between / notBetween / gt / ge / lt / le)
这些方法处理范围和比较运算,同样是参数化的。需要注意的是,between和notBetween接受两个参数,都会被安全绑定。
wrapper.between("age", 18, 30);
wrapper.gt("create_time", startDate);
wrapper.le("score", 100);生成SQL:WHERE age BETWEEN ? AND ? AND create_time > ? AND score <= ?
3. 模糊查询(like / notLike / leftLike / rightLike)
模糊查询是开发中最常用的功能之一,也是最容易被误用导致注入的地方。MyBatis-Plus的like系列方法会自动在值的前后加上通配符,并且整个值作为参数绑定。
wrapper.like("name", keyword);
wrapper.notLike("email", "test");
wrapper.leftLike("phone", "138");生成SQL:WHERE name LIKE ?(参数为"%keyword%"),注意通配符是在参数层面拼接的,不是在SQL层面拼接的,所以即使keyword里包含单引号、分号等特殊字符也不会造成注入。
4. 空值判断(isNull / isNotNull)
wrapper.isNull("email");
wrapper.isNotNull("phone");生成SQL:WHERE email IS NULL AND phone IS NOT NULL。这类方法不涉及用户输入值,本身就是安全的。
5. 排序(orderByAsc / orderByDesc)
排序方法需要特别注意。如果排序字段是用户可控的,直接传入用户输入会有风险。但Wrapper的orderBy方法本身是参数化的,只是它绑定的是字段名而非字段值。如果字段名来自用户输入,就需要自己做白名单校验。
// 安全做法:字段名做白名单
String sortField = "create_time";
if ("create_time".equals(sortField) || "name".equals(sortField)) {
wrapper.orderByDesc(sortField);
}6. IN查询(in / notIn)
wrapper.in("id", idList);
wrapper.notIn("status", Arrays.asList(0, 1));生成SQL:WHERE id IN (?, ?, ?)。集合中的每个元素都作为独立参数绑定,完全安全。
三、LambdaQueryWrapper:类型安全的防注入利器
LambdaQueryWrapper是MyBatis-Plus 3.x之后引入的基于Lambda表达式的条件构造器。它的最大优势是用实体类的方法引用代替字符串字段名,编译期就能检查字段是否存在,避免了手写字符串拼错字段名的问题。
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, userInput)
.like(User::getEmail, keyword)
.ge(User::getAge, 18);从防注入角度看,LambdaQueryWrapper和普通QueryWrapper的安全性完全一致,都是参数化查询。但它额外提供了类型安全保障,减少了因字段名写错而导致的逻辑漏洞,间接提升了整体安全性。
四、UpdateWrapper和删除操作中的防注入
不仅是查询,更新和删除操作同样需要防注入。UpdateWrapper的set方法、delete操作中的条件构造,全部都是参数化的。
UpdateWrapper<User> updateWrapper = new UpdateWrapper<>();
updateWrapper.eq("id", userId)
.set("status", newStatus)
.set("update_time", new Date());
userMapper.update(null, updateWrapper);生成SQL:UPDATE user SET status = ?, update_time = ? WHERE id = ?。所有值都是参数绑定,安全无虞。
LambdaUpdateWrapper<User> lambdaUpdate = new LambdaUpdateWrapper<>();
lambdaUpdate.set(User::getStatus, 0)
.eq(User::getId, id);
userMapper.update(null, lambdaUpdate);五、apply方法:Wrapper中唯一需要警惕的地方
前面说了Wrapper的标准方法都是安全的,但apply方法是个例外。apply允许你直接写入SQL片段,如果你在里面拼接了用户输入,就会产生注入风险。
// 危险写法!不要这样做!
wrapper.apply("date_format(create_time, '%Y-%m-%d') = {0}", userInput);
// 更危险的写法
wrapper.apply("id = " + userInput);apply方法的正确使用方式是把用户输入作为参数传入,而不是拼接到SQL字符串里:
// 安全写法
wrapper.apply("date_format(create_time, '%Y-%m-%d') = {0}", userInput);
// 注意:{0}是占位符,MyBatis-Plus会将userInput作为参数绑定但即便如此,apply方法仍然比标准Wrapper方法风险更高,因为你需要自己确保SQL片段的正确性。能用标准方法解决的,就不要用apply。
六、自定义SQL中的防注入最佳实践
在实际项目中,总有一些复杂查询是Wrapper无法直接表达的,需要写自定义SQL。这时候防注入的责任就落在开发者身上。MyBatis-Plus支持在Mapper接口中用注解或XML写自定义SQL,关键原则是:永远使用#{}而不是${}。
// 注解方式 - 安全
@Select("SELECT * FROM user WHERE username = #{username}")
List<User> findByUsername(@Param("username") String username);
// 注解方式 - 危险!${}会直接拼接
@Select("SELECT * FROM user WHERE username = ${username}")
List<User> findByUsernameUnsafe(@Param("username") String username);#{}是预编译参数绑定,${}是字符串直接替换。在MyBatis中,${}的使用场景非常有限,通常只用于动态表名、动态排序字段等必须拼接SQL结构的地方,而且这些值必须经过严格的白名单校验。
七、多层防御:不要只依赖Wrapper
虽然MyBatis-Plus的Wrapper提供了很好的防注入能力,但安全是一个系统工程,不能只靠一层防护。以下是建议的多层防御策略:
第一层:输入验证。在Controller层对用户输入做格式校验,比如年龄必须是数字、邮箱必须符合格式等。可以使用JSR 303注解(@NotNull、@Size、@Pattern等)配合Spring Validation实现。
@Data
public class UserQueryDTO {
@NotBlank(message = "用户名不能为空")
@Size(max = 50, message = "用户名最长50个字符")
private String username;
@Min(value = 0, message = "年龄不能为负数")
@Max(value = 150, message = "年龄不合法")
private Integer age;
}第二层:Wrapper参数化查询。这是本文的核心,确保所有数据库操作都通过Wrapper或#{}参数化方式执行。
第三层:最小权限原则。数据库连接使用的账号只授予必要的权限,不要用root账号连接业务数据库。即使发生注入,攻击者能做的事情也有限。
第四层:WAF和SQL审计。在生产环境部署Web应用防火墙,开启SQL注入检测规则。同时可以使用MyBatis-Plus的SQL日志功能,定期审计生成的SQL语句,排查异常。
八、常见误区和容易踩的坑
误区一:认为用了MyBatis-Plus就不会有SQL注入。Wrapper的标准方法确实安全,但如果你在项目中混用了字符串拼接、动态SQL拼接、或者不当使用apply方法,注入风险依然存在。工具是好工具,但用的人要正确使用。
误区二:认为like方法能防所有注入。like方法本身是安全的,但如果你自己手动拼接了通配符再传给like,比如"%" + userInput + "%",虽然最终还是参数化的,但如果你把整个拼接结果放到apply里就危险了。
误区三:忽略了排序字段的注入。orderBy方法绑定的是字段名,如果字段名来自前端且没有白名单校验,攻击者可以注入恶意字段名导致报错或信息泄露。虽然不是传统意义的SQL注入,但属于SQL注入的变种。
误区四:在拦截器或插件中做SQL拼接。有些开发者喜欢在MyBatis-Plus的拦截器里做动态SQL处理,如果在拦截器中使用字符串拼接而非参数化方式,同样会引入注入风险。
九、总结与建议
MyBatis-Plus的条件构造器是Java开发中防SQL注入的优秀工具。QueryWrapper、LambdaQueryWrapper、UpdateWrapper等类的所有标准方法都基于参数化查询,能够有效防止SQL注入攻击。开发者需要做的是:坚持使用Wrapper的标准API方法,避免使用apply方法拼接用户输入,自定义SQL时严格使用#{}而非${},同时配合输入验证、最小权限、WAF等多层防御手段,构建完整的安全体系。安全没有银弹,但用对工具、遵循规范,就能把风险降到最低。
记住一句话:只要你不把用户输入直接拼进SQL字符串里,SQL注入就很难发生。MyBatis-Plus的Wrapper帮你做到了这一点,你要做的就是别绕过它。
