MyBatis作为流行的持久层框架,常被用于拼接复杂查询语句。当业务需要前端动态指定排序字段与顺序时,不少开发者会在XML映射文件里使用ORDER BY ${sortField} ${sortOrder}这类写法。由于美元符号占位符不会做预编译参数绑定,而是把值直接替换进SQL文本,一旦字段名或排序方向来自未校验的用户输入,攻击者就能构造特殊字符串改变原意,形成SQL注入。例如传入sortField值为"update_time,(select 1 from dual where 1=2)"即可干扰查询逻辑。

为什么ORDER BY不能使用#{}也无法直接信任${}
在MyBatis中,#{}会将参数转为JDBC的PreparedStatement占位符,由驱动做类型安全的绑定,这对WHERE条件里的字符串、数字非常有效。但ORDER BY后面的字段名属于SQL标识符,不是值,不能被引号包裹。如果写成ORDER BY #{sortField},多数数据库会将其解释为常量字符串而非列名,导致语法错误或排序失效。因此开发者容易退而求其次使用${},却忽视了它本质是字符串拼接。
使用${}接收排序字段时,MyBatis在解析阶段就把参数值原样写入映射后的SQL。假设接口暴露的排序字段由前端下拉框控制,看似只能选几个固定值,但接口参数可被抓包篡改。攻击者提交sortField=id;drop table user--就能尝试注入,虽然后端数据库账号权限可能限制危害,但任意SQL片段执行风险始终存在。排序方向参数同样危险,传入desc, (select sleep(5))也可能被拼入。
要解决这个矛盾,既不能丢掉动态排序的灵活性,又要避免标识符被篡改,核心思路是:所有进入ORDER BY的标识符都必须来自服务端受控集合,而不是用户原始输入。这就引出了白名单机制。
基于集合校验的纯Java白名单实现
最简单的白名单是在Java代码层维护一个允许排序的字段映射表,例如用Map关联前端传来的key与服务端真实的列名。这样即便用户提交未知key,也会因不在表中而被拒绝或回落到默认排序。该方法不依赖任何框架特性,清晰且易单测。
下面示例展示了一个排序参数解析工具,前端传field=name&dir=asc,后端先查白名单,命中才拼装。注意排序方向也限定为两个枚举,杜绝其他字符。
import java.util.HashMap;
import java.util.Map;
public class SortGuard {
private static final Map<String, String> FIELD_WHITELIST = new HashMap<>();
static {
FIELD_WHITELIST.put("name", "user_name");
FIELD_WHITELIST.put("age", "user_age");
FIELD_WHITELIST.put("ctime", "create_time");
}
public static String buildOrderSql(String field, String dir) {
String realField = FIELD_WHITELIST.get(field);
if (realField == null) {
realField = "create_time"; // 默认安全字段
}
String realDir = "desc".equalsIgnoreCase(dir) ? "desc" : "asc";
return " ORDER BY " + realField + " " + realDir;
}
}
上述代码把前端字段key映射为数据库列名,从根源上阻止了任意标识符注入,因为realField只能是白名单里的字符串字面量。即便攻击者传入field=xxx,也只会走到默认值。在Mapper调用时,可以把buildOrderSql结果作为一个整体参数用${}引入,但由于内容已受控,不再有注入风险。
这种写法的优点是直观、易调试,适合字段较少的系统。缺点是当表结构变更或新增排序项时,需要同步修改Java代码并重新发布。若团队希望把白名单声明贴近SQL,也可采用注解方案。
利用自定义注解与拦截器统一管控白名单
对于中大型项目,可以在Mapper方法参数上标记注解,声明该方法允许的排序字段范围,再通过MyBatis拦截器在参数入库前做校验。这样白名单随接口定义走,review时一眼可见,也方便集中审计。
示例定义一个@SafeSort注解,value为允许字段数组。拦截器扫描参数对象中的sortField属性,若不在数组内则抛异常。以下为简化版注解与拦截逻辑:
import java.lang.annotation.ElementType;
import java.lang.annotation.Target;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SafeSort {
String[] value();
}
// 拦截器片段
public Object intercept(Invocation inv) throws Throwable {
Object[] args = inv.getArgs();
// 假设第一个参数是包含sortField的Map
if (args[0] instanceof Map) {
Map<String, Object> param = (Map<String, Object>) args[0];
String field = (String) param.get("sortField");
Method m = inv.getMethod();
SafeSort ann = m.getAnnotation(SafeSort.class);
if (ann != null && !Arrays.asList(ann.value()).contains(field)) {
param.put("sortField", "id"); // 回落默认
}
}
return inv.proceed();
}
该方案把安全策略嵌入调用链,不需要每个排序逻辑都手写Map。配合XML里仍使用${sortField},只要拦截器保证sortField被净化即可。需要注意的是,拦截器应同时处理sortOrder,仅放行asc与desc。
从维护角度看,注解方式减少了重复代码,但当有多个Mapper且字段差异大时,注解值容易膨胀。团队可根据规模选择:小型系统用集合常量,复杂系统用注解加拦截器,甚至二者结合,把白名单抽取到配置中心实现热更新。
白名单机制的边界与补充防护
白名单能解决ORDER BY标识符注入,但并不意味着可以忽视其他动态SQL风险。例如WHERE条件中的LIKE、IN列表若用${}同样危险,应优先使用#{}或 <foreach> 标签。白名单只是针对标识符场景的补丁,不是万能药。
另外,即便用了白名单,也要防范排序字段对应的数据量过大导致全表排序拖垮数据库。建议对白名单字段建立索引,并在接口层限制分页大小。同时,前端传参应使用固定下拉而非自由文本,降低被探测可能。最后,定期开展代码审计,搜索所有${}用法,确认每个都经过校验,才能把SQL注入面缩到最小。
综合来看,使用白名单机制过滤字段名来规避MyBatis动态排序注入完全可行,也是目前最稳妥的做法。关键在于白名单必须由服务端定义、覆盖字段名与排序方向、并与默认安全值配合。只要落实这几点,动态排序功能就能在安全边界内平稳运行。