动态排序攻击面:ORDER BY 注入与字段白名单
攻击面: 查询值可以使用预编译参数,表名和排序列却不能直接用
?占位。为了支持前端表格排序,不少项目把sortField原样拼进${},由此留下 SQL 注入、越权探测和不可控慢查询风险。本文给出枚举白名单、字段映射和类型安全方法引用三层方案,并结合 MetaLiteOrderBy的源码说明公共查询组件应该负责什么、不能相信什么。
很多后台表格都会提交这两个参数:
{
"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());
这里体现了两层约束:
- 排序方向来自框架枚举,只能是受支持的值;
- 方法引用必须指向实体真实属性,字段改名时编译器能够发现问题。
五、方法引用不等于前端输入已经安全
如果 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 负责形成确定的全序。
七、复杂排序应该怎样处理
真实业务可能需要:
- 聚合别名排序;
- 距离排序;
- 自定义状态优先级;
- 跨表字段排序;
- 数据库函数计算排序。
这类排序不应为了复用通用列表接口而开放任意表达式。更合适的做法是:
- 为场景定义专用查询方法;
- SQL 表达式固定在服务端;
- 外部只传有限的策略枚举;
- 对慢排序建立独立索引和执行计划测试。
MetaLite 的字符串 OrderBy 是扩展逃生口,不应该成为外部参数直通 SQL 的入口。
八、可直接复用的动态排序检查清单
- Controller 不直接接收数据库列名;
- 外部字段先映射为服务端枚举;
- 排序方向只允许
ASC、DESC; - 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

358

被折叠的 条评论
为什么被折叠?



