Mybatis动态排序攻击面-ORDER-BY注入与字段白名单

动态排序攻击面:ORDER BY 注入与字段白名单

攻击面: 查询值可以使用预编译参数,表名和排序列却不能直接用 ? 占位。为了支持前端表格排序,不少项目把 sortField 原样拼进 ${},由此留下 SQL 注入、越权探测和不可控慢查询风险。本文给出枚举白名单、字段映射和类型安全方法引用三层方案,并结合 MetaLite OrderBy 的源码说明公共查询组件应该负责什么、不能相信什么。

很多后台表格都会提交这两个参数:

{
  "sortField": "createdTime",
  "sortDirection": "desc"
}

为了生成动态排序,一些 MyBatis XML 会写成:

ORDER BY ${sortField} ${sortDirection}

功能上线很快,但 ${} 是字符串替换,不会像 #{} 一样变成预编译参数。只要字段来自外部输入,攻击者就可能改变 SQL 结构。

一、为什么排序列不能使用普通占位符

下面这种写法通常不能表达动态列名:

ORDER BY ? DESC

数据库会把 ? 当成一个值,而不是 SQL 标识符。列名、表名、排序方向属于 SQL 结构,必须在生成 SQL 前完成可信映射。

这也是动态排序比普通查询参数更危险的原因:

  • user_id = ? 的值不会改变 SQL 结构;
  • ORDER BY ${field} 会直接改变解析后的语句。

二、风险不只有传统 SQL 注入

即使数据库驱动禁止多语句执行,动态排序仍可能带来其他问题。

1. 探测内部字段

攻击者不断尝试字段名,可以推断表结构、隐藏列或数据库函数是否可用。

2. 构造高成本表达式

复杂函数、计算表达式或未建索引字段可能让分页查询突然变慢。

3. 绕过数据展示规则

某些字段虽然允许查询,却不应该成为公开排序条件。例如内部风险分、逻辑删除标记或安全等级。

4. 排序结果不稳定

只按非唯一字段排序,翻页时可能出现重复或遗漏记录。

三、最稳妥的入口是业务白名单

前端参数不应直接等于数据库字段,而应先映射到服务端枚举:

enum UserSortField {
    CREATED_TIME,
    USERNAME,
    STATUS
}

Service 再把枚举转换为受信字段:

OrderBy orderBy = switch (param.getSortField()) {
    case CREATED_TIME -> OrderBy.desc(UserEntity::getCreatedTime);
    case USERNAME -> OrderBy.asc(UserEntity::getUsername);
    case STATUS -> OrderBy.asc(UserEntity::getStatus);
};

这样前端只能选择产品允许的排序能力,无法提交任意数据库标识符。

四、MetaLite 如何把字段引用转换成列名

MetaLite OrderBy 支持字符串字段:

OrderBy.desc("createdTime")

也支持 Java 方法引用:

OrderBy.desc(UserEntity::getCreatedTime)

方法引用版本通过 EntityHelper.genFieldName(function) 提取实体属性:

public static <T, R> OrderBy desc(EntityFieldNameFunction<T, R> function) {
    AssertUtil.paramNotNull(function, "function");
    return new OrderBy(EntityHelper.genFieldName(function), Direction.DESC);
}

JDBC SQL 生成阶段再从实体属性到数据库列名映射表中取值:

sb.append(property2ColumnMap.get(orderBy.getKey()))
  .append(" ")
  .append(orderBy.getDirection());

这里体现了两层约束:

  1. 排序方向来自框架枚举,只能是受支持的值;
  2. 方法引用必须指向实体真实属性,字段改名时编译器能够发现问题。

五、方法引用不等于前端输入已经安全

如果 Controller 仍然接收任意字符串,然后调用:

OrderBy.desc(param.getSortField())

虽然 property2ColumnMap 不一定能映射非法字段,但这不是完整的安全契约。调用方仍应校验:

  • 字段是否在当前接口白名单;
  • 排序方向是否合法;
  • 字段是否适合建立排序索引;
  • 是否需要追加稳定的主键排序。

公共 ORM 可以提供类型安全能力,却无法判断某个字段是否应该开放给某个页面。

六、分页排序必须增加唯一兜底字段

假设大量订单的创建时间完全相同:

ORDER BY created_time DESC
LIMIT 20 OFFSET 20

数据库对相同时间记录的内部顺序没有承诺。并发插入后,第二页可能重复出现第一页数据,也可能漏掉记录。

建议写成:

query.orderBy(
    OrderBy.desc(OrderEntity::getCreatedTime),
    OrderBy.desc(OrderEntity::getId)
);

其中 id 负责形成确定的全序。

七、复杂排序应该怎样处理

真实业务可能需要:

  • 聚合别名排序;
  • 距离排序;
  • 自定义状态优先级;
  • 跨表字段排序;
  • 数据库函数计算排序。

这类排序不应为了复用通用列表接口而开放任意表达式。更合适的做法是:

  1. 为场景定义专用查询方法;
  2. SQL 表达式固定在服务端;
  3. 外部只传有限的策略枚举;
  4. 对慢排序建立独立索引和执行计划测试。

MetaLite 的字符串 OrderBy 是扩展逃生口,不应该成为外部参数直通 SQL 的入口。

八、可直接复用的动态排序检查清单

  • Controller 不直接接收数据库列名;
  • 外部字段先映射为服务端枚举;
  • 排序方向只允许 ASCDESC
  • ORM 内优先使用实体方法引用;
  • 分页排序追加唯一主键;
  • 公开排序字段有索引和慢 SQL 验证;
  • 聚合、函数和跨表排序使用专用查询;
  • 日志记录最终排序策略,但不回显内部表结构。

九、结论

动态排序的本质不是“拼一个 ORDER BY”,而是把外部意图转换为受控的 SQL 结构。

MetaLite 方法引用解决了字段改名和内部类型安全问题;业务白名单解决了哪些能力允许对外开放的问题;稳定排序和索引验证解决了分页正确性与性能问题。三层缺一不可。


框架简介
MetaLite 是面向企业生产环境的新一代 Java 微服务技术底座。系列文章重点分享代码背后的设计思路、技术取舍与工程实践。

源码基线
JDK 21、Spring Boot 3.2.9、Spring Cloud 2023.0.1、Spring Cloud Alibaba 2023.0.1.3,具体组件版本以项目 backend-bom 为准。

作者简介
15 年 Spring 体系企业级开发经验,专注于 Java 微服务架构、工程治理与生产实践。

持续更新
MetaLite 系列内容将持续更新,围绕核心设计、源码链路、技术取舍与生产实践展开。欢迎关注作者,及时获取后续内容。

在线演示
演示地址: https://admin.metalite.top/
演示账号: guess
演示密码: admin@2026

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值