如何修复Spring Boot导出Excel功能中的SQL排序注入点

作者:袖梨 2026-07-16
ORDER BY字段必须白名单校验,禁止参数化拼接;需严格校验排序字段(如"id","name")、方向("ASC"/"DESC")及多字段拆分,反引号别名须额外过滤,校验须前置到Controller层且全覆盖所有导出入口。

ORDER BY 字段不能参数化,必须白名单校验

Spring Boot 导出 Excel 时若支持用户指定排序字段(如 ?sort_field=created_at&sort_order=desc),ORDER BY 后的内容无法用 ?:param 占位符处理——JDBC 和所有主流 ORM 都不支持对排序字段做参数绑定。直接拼接 "ORDER BY " + request.getParameter("sort_field") 等同于把 SQL 解析权交给攻击者。

常见错误是试图用正则过滤或转义单引号,但攻击者可绕过:sort_field=1; DROP TABLE users--sort_field=(SELECT password FROM admins)sort_field=`status` ASC, (SELECT 1) -- 均可成功执行。

  • 白名单必须硬编码,例如 private static final Set<string> ALLOWED_SORT_FIELDS = Set.of("id", "name", "email", "created_at", "amount");</string>
  • 校验需严格区分大小写:PostgreSQL 默认区分,MySQL 默认不区分;建议统一用小写存储白名单,并对输入调用 .toLowerCase() 后比对
  • 排序方向(ASC/DESC)也需单独白名单校验,禁止透传任意字符串,例如 Set.of("ASC", "DESC")
  • 若业务允许多字段排序(如 sort_field=name,created_at),需拆分后逐个校验,任一不匹配即拒

MyBatis-Plus 动态排序的正确写法

@Select 注解或 XML 写原生 SQL 时,${} 是危险的(非参数化,直接替换),而 #{}ORDER BY 无效(会加引号导致语法错误)。所以不能靠 MyBatis 的占位符“蒙混过关”。

正确做法是:在 Service 层完成白名单校验后,再拼接安全的 SQL 片段。例如:

String sortField = request.getParameter("sort_field");String sortOrder = request.getParameter("sort_order");if (!ALLOWED_SORT_FIELDS.contains(sortField.toLowerCase()) ||    !ALLOWED_SORT_ORDERS.contains(sortOrder.toUpperCase())) {    throw new IllegalArgumentException("Invalid sort parameter");}String safeOrderBy = String.format("%s %s", sortField, sortOrder);// 再传入 MyBatis 的 ${},此时已无风险
  • XML 中写成 ORDER BY ${safeOrderBy},但前提是 safeOrderBy 已经是校验后的纯字母+空格+ASC/DESC
  • 避免在 Mapper 接口方法签名里直接接收 @Param("sortField") String sortField 并传给 ${} —— 校验必须在进入 DAO 层前完成
  • 如果用 QueryWrapper,不要写 wrapper.orderBy(true, true, sortField),因为 sortField 未校验;应先校验,再调用 wrapper.orderByAsc("id").orderByDesc("created_at") 等固定链式调用

Spring Data JPA 的排序注入风险点

Sort.by("user_input") 看似安全,实则危险:它底层仍会将字符串拼进 SQL 的 ORDER BY 子句,且不做白名单检查。传入 new Sort(Sort.Direction.ASC, "name; DROP TABLE users--") 可触发注入。

JPA 的 Pageable 构造函数同样不校验字段名,PageRequest.of(0, 10, Sort.by("status")) 中的 "status" 必须来自可信源。

  • 禁止直接将 request.getParameter("sort") 塞进 Sort.by()
  • 正确做法:解析参数 → 白名单比对 → 映射为预定义的 Sort 实例,例如:Map.of("created_at", Sort.by(Sort.Direction.DESC, "created_at"))
  • 若需支持多字段,用 Sort.by(Sort.Order.asc("name"), Sort.Order.desc("created_at")),而非拼字符串
  • 注意 Sort.Order 构造器接受字段名,但不校验;所以映射逻辑必须前置

导出接口中容易被忽略的别名与反引号陷阱

有些导出需求允许用户自定义列别名,例如 SELECT name AS `Full Name`, email AS `Contact Email` FROM users。反引号内的内容若来自用户输入,可能逃逸并闭合引号,插入恶意 SQL。

比如用户传 alias=`status` ASC, (SELECT 1)--,拼进去变成 SELECT status AS `status` ASC, (SELECT 1)--` FROM users,破坏语法甚至执行子查询。

  • 别名必须走第二套白名单,或禁止用户自定义别名,统一用代码内建的中文映射(如 Map.of("name", "姓名", "email", "邮箱")
  • 若必须支持带空格别名,只允许使用下划线替代空格(full_name),并拒绝任何反引号、双引号、单引号字符
  • 校验逻辑应放在 Controller 层,早于任何 SQL 拼接动作;不要依赖前端传来的“看起来合法”的字符串
  • 特别注意 MySQL 的标识符引号是反引号,PostgreSQL 是双引号,SQL Server 是方括号——白名单校验不解决跨库问题,统一用小写字母+下划线是最稳妥的兼容方案

真正的难点不在写校验逻辑,而在确认所有导出入口——包括后台管理页、API 文档测试框、Swagger UI、Postman 收藏夹里的旧请求——都经过同一套白名单校验。漏掉一个,就等于整条防线失效。

相关文章

精彩推荐