在MyBatis框架的实际开发中,动态SQL的拼接点往往是SQL注入攻击的重灾区。很多开发者知道要使用#{}来预防注入,但一碰到复杂的动态场景,比如order by、like模糊查询、in列表或者表名/字段名动态传入,就容易图省事直接使用${}拼接,瞬间把防线撕开一道口子。问题的核心在于,MyBatis的#{}本质上是通过PreparedStatement的参数占位符来实现的,它能处理的只是SQL语句中的参数值,而无法覆盖SQL语句结构本身发生变化的场景。一旦业务需要动态决定排序字段、动态传入表名或者拼接复杂的查询条件,就必须在灵活性和安全性之间找到平衡点。

参数占位符与字符串替换的本质区别

MyBatis的#{}和${}不是同一个量级的安全工具。#{}在底层会将传入的参数值交给JDBC的PreparedStatement处理,数据库驱动会自动对特殊字符进行转义,并把参数值严格当作数据而不是SQL指令来执行。而${}做的是纯粹的字符串替换,它在SQL语句被编译之前就把变量内容拼接进去了,这意味着攻击者输入的任何SQL片段都会直接参与SQL逻辑的构建。很多开发者误以为在Java代码里做一层字符串过滤就能安全使用${},这种想法极其危险,因为黑名单过滤永远跟不上攻击手法的变化,更何况SQL注入的绕过技巧已经非常成熟。

order by动态排序的安全加固写法

动态排序是业务系统里最常见的硬骨头。用户点击列表页的列头,前端传一个排序字段名过来,后端必须把它拼到order by后面。这个场景下排序字段名属于SQL结构的一部分,#{}完全用不上,直接用${}又等于裸奔。正确的做法是引入白名单校验机制。在服务层或者Mapper层接口调用之前,定义一个允许排序的字段集合,把前端传过来的字段名在这个集合里做精确匹配,匹配不通过就使用默认排序字段或者直接抛出参数异常。

// 服务层白名单校验
private static final Set<String> ALLOWED_ORDER_COLUMNS = Set.of("id", "username", "create_time", "update_time");

public List<User> getUsersByOrder(String orderColumn) {
    if (!ALLOWED_ORDER_COLUMNS.contains(orderColumn)) {
        throw new IllegalArgumentException("非法的排序字段: " + orderColumn);
    }
    return mapper.getUsersByOrder(orderColumn);
}

在Mapper XML里,因为已经确保了orderColumn的值一定是白名单内的合法字段名,这时候使用${}拼接就是安全的。但必须注意,白名单的维护要和数据库表结构同步更新,新增字段时要记得把允许排序的字段加进去,否则就会出现功能缺失。还有一种更严谨的做法是直接把白名单校验逻辑封装成一个工具类,在所有需要动态排序的接口里统一调用,避免散落各处导致遗漏。

like模糊查询的防注入与性能兼顾

模糊查询的注入风险往往被低估。很多系统直接把用户输入拼成%关键字%的形式,然后塞进${}里,这相当于把大门敞开。实际上like查询完全可以使用#{}来实现,只需要把百分号放在参数值里一起传递进去。MyBatis的#{}会对参数值做预编译处理,特殊字符会被转义,不会破坏SQL结构。

