分页查询几乎出现在每个后端列表接口中,而 LIMIT 和 OFFSET 这两个参数经常被当成天然安全的数字,直接从请求里取出来拼接进 SQL。攻击者确实很难像字符串注入那样用单引号闭合,但并不意味着没有风险。要堵住这条路,最直接有效的方法不是在 SQL 里想办法,而是在参数进入 SQL 之前完成强制转型和范围校验。

一、为什么明明是数字还能产生SQL注入
很多开发者对 SQL 注入的认知停留在字符串参数上,认为只要参数没有单引号就不会出问题。这个假设在分页场景里并不成立。分页参数通常来自 URL 查询串,例如 offset=0&pageSize=20,后端代码如果直接写成 "LIMIT " + offset + ", " + pageSize,那么 offset 的值就完全由请求方控制。
以 MySQL 为例,LIMIT 子句的语法允许接收表达式,并且在某些历史版本中还支持 PROCESSEDURE ANALYSE 这一类过程化写法。虽然现代 MySQL 已经收紧了这部分能力,但在驱动开启多语句执行、数据库权限配置不当或后端拼接 SQL 时,分页参数仍然可能携带逗号、注释符和关键字,改变原有语句结构。即使不发生经典的数据篡改,攻击者也可以通过传入超大 offset 造成慢查询,或者借助报错信息获得表结构线索。
下面是一个常见的不安全写法:
String offset = request.getParameter("offset");
String pageSize = request.getParameter("pageSize");
String sql = "SELECT id, title FROM article ORDER BY id DESC LIMIT " + offset + ", " + pageSize;
这段代码没有对 offset 和 pageSize 做任何类型校验。攻击者可以提交 offset=0,1;SELECT+SLEEP(5)--,如果数据库驱动允许多语句执行,就会触发额外的查询;即使不允许多语句,也可能让 SQL 报错并泄露部分堆栈信息。因此,分页参数不能被简单归类为安全数字。
二、预处理语句为什么不能直接绑定LIMIT和OFFSET
既然直接拼接有风险,自然想到使用 PreparedStatement 绑定参数。理想状态下,SQL 模板写成 SELECT * FROM article LIMIT ?, ?,然后通过 setInt 方法赋值。但现实是,不同数据库对 LIMIT 子句的预处理支持并不一致。
PostgreSQL 可以很好地处理 LIMIT $1 OFFSET $2 这种写法,服务端能够准备通用计划。而在 MySQL 的一些驱动版本中,LIMIT 后面的占位符会被转换成带引号的字符串,导致语法错误。例如 MySQL Connector/J 的旧版本会把 setInt 的 20 处理成 '20',数据库无法接受 LIMIT 子句中的字符串字面量。即使某些新版本支持,也会因为数据库方言差异导致应用无法平滑迁移。
更关键的是,预处理语句解决的是“参数值不改变 SQL 结构”的问题,但分页场景里攻击者要利用的恰恰是结构篡改和边界失控。如果应用层不先保证 offset 和 pageSize 是整数,即使使用了 ORM 或查询构造器,也可能在原生 SQL 拼接处留下隐患。所以更稳妥的方案是在进入 SQL 之前完成强制转型,而不是依赖数据库驱动的兼容性。
三、强制转型的完整实现:先校验再拼接
强制转型的核心思路很简单:从请求中取到参数后,先尝试转换为整数;转换失败直接拒绝请求,转换成功后还要检查取值范围。这样进入 SQL 模板的值就一定是由数字组成的字面量,不可能包含逗号、注释符或关键字。
Java 实现可以借助 Integer.parseInt,再配合上下限判断:
public class PaginationParams {
private static final int MAX_PAGE_SIZE = 200;
public static int parseOffset(String rawOffset) {
int offset;
try {
offset = Integer.parseInt(rawOffset);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("offset 必须是整数");
}
if (offset < 0 || offset > 1_000_000) {
throw new IllegalArgumentException("offset 超出允许范围");
}
return offset;
}
public static int parsePageSize(String rawPageSize) {
int pageSize;
try {
pageSize = Integer.parseInt(rawPageSize);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("pageSize 必须是整数");
}
if (pageSize <= 0 || pageSize > MAX_PAGE_SIZE) {
throw new IllegalArgumentException("pageSize 必须在 1 到 " + MAX_PAGE_SIZE + " 之间");
}
return pageSize;
}
}
Go 项目里可以使用 strconv.Atoi,返回值同时包含错误信息,流程会更清晰:
package main
import (
"errors"
"strconv"
)
const maxPageSize = 200
func parsePagination(offsetStr, pageSizeStr string) (int, int, error) {
offset, err := strconv.Atoi(offsetStr)
if err != nil {
return 0, 0, errors.New("offset 必须是整数")
}
if offset < 0 || offset > 1000000 {
return 0, 0, errors.New("offset 超出允许范围")
}
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil {
return 0, 0, errors.New("pageSize 必须是整数")
}
if pageSize <= 0 || pageSize > maxPageSize {
return 0, 0, errors.New("pageSize 必须在 1 到 200 之间")
}
return offset, pageSize, nil
}
PHP 的 filter_var 提供了一套适合请求参数校验的函数,可以直接限定整数范围:
$offset = filter_var($_GET['offset'] ?? 0, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 0, 'max_range' => 1000000],
]);
if ($offset === false) {
throw new InvalidArgumentException('offset 必须是 0 到 1000000 之间的整数');
}
$pageSize = filter_var($_GET['pageSize'] ?? 20, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 200],
]);
if ($pageSize === false) {
throw new InvalidArgumentException('pageSize 必须是 1 到 200 之间的整数');
}
完成转型后,再拼接 SQL 就相对安全了。虽然仍然是拼接写法,但此时参数已经是纯整数变量,不可能再携带 SQL 元字符:
int offset = PaginationParams.parseOffset(request.getParameter("offset"));
int pageSize = PaginationParams.parsePageSize(request.getParameter("pageSize"));
String sql = "SELECT id, title FROM article ORDER BY id DESC LIMIT " + offset + ", " + pageSize;
之所以可以接受这种拼接,是因为安全边界已经前移到了类型转换阶段。代码审查时也更容易判断:只要看到分页参数经过 parseInt 或 filter_var 并通过范围校验,就可以认为这一处拼接没有注入风险。
四、参数校验之外还需要注意什么
分页接口往往不只是接收 offset 和 pageSize,还会包含排序字段和排序方向。排序字段不能像分页参数那样简单转型,因为它本质上是列名,必须使用白名单。把所有允许排序的列维护在集合中,客户端传入的字段不在白名单内就直接回退到默认列。
下面是一个排序字段白名单的 Java 示例:
private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "title", "created_at");
private static final Set<String> ALLOWED_DIRECTIONS = Set.of("ASC", "DESC");
public static String resolveOrderBy(String column, String direction) {
String safeColumn = ALLOWED_COLUMNS.contains(column) ? column : "id";
String safeDirection = ALLOWED_DIRECTIONS.contains(direction != null ? direction.toUpperCase() : "DESC")
? direction.toUpperCase()
: "DESC";
return safeColumn + " " + safeDirection;
}
此外,数据库账户应遵循最小权限原则。即使应用层出现遗漏,使用只授予 SELECT 权限的账号也能降低注入后的影响范围。对于偏移量特别大的请求,可以在网关层或缓存层提前拒绝,避免数据库执行无意义的深分页。生产环境中还可以记录所有分页参数校验失败的请求,作为潜在攻击行为的告警来源。
强制转型解决的是分页参数自身的安全问题,但它不能替代对其他动态 SQL 片段的防护。表名、列名、排序方向以及任何无法用占位符绑定的部分,都需要单独设计白名单或使用框架提供的安全方法。只有把类型校验、范围限制和白名单机制组合起来,分页查询的安全边界才会相对完整。