MyBatis Plus的条件构造器本身提供了非常强大的防注入能力,但前提是开发者必须正确使用。绝大多数安全问题的根源,不是框架的漏洞,而是开发者在拼接SQL片段时,错误地将用户输入直接传递给了条件构造器的字符串方法。很多人以为用了条件构造器就万事大吉,其实条件构造器内部有两套完全不同的处理逻辑:一套是自动参数化,另一套是直接拼接。一旦用错了后者,SQL注入的风险就会立刻引入。
条件构造器的安全边界在哪里MyBatis Plus的AbstractWrapper类是所有条件构造器的基类,它的核心方法可以分为两类。第一类是安全方法,比如eq、ne、gt、lt、like、in等,这些方法在底层会使用占位符将参数传递给JDBC的PreparedStatement,由数据库驱动完成参数转义,从根源上杜绝注入。第二类是高风险方法,包括apply、last、exists、notExists以及所有接收字符串片段作为条件的方法,这些方法会将传入的字符串直接拼接到最终生成的SQL语句中,不做任何转义处理。
安全方法的典型调用链是:用户调用wrapper.eq("name", userInput),底层会将userInput封装为参数对象,最终生成"name = ?"的SQL片段,并将userInput的值通过PreparedStatement的setString方法安全地传递给数据库。而高风险方法apply("name = '" + userInput + "'")则完全不同,它直接将拼接后的字符串嵌入SQL,如果userInput中包含单引号或其他特殊字符,注入攻击就会发生。
apply方法的正确与错误用法apply方法是日常开发中最容易被误用的高风险API。它的设计初衷是让开发者能够添加条件构造器不直接支持的复杂SQL片段,比如数据库函数调用、子查询片段等。apply方法有两个重载:一个只接收SQL字符串,另一个接收SQL字符串和参数数组。
错误用法示例:
// 危险:直接拼接用户输入
String keyword = request.getParameter("keyword");
wrapper.apply("name like '%" + keyword + "%'");
这种写法将keyword直接嵌入SQL,攻击者可以构造keyword的值为"%' or 1=1 -- ",最终生成的SQL条件变为"name like '%%' or 1=1 -- %'",导致查询返回全表数据。
正确用法是使用apply的参数化版本:
// 安全:使用占位符传递参数
String keyword = request.getParameter("keyword");
wrapper.apply("name like {0}", "%" + keyword + "%");
apply方法支持两种占位符格式:{0}、{1}、{2}等索引占位符,以及{paramName}命名占位符。使用占位符时,MyBatis Plus会将参数值提取出来,通过PreparedStatement安全传递,而不是直接拼接到SQL字符串中。命名占位符的用法如下:
// 使用命名占位符
wrapper.apply("name like {keyword}", "%" + userInput + "%");
需要注意的是,占位符只能用于参数值,不能用于表名、字段名等SQL标识符。如果业务确实需要动态传入字段名或排序方向,必须使用白名单校验,绝不能将用户输入直接传递给apply。
last方法的风险与控制last方法是另一个高风险API,它的作用是在SQL语句的最后追加任意字符串,通常用于添加排序、分组或数据库特定的语法。last方法不做任何参数化处理,直接将传入的字符串追加到SQL末尾。
典型的风险场景是动态排序:
// 危险:用户输入的排序字段直接拼接
String orderField = request.getParameter("orderField");
String orderDirection = request.getParameter("orderDirection");
wrapper.last("order by " + orderField + " " + orderDirection);
攻击者可以通过orderField参数注入恶意SQL,比如传入"id; drop table users; --",造成严重后果。安全的做法是建立字段白名单:
// 安全:白名单校验排序字段
private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "username", "create_time", "update_time");
private static final Set<String> ALLOWED_DIRECTIONS = Set.of("ASC", "DESC");
public void safeOrderBy(QueryWrapper<User> wrapper, String orderField, String orderDirection) {
if (orderField == null || !ALLOWED_COLUMNS.contains(orderField)) {
throw new IllegalArgumentException("非法的排序字段: " + orderField);
}
if (orderDirection == null || !ALLOWED_DIRECTIONS.contains(orderDirection.toUpperCase())) {
throw new IllegalArgumentException("非法的排序方向: " + orderDirection);
}
wrapper.last("order by " + orderField + " " + orderDirection.toUpperCase());
}
白名单校验是处理动态SQL标识符的唯一安全方式,无论使用什么框架,这个原则都适用。
exists和notExists的注入风险exists和notExists方法用于添加EXISTS子查询条件,它们接收一个字符串参数作为子查询SQL。这两个方法同样属于高风险API,会将传入的字符串直接拼接。很多开发者会在子查询中拼接用户输入,从而引入注入漏洞。
错误用法:
// 危险:子查询中拼接用户输入
String userId = request.getParameter("userId");
wrapper.exists("select 1 from orders where orders.user_id = " + userId);
正确的做法是使用参数化子查询,或者将子查询逻辑放在Mapper XML中,通过MyBatis的参数传递机制保证安全。如果必须在条件构造器中使用exists,应该利用apply的参数化能力,或者将用户输入的值通过占位符传递:
// 相对安全:在exists内部使用参数化
wrapper.exists("select 1 from orders where orders.user_id = {0}", userId);
但更推荐的做法是将这类复杂查询封装到Mapper方法中,避免在条件构造器中编写复杂的子查询字符串。
in方法的安全陷阱in方法本身是安全的,它接收一个集合参数,会为集合中的每个元素生成占位符。但很多开发者在使用in时,会先将用户输入拼接成逗号分隔的字符串,然后错误地使用apply来添加in条件,这就绕过了安全机制。
错误用法:
// 危险:将用户输入的字符串直接拼接到in条件中
String ids = request.getParameter("ids"); // 例如 "1,2,3"
wrapper.apply("id in (" + ids + ")");
安全用法是将字符串解析为集合,然后使用in方法:
// 安全:解析为集合后使用in方法
String idsStr = request.getParameter("ids");
List<Long> ids = Arrays.stream(idsStr.split(","))
.map(String::trim)
.map(Long::valueOf)
.collect(Collectors.toList());
wrapper.in("id", ids);
这样做的好处是MyBatis Plus会为每个ID生成独立的占位符,同时Long::valueOf的转换也起到了类型校验的作用,非数字输入会直接抛出异常。
like方法的自动转义MyBatis Plus的like方法在参数化方面是安全的,但它不会自动转义SQL通配符。如果业务场景需要将用户输入中的百分号和下划线当作普通字符处理,就需要手动转义。这不是注入问题,而是业务逻辑问题,但同样可能导致数据泄露。
like方法生成的SQL是"column like ?",参数值由PreparedStatement安全传递,所以不存在注入风险。但如果用户输入包含"%"字符,like查询会匹配任意字符序列,可能返回超出预期的结果。解决方法是在应用层转义通配符:
// 转义like通配符
String userInput = request.getParameter("keyword");
String escapedInput = userInput
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
wrapper.like("name", escapedInput);
同时需要在SQL中指定转义字符,这可以通过apply安全地添加:
wrapper.like("name", escapedInput);
wrapper.apply("escape '\\'"); // 注意这里'\\'是固定字符串,不是用户输入,所以安全
或者使用like方法的重载版本直接指定转义字符,如果框架版本支持的话。
动态表名和字段名的处理在某些分表或动态查询场景中,表名或字段名需要根据业务逻辑动态确定。这些SQL标识符无法通过PreparedStatement的参数化来处理,必须直接拼接到SQL中。此时白名单校验是唯一的安全手段。
对于动态表名,MyBatis Plus提供了动态表名拦截器,可以在拦截器中根据上下文安全地修改表名,避免在条件构造器中直接拼接。对于动态字段名,应该建立一个允许的字段名集合,任何不在集合中的输入都拒绝执行。
// 动态字段名的白名单校验
private static final Map<String, String> FIELD_MAPPING = Map.of(
"username", "user_name",
"createTime", "create_time",
"updateTime", "update_time"
);
public String getSafeColumn(String userInput) {
String column = FIELD_MAPPING.get(userInput);
if (column == null) {
throw new IllegalArgumentException("非法的字段名: " + userInput);
}
return column;
}
这种映射方式不仅能防止注入,还能将前端使用的驼峰命名转换为数据库的下划线命名,一举两得。
代码审计中的关键检查点在进行代码安全审计时,针对MyBatis Plus条件构造器的使用,应该重点关注以下几个检查点。第一,搜索所有apply方法的调用,检查是否使用了占位符参数化,还是直接拼接了用户输入。第二,搜索last方法的调用,检查参数是否包含用户可控的数据,如果包含,是否经过了白名单校验。第三,搜索exists和notExists方法,检查子查询字符串中是否拼接了外部输入。第四,检查所有使用字符串拼接构建SQL条件的代码,确认是否可以用安全方法替代。
一个实用的审计技巧是在IDE中全局搜索正则表达式"apply\(.*\+.*\)"和"last\(.*\+.*\)",这能快速定位到可能存在风险的字符串拼接。同时,代码评审时应该将条件构造器的使用作为重点审查对象,任何apply和last的使用都需要提供安全理由。
防御层次的纵深构建除了正确使用条件构造器API之外,还应该构建多层次的防御体系。在数据库层面,严格限制应用数据库用户的权限,只授予必要的SELECT、INSERT、UPDATE、DELETE权限,禁止DDL操作权限,这样即使发生注入,攻击者也无法执行DROP TABLE等高危操作。在网络层面,部署Web应用防火墙可以拦截常见的SQL注入攻击模式。在应用层面,对所有用户输入进行统一的参数校验和过滤,虽然不能完全依赖过滤来防止注入,但可以作为一种补充防护手段。
日志记录和监控同样重要。记录所有包含apply和last调用的SQL语句,便于事后审计和攻击溯源。当检测到异常的大量数据查询或包含可疑关键字的SQL时,及时触发告警。这些措施与正确使用条件构造器相结合,才能构建起真正可靠的安全防线。
条件构造器的安全性最终取决于开发者的使用方式。框架已经提供了足够的安全能力,关键在于理解安全方法与高风险方法的区别,在高风险方法中严格使用占位符参数化,对动态SQL标识符实施白名单校验。只要遵循这些原则,MyBatis Plus的条件构造器完全可以成为防止SQL注入的有力工具。