<select id="searchUsers" resultType="User">
    SELECT id, username, email FROM users
    WHERE username LIKE CONCAT('%', #{keyword}, '%')
</select>

这里的关键点在于,CONCAT函数在SQL执行时把百分号和参数值拼接起来,而参数值本身是经过预编译处理的,不会造成注入。有些开发者担心CONCAT在大量数据下会有性能问题,实际上这个函数在MySQL等主流数据库里执行效率很高,比起SQL注入带来的安全灾难,这点性能损耗完全可以接受。如果使用的是Oracle数据库,可以用||操作符代替CONCAT。另外要注意,如果keyword本身包含了百分号或者下划线这类SQL通配符,业务上需要明确是允许用户进行通配符搜索还是需要做转义处理。如果不需要通配符功能,应该在服务层对keyword里的%和_进行转义,否则用户输入一个%就能查出全表数据,造成性能问题。

in语句的列表参数安全传递

in查询的动态参数数量变化是另一个容易踩坑的地方。很多开发者会直接用${}把逗号分隔的字符串拼进去,比如传一个"1,2,3"然后拼成IN (${ids}),这完全绕过了预编译机制。MyBatis的foreach标签就是专门解决这个问题的,它能把一个集合参数动态展开成多个参数占位符,既保持了SQL结构的稳定,又实现了参数数量的动态变化。

<select id="getUsersByIds" resultType="User">
    SELECT id, username, email FROM users
    WHERE id IN
    <foreach collection="idList" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>

foreach生成的SQL最终是IN (?, ?, ?)的形式,每个参数值都走预编译通道,安全性毫无问题。需要注意的一个细节是,如果idList为空或者null,foreach不会生成任何内容,SQL就会变成WHERE id IN,导致语法错误。因此在使用foreach之前,必须在服务层判断集合是否为空,为空时要么不执行查询直接返回空列表,要么走另外的逻辑分支。另外,部分数据库对in列表的长度有限制,比如Oracle的in列表不能超过1000个值,这种情况下需要在服务层对集合进行分片处理,分批查询后再合并结果。

表名和列名动态传入的严格管控

多租户系统或者动态报表场景里,表名和列名可能需要根据配置动态决定。这类场景#{}完全无能为力,因为表名和列名属于数据库对象标识符,不是参数值。但直接使用${}又极度危险,攻击者一旦控制了表名参数,就能执行任意SQL。解决思路依然是白名单机制,但这次白名单需要更加严格。在服务层维护一个允许的表名映射表,前端传过来的表名别名必须在这个映射表里存在,然后取对应的实际表名进行拼接。绝对不要让前端直接传入真实的数据库表名。

// 表名白名单映射
private static final Map<String, String> TABLE_NAME_MAPPING = Map.of(
    "user", "sys_user",
    "order", "biz_order",
    "product", "biz_product"
);

public List<Map<String, Object>> dynamicQuery(String tableAlias, String condition) {
    String realTableName = TABLE_NAME_MAPPING.get(tableAlias);
    if (realTableName == null) {
        throw new IllegalArgumentException("非法的表名别名: " + tableAlias);
    }
    return mapper.dynamicQuery(realTableName, condition);
}

列名的处理同理,必须建立允许的列名白名单。对于动态报表这种列名变化非常多的场景,可以考虑从数据库的information_schema里动态获取当前表的列名列表来做校验,这样白名单能自动和表结构保持一致。但要注意,查询information_schema本身也有性能开销,适合在应用启动时加载一次并缓存,定期刷新即可,不要每次请求都去查。

复杂动态条件组合的安全架构设计

当查询条件非常多且组合方式复杂时,很多团队会选择在XML里写大量的if标签和choose标签来动态拼接where条件。这种写法本身是安全的,因为if标签只是控制SQL片段的出现与否,参数值依然通过#{}传递。但问题出在当条件过于复杂时,XML的维护成本急剧上升,可读性变差,容易出现逻辑漏洞。一种更优雅的做法是引入条件构造器模式,在服务层用代码构建查询条件对象,然后传递给Mapper使用。这样既能利用Java代码的可读性和可测试性,又不牺牲MyBatis的动态SQL能力。

// 条件构造器
public class UserQueryCondition {
    private String username;
    private String email;
    private Integer minAge;
    private Integer maxAge;
    private List<String> orderColumns = new ArrayList<>();
    
    // getter和setter省略
    
    public boolean hasUsername() {
        return username != null && !username.isEmpty();
    }
    
    public boolean hasAgeRange() {
        return minAge != null && maxAge != null;
    }
}

在Mapper XML里可以直接调用条件对象的方法来判断是否拼接对应的SQL片段,参数值依然用#{}传递。这种模式下,条件逻辑的单元测试变得非常容易,而且条件对象可以在多个查询方法间复用。更重要的是,排序字段的白名单校验可以统一放在条件对象的setter方法里,或者放在一个专门的校验器里,形成一道统一的安全闸门。

MyBatis Plus等增强工具的安全使用边界

很多项目使用MyBatis Plus来简化开发,它的条件构造器LambdaQueryWrapper在防注入方面做得相当不错,因为它是通过Lambda表达式来引用实体类的属性,编译期就能确定字段名,避免了字符串拼接。但MyBatis Plus也提供了类似last()方法可以直接拼接任意SQL字符串到查询末尾,还有apply()方法可以拼接自定义SQL片段,这些方法内部实际上就是用的字符串拼接,如果传入了用户可控的参数,同样会造成注入风险。使用这些方法时必须确保拼接的内容完全来自服务端硬编码,不能包含任何用户输入。

// 危险的写法,orderField来自用户输入
wrapper.last("ORDER BY " + orderField);

// 安全的写法,排序字段通过Lambda表达式指定
wrapper.orderByAsc(User::getCreateTime);

另一个容易被忽视的点是MyBatis Plus的QueryWrapper支持直接传入字符串字段名,比如eq("username", value)。这里的字段名字符串"username"是硬编码的,不存在注入风险,但如果字段名本身是从变量拼接来的,就需要警惕了。最佳实践是优先使用LambdaQueryWrapper,它从语言层面杜绝了字段名拼写错误和注入的可能。

存储过程和自定义函数的注入防御

有些系统为了性能或者封装业务逻辑,会在数据库层编写存储过程或者自定义函数,然后通过MyBatis调用。调用存储过程时,参数传递同样要严格使用#{},绝对不能在调用语句里用${}拼接参数。另外,存储过程内部的SQL拼接如果使用了动态SQL,比如在存储过程里用EXECUTE执行拼接好的SQL字符串,那么即使MyBatis层面用了#{},注入风险依然存在。这种情况下需要确保存储过程内部也使用了参数化查询,或者对拼接的变量做严格的类型和格式校验。从安全架构的角度看,应尽量避免在存储过程里做动态SQL拼接,把动态逻辑放在应用层处理,存储过程只负责执行确定结构的SQL。

全局拦截与监控的兜底策略

即使代码层面做了充分的防护,也应该在系统架构上增加一层兜底机制。可以通过MyBatis的拦截器插件,对所有执行的SQL进行监控和审计。拦截器可以检测SQL中是否出现了异常的拼接模式,比如检测到SQL里有注释符号、多语句执行的分号、或者常见的注入关键字,就记录告警日志甚至直接阻断执行。但要注意,这种基于规则的检测只能作为辅助手段,不能替代代码层面的安全防护,因为规则很容易被绕过。更有效的做法是在数据库层面配置最小权限原则,应用连接的数据库账号只授予必要的SELECT、INSERT、UPDATE、DELETE权限,不授予DROP、ALTER等DDL权限,这样即使发生注入,攻击者的破坏范围也能被限制在数据层面,无法对数据库结构造成破坏。

动态SQL的防注入本质上是一个纵深防御的问题。没有一种单一技术能覆盖所有场景,需要根据SQL片段变化的类型,组合使用预编译参数、白名单校验、安全构造器、权限最小化等多种手段。开发团队应该把这些防护措施沉淀到代码规范和框架基类里,让安全的写法比不安全的写法更容易使用,这样才能在快速迭代的业务压力下持续保持系统的安全水位。