MybatisPlus防SQL注入实战:安全使用QueryWrapper与LambdaQueryWrapper
1. 从一次线上事故说起:为什么MybatisPlus用户也需要警惕SQL注入
去年,我参与处理了一个线上服务的数据异常问题。一个基于Spring Boot和MybatisPlus开发的后台管理系统,在某个查询接口被恶意调用后,出现了用户数据泄露。开发团队的第一反应是:“我们用的是MybatisPlus,ORM框架不是已经防注入了吗?” 然而,经过排查,问题恰恰出在一个他们自认为“安全”的QueryWrapper动态条件拼接上。他们使用了wrapper.apply(“date_format(create_time, ‘%Y%m’) = {0}”, userInput)这样的写法,本意是进行日期格式化的匹配,但攻击者通过精心构造的userInput,最终绕过了预编译,导致了SQL注入。
这个案例非常典型,它打破了许多开发者的一个固有认知:使用了MybatisPlus(或任何ORM框架)就等于高枕无忧,自动免疫SQL注入。事实上,ORM框架提供的是一种“安全编程模型”和“安全工具”,但工具能否被正确使用,完全取决于开发者。MybatisPlus在默认、规范的使用下,能极大程度地避免SQL注入,但它也提供了许多灵活、强大的动态SQL构建方式,这些方式如果使用不当,就会成为安全漏洞的源头。
SQL注入作为OWASP Top 10长期榜上有名的安全威胁,其危害不言而喻:数据泄露、数据篡改、甚至服务器被接管。对于MybatisPlus用户来说,理解其防注入原理的边界,明确哪些用法是“安全区”,哪些是“危险区”,是写出健壮代码的必备知识。这不是一个可选项,而是每个使用该框架的开发者的责任。本文将彻底拆解MybatisPlus与SQL注入的攻防,让你不仅知道“怎么用是安全的”,更深入理解“为什么这样是安全的”,以及“为什么那样做就危险了”。
2. MybatisPlus防注入的核心基石:SQL预编译与参数化查询
要理解MybatisPlus如何防注入,首先必须回到最根本的数据库访问安全机制:参数化查询(Prepared Statement)。这是所有现代数据库访问层防御SQL注入的第一道,也是最核心的一道防线。
2.1 预编译机制是如何工作的
当你直接拼接SQL字符串时,代码可能是这样的:
String sql = "SELECT * FROM user WHERE name = '" + userName + "'";如果userName是admin' OR '1'='1,最终的SQL就变成了:
SELECT * FROM user WHERE name = 'admin' OR '1'='1'这将导致查询条件永远为真,返回所有用户数据。
而参数化查询的做法截然不同:
String sql = "SELECT * FROM user WHERE name = ?"; PreparedStatement stmt = connection.prepareStatement(sql); stmt.setString(1, userName);在这个例子中,SQL语句SELECT * FROM user WHERE name = ?会先被数据库驱动发送到数据库进行编译(解析语法、确定执行计划)。这个编译过程发生在传入具体参数值之前。那个问号?是一个占位符,它代表一个“参数位置”,而不是值的一部分。
当你调用stmt.setString(1, userName)时,无论userName的值是什么(即使是admin' OR '1'='1),数据库驱动都会将其作为一个完整的字符串值,填充到已经编译好的SQL模板的对应占位符上。数据库引擎不会将这个值再作为SQL语法的一部分进行解析。
关键区别在于:在拼接SQL中,用户输入被当成了SQL语句的“语法组成部分”;在参数化查询中,用户输入始终被当作纯粹的“数据值”。从数据库引擎的视角看,它执行的两条语句本质上是不同的:
- 拼接语句:
执行(SELECT * FROM user WHERE name = ‘admin‘ OR ‘1‘=‘1‘)。 - 参数化语句:
执行(预编译好的查询计划, 参数=‘admin\‘ OR \‘1\‘=\‘1‘)。这里的单引号是字符串内容的一部分,而不是SQL语法中的字符串界定符。
2.2 Mybatis/MybatisPlus对预编译的封装
Mybatis(MybatisPlus在其之上构建)的核心设计之一就是将参数化查询模型化、优雅地集成到了XML映射文件和注解中。
在XML映射文件中:
<select id="selectUser" resultType="User"> SELECT * FROM user WHERE name = #{name} </select>这里的#{name}就是Mybatis的参数占位符。在运行时,Mybatis会将其转换为JDBC的?,并通过PreparedStatement.setXxx()方法安全地设置参数值。这是绝对安全的用法。
与之相对的危险用法是${}:
<select id="selectUser" resultType="User"> SELECT * FROM user ORDER BY ${orderByField} </select>${orderByField}是字符串替换。Mybatis在运行前会直接将变量的值替换到SQL语句中,然后才发送给数据库。如果orderByField来自用户输入且未经验证,例如输入name; DROP TABLE user--,生成的SQL将是灾难性的。因此,${}只能用于拼接非用户输入的、可信的SQL片段,如固定的列名、表名(但即使如此也需谨慎)。
MybatisPlus的CRUD接口、条件构造器(如QueryWrapper),其设计目标就是将开发者从编写原始SQL(无论是#{}还是${})中解放出来,通过API调用的方式生成最终的安全SQL。接下来我们就深入它的条件构造器,看看安全与风险的边界在哪里。
3. QueryWrapper与LambdaQueryWrapper:安全区的正确打开方式
MybatisPlus的条件构造器是其标志性功能之一,它让我们能够以面向对象的方式构建查询条件。正确使用时,它是坚固的盾牌;错误使用时,它可能留下缝隙。
3.1 安全的方法:使用Getter方法引用或字符串常量
LambdaQueryWrapper(推荐的安全方式):
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getName, userInputName) .gt(User::getAge, minAge);User::getName是一个方法引用,它在编译时就被确定,指向User实体类的getName方法对应的数据库字段(默认是下划线格式的name)。MybatisPlus在内部处理时,会将字段名(name)和参数值(userInputName)分开处理:字段名作为SQL标识符,参数值通过预编译占位符?传入。整个过程,用户输入的userInputName没有机会干扰SQL结构。
基于字符串的QueryWrapper(需注意写法):
QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq(“name”, userInputName) .gt(“age”, minAge);这里的“name”和“age”是硬编码的字符串列名。只要这个列名字符串不是来自用户输入(而是开发者自己写的),那么userInputName和minAge作为参数值,依然是通过预编译传入的,因此也是安全的。风险点在于,如果你错误地将列名也变成了动态的:
String column = request.getParameter(“column”); // 危险! wrapper.eq(column, userInputValue);此时,column作为SQL的一部分(列名)来自用户输入,就可能被注入。例如用户传入1=1) OR (1=1作为column,结合某些条件,可能构造出意外的查询。
3.2 需要高度警惕的“模糊”地带:like、in等语句
即使使用安全的API,在某些特定场景下,如果对输入值处理不当,也可能间接引发问题,尤其是在模糊查询和in语句中。
模糊查询like的陷阱:
wrapper.like(“name”, userInput);假设userInput包含通配符%或_,例如用户搜索%,那么like ‘%’会匹配所有记录。这不是SQL注入,但可能是一个逻辑漏洞,导致返回过多数据,引发性能问题或数据过度暴露。如果本意是精确匹配包含百分号的字符串,就需要对输入进行转义,或者在应用层处理。
注意:MybatisPlus的
like方法默认会在值两侧加上%,即like %value%。如果你使用wrapper.like(“name”, userInput),而userInput本身包含%,那么最终的匹配模式会变得复杂。对于需要由用户控制通配符的场景,应使用wrapper.apply?不,那更危险(见下文)。更安全的做法是在业务代码中对userInput中的通配符进行转义或过滤,或者明确使用wrapper.eq。
in语句的构造:
List<Long> idList = Arrays.asList(1L, 2L, 3L); wrapper.in(“id”, idList);这是安全的,MybatisPlus会生成id in (?, ?, ?)并进行预编译。危险来自于手动拼接in语句的字符串:
String ids = “1,2,3”; // 假设来自用户输入 “1) OR 1=1 --” wrapper.inSql(“id”, ids);inSql方法会将第二个参数直接拼接到SQL中,生成id in (1) OR 1=1 --),导致注入。绝对不要使用inSql来处理来自用户输入的、逗号分隔的ID字符串。正确的做法是将字符串分割成List,再使用安全的in方法。
3.3 动态排序的安全实践
排序字段和方向(order by)是另一个常见动态需求,且不能使用#{}预编译,因为字段名和ASC/DESC是SQL语法的一部分。
String orderByField = request.getParameter(“orderBy”); // 例如 “name” String orderDirection = request.getParameter(“order”); // 例如 “desc”错误做法(直接拼接):
wrapper.orderBy(true, false, orderByField + “ “ + orderDirection);或使用更危险的last方法:
wrapper.last(“order by “ + orderByField + “ “ + orderDirection);安全做法(白名单校验):
// 定义允许排序的字段白名单 Set<String> allowedFields = new HashSet<>(Arrays.asList(“name”, “age”, “create_time”)); // 定义允许的排序方向 Set<String> allowedDirections = new HashSet<>(Arrays.asList(“asc”, “desc”)); if (allowedFields.contains(orderByField) && allowedDirections.contains(orderDirection.toLowerCase())) { wrapper.orderBy(true, false, orderByField + “ “ + orderDirection); // 或者使用 orderByAsc/orderByDesc 方法组合 if (“asc”.equalsIgnoreCase(orderDirection)) { wrapper.orderByAsc(orderByField); } else { wrapper.orderByDesc(orderByField); } } else { // 使用默认排序或抛出异常 wrapper.orderByDesc(“create_time”); }通过白名单机制,确保拼接进order by子句的内容完全在控制范围内,从而杜绝注入可能。
4. 明确的高危禁区:apply、last、exists与自定义SQL
MybatisPlus提供了一些非常灵活的方法,允许开发者插入自定义的SQL片段。这些方法功能强大,但一旦接受了不可信的用户输入,就是打开了一道直接通往SQL注入的大门。
4.1apply方法:最容易被误用的“后门”
apply方法的签名为apply(String applySql, Object... values)。它的设计初衷是在WHERE条件中插入一段自定义的SQL片段,并对其中的{0}、{1}等占位符用values参数进行字符串替换,而非预编译。
错误案例重现:文章开头提到的线上事故,代码是这样的:
wrapper.apply(“date_format(create_time, ‘%Y%m’) = {0}”, userInput);开发者的本意是:userInput是“202304”这样的字符串,替换{0}后,生成date_format(create_time, ‘%Y%m’) = ‘202304’。这看起来没问题,因为userInput被放在单引号内。
但攻击者输入的是:202304‘) OR 1=1 --。 替换后生成的SQL片段为:date_format(create_time, ‘%Y%m’) = ‘202304‘) OR 1=1 --’由于--是SQL注释符,最终有效的WHERE条件变成了:
... WHERE (date_format(create_time, ‘%Y%m’) = ‘202304‘) OR 1=11=1永远为真,导致查询条件失效,泄露数据。
问题的根源:apply方法内部对{0}的处理是简单的字符串替换。虽然userInput被替换到了引号内,但攻击者通过提前闭合单引号,并添加额外的SQL逻辑,就跳出了“数据值”的范畴,干涉了SQL语法结构。
安全使用apply的建议:
- 绝对原则:
apply的SQL片段模板(第一个参数)必须完全由开发者控制,硬编码在代码中。 - 替换值原则:
{0}、{1}等占位符所替换的值,必须进行严格的校验和过滤。对于日期、数字等类型,应先转换为对应的Java类型(如LocalDate,Integer)。对于字符串,如果必须使用,要严格限制输入格式(如正则匹配^\\d{6}$对于年月),并进行转义(但转义往往复杂且易漏)。 - 优先替代方案:考虑是否能用安全的
wrapper方法组合实现。例如,对于日期范围查询,使用wrapper.between(“create_time”, startDate, endDate)。对于复杂的函数比较,也许需要在业务层计算好值,再用eq或ge、le进行比较。
4.2last方法:在SQL末尾“埋雷”
last方法更直接:last(String lastSql)。它会在生成的SQL语句末尾直接拼接lastSql字符串。这通常用于添加order by、limit、for update等子句。
高危示例:
String limitSql = “limit “ + offset + “, “ + pageSize; // 如果offset/pageSize来自用户 wrapper.last(limitSql);如果用户传入offset为0; DROP TABLE user --,生成的SQL将是SELECT ... FROM user limit 0; DROP TABLE user --。分页参数必须转换为整数类型。
另一个常见错误是拼接order by:
wrapper.last(“order by “ + orderBy);这等同于直接将用户输入拼接为SQL语法,极度危险。解决方案同第3.3节的白名单校验。
4.3exists与notExists方法
这两个方法用于构建exists子查询,其参数是一个子查询SQL字符串。和apply、last一样,如果这个子查询SQL字符串包含了未经验证的用户输入,就会导致注入。
// 危险! String subQuery = “SELECT 1 FROM role WHERE role_id = ‘“ + userInputRoleId + “‘ AND user.id = role.user_id”; wrapper.exists(subQuery);应使用参数化方式构建子查询,或者确保子查询中的条件值来自可信源或经过严格校验。
4.4 自定义SQL(@Select注解或XML中的${})
在MybatisPlus中,你仍然可以使用原生的Mybatis方式编写SQL,例如在Mapper方法上使用@Select注解,或在XML文件中编写。
@Select(“SELECT * FROM user WHERE ${whereCondition}”) List<User> selectByCondition(@Param(“whereCondition”) String whereCondition);这里的${whereCondition}是赤裸裸的字符串替换,极度危险。绝对禁止将任何来自用户输入的、未经验证和过滤的内容通过${}拼接到SQL中。
即使在XML中,使用<if test=”...”>等动态SQL标签,其test表达式中的变量是OGNL表达式,是安全的。但一旦在SQL文本中使用了${column},风险就出现了。
<select id=”selectBySort”> SELECT * FROM user ORDER BY ${sortField} ${sortOrder} </select>同样,必须对sortField和sortOrder实施白名单校验。
5. 深度防御:超越框架的代码审计与安全实践
依赖MybatisPlus的安全特性只是第一层防御。要构建健壮的应用,必须在开发流程和代码习惯上建立深度防御体系。
5.1 代码审计中的关键检查点
在团队Code Review或使用SAST(静态应用安全测试)工具时,应重点关注以下模式:
- 搜索
${:在XML映射文件中,全局搜索${,检查每一个使用点。确认被替换的变量(如${orderBy})是否来自用户输入。如果来自用户输入,必须要有严格的白名单校验逻辑,并且该逻辑要在审计路径上清晰可见。 - 搜索
.apply(和.last(:在Java代码中搜索这些方法调用。检查第一个参数(SQL片段字符串)是否包含字符串连接操作(+),特别是连接了来自HttpServletRequest、@RequestParam、@PathVariable等来源的变量。 - 搜索
.inSql(:确认第二个参数是否为不可控的字符串。通常,.inSql应该只用于固定的、小的子查询,例如id in (select user_id from dept where id = 1)。 - 检查Wrapper的setEntity方法:
wrapper.setEntity(user)会将实体的所有非空字段作为等于条件。需确保这个实体对象的所有字段值都是可信的,特别是当实体对象是从前端反序列化而来时,要防止攻击者篡改其他查询字段。
5.2 输入验证与参数化思维
- 类型强制转换:对于分页参数(page, size)、ID等,在Controller层就将其转换为整数类型。Spring MVC的
@RequestParam或@PathVariable可以配合类型声明自动转换,转换失败会抛出异常,这比在后端处理字符串安全得多。public Page<User> listUsers(@RequestParam Integer pageNum, @RequestParam Integer pageSize) { ... } - 内容白名单:对于排序字段、分组字段、筛选字段名等必须作为SQL语法一部分的输入,建立白名单。白名单应尽可能小,并与数据库实际列名对应。
- 业务逻辑校验:即使参数通过了语法层面的安全检查,也要进行业务逻辑校验。例如,查询某个用户的订单时,除了传入订单ID,还必须在查询条件中强制加入当前登录用户的ID条件,防止越权。
wrapper.eq(“order_id”, orderId).eq(“user_id”, currentUserId);
5.3 使用更安全的工具链
- MybatisPlus代码生成器:使用官方代码生成器生成的Entity、Mapper、Service代码,默认使用的是安全的
#{}和Lambda表达式。这为项目奠定了良好的安全基础。 - ORM与原生SQL的权衡:对于极度复杂的查询(如多表关联、窗口函数),有时会觉得MybatisPlus的Wrapper表达起来很吃力,从而想退回到写原生XML SQL。此时,务必坚持使用
#{}。如果#{}无法满足(如动态表名、列名),那就将动态部分严格限制在白名单内。永远不要因为方便而牺牲安全。 - 启用SQL日志与监控:在开发测试环境,开启MybatisPlus的SQL日志输出(
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl)。观察最终执行的SQL语句和参数,检查是否有意外的拼接行为。在生产环境,可以通过APM工具监控慢SQL,异常的、全表扫描的SQL有时可能就是注入攻击成功的信号。
6. 实战演练:构建一个安全的动态查询接口
假设我们需要实现一个用户查询接口,支持根据姓名(模糊)、年龄范围、创建时间范围和指定字段排序。
不安全版本的诱惑:一个快速但不安全的想法可能是接收一个Map<String, Object>参数,然后遍历Map去动态构造Wrapper。这极易出错且危险。
安全版本的设计:
定义安全的请求参数DTO:
@Data public class UserQueryDTO { private String nameLike; // 模糊姓名 private Integer minAge; private Integer maxAge; private LocalDateTime createTimeStart; private LocalDateTime createTimeEnd; private String sortBy = “create_time”; // 排序字段,有默认值 private String sortOrder = “desc”; // 排序方向,有默认值 }在Service层进行安全构造:
@Service public class UserService { // 排序字段白名单 private static final Set<String> ALLOWED_SORT_FIELDS = Set.of(“name”, “age”, “create_time”); // 排序方向白名单 private static final Set<String> ALLOWED_SORT_ORDERS = Set.of(“asc”, “desc”); public Page<User> queryUsers(UserQueryDTO dto, Page<User> page) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); // 1. 模糊查询:对输入进行通配符转义(如果业务需要精确包含%_) if (StringUtils.isNotBlank(dto.getNameLike())) { // 假设我们允许用户使用通配符,但为安全起见,可以在这里进行转义 // String escapedName = escapeSqlWildcard(dto.getNameLike()); // wrapper.like(User::getName, escapedName); // 更常见的做法是,我们控制通配符,用户输入作为纯文本内容 wrapper.like(User::getName, dto.getNameLike()); } // 2. 范围查询:直接使用安全的ge, le, between方法 if (dto.getMinAge() != null) { wrapper.ge(User::getAge, dto.getMinAge()); } if (dto.getMaxAge() != null) { wrapper.le(User::getAge, dto.getMaxAge()); } if (dto.getCreateTimeStart() != null && dto.getCreateTimeEnd() != null) { wrapper.between(User::getCreateTime, dto.getCreateTimeStart(), dto.getCreateTimeEnd()); } // 3. 动态排序:使用白名单校验 String sortBy = dto.getSortBy(); String sortOrder = dto.getSortOrder(); if (!ALLOWED_SORT_FIELDS.contains(sortBy)) { sortBy = “create_time”; } if (!ALLOWED_SORT_ORDERS.contains(sortOrder.toLowerCase())) { sortOrder = “desc”; } // 根据校验后的字段和方向,使用安全的orderBy方法 if (“asc”.equalsIgnoreCase(sortOrder)) { wrapper.orderByAsc(getSortLambda(sortBy)); } else { wrapper.orderByDesc(getSortLambda(sortBy)); } return userMapper.selectPage(page, wrapper); } // 一个辅助方法,将字符串字段名转换为Lambda表达式(简化版,实际可能需要反射) // 这里为了安全,我们直接使用条件判断,避免反射带来的复杂性和潜在风险。 private SFunction<User, ?> getSortLambda(String sortBy) { switch (sortBy) { case “name”: return User::getName; case “age”: return User::getAge; case “create_time”: default: return User::getCreateTime; } } }
这个实现完全避免了字符串拼接,所有查询条件值都通过Lambda表达式指向明确的字段,并通过预编译传入。排序字段通过白名单和switch-case进行严格限制,彻底堵死了SQL注入的可能。它可能没有直接拼接字符串那么“灵活”,但换来的却是系统的“坚固”。在安全面前,这一点点灵活性的牺牲是绝对必要且值得的。记住,框架是你的助手,而不是你安全意识的替代品。正确的认知加上严谨的实践,才能让你的应用在复杂的网络环境中立于不败之地。