分页本身不越权,但漏绑 tenant_id 就直接跨租户读全量数据;gorm 的 limit/offset 不自动继承租户上下文,count、preload、raw 查询等均需每层显式注入 tenant_id,否则导致越权。

分页本身不越权,但漏绑 tenant_id 就直接跨租户读全量数据——GORM 的 Limit/Offset 不自动继承租户上下文,必须每层显式注入。
为什么分页查询最容易漏掉 tenant_id
GORM 的 Limit 和 Offset 是链式调用的末端操作,它们不感知你前面是否用了 Scopes(ByTenant(tenantID)),更不会自动透传到 Count 或 Preload 中。常见错误现象包括:
-
db.Scopes(ByTenant("t-abc")).Limit(10).Offset(0).Find(&users)看似安全,但Count()若没同样加Scopes,就查的是全租户总数 -
Preload("Orders")即使主表加了租户条件,关联表仍裸查,Orders里所有租户的数据都可能被拉出来 - 手写
Raw("SELECT * FROM users ... LIMIT ? OFFSET ?")时,连WHERE tenant_id = ?都没写,等于直接暴露全表
Count 必须和主查询完全隔离且带租户条件
db.Where("status = ?", "active").Limit(10).Offset(20).Count(&total) 返回的 total 永远 ≤ 10 —— 因为 Count 复用了前面的 Limit/Offset。这不是 bug,是 GORM v2 的行为设计。
- 正确做法:用独立
*gorm.DB实例执行总数查询,且必须显式带租户过滤:db.Where("tenant_id = ? AND status = ?", tenantID, "active").Model(&User{}).Count(&total) - 别依赖
RowsAffected:它只返回当前页条数,不是总记录数 - 复杂 JOIN 场景下,
Count易出错,建议手写子查询:db.Raw("SELECT COUNT(*) FROM (SELECT 1 FROM users u JOIN profiles p ON u.id = p.user_id WHERE u.tenant_id = ?) AS t", tenantID).Scan(&total)
分页参数校验 + 排序强制绑定是防越权的最后防线
前端传来的 page 和 page_size 若未校验,可能触发 Offset 负数、超大值,导致查空、panic 或拖垮数据库;而无 Order 的分页在并发写入下会漏数据或重复,间接造成租户数据错乱。
-
page小于 1 时强制设为 1,page_size超过上限(如 100)就截断,避免被刷库 - 排序字段必须确定且带索引:
Order("created_at DESC, id DESC")比单用created_at更稳,防止时间重复导致行序漂移 - 禁止
Order("RAND()")或动态拼接排序字段,若需可选排序,字段名必须白名单校验:if !validSortFields[sortField] { return err } - 游标分页更适合大数据量场景:用上一页末尾的
created_at和id做条件,绕过OFFSET性能陷阱,也天然规避部分租户条件遗漏风险
Preload 关联分页必须手动补租户条件
Preload("Orders") 默认完全不继承主查询的 tenant_id 条件,这是多租户下最隐蔽的 P0 漏洞点之一。即使主表查的是租户 A 的用户,Orders 关联加载的仍是全库订单。
- 不能写:
db.Scopes(ByTenant(tenantID)).Preload("Orders").Find(&users) - 必须显式传 scope:
db.Scopes(ByTenant(tenantID)).Preload("Orders", ByTenant(tenantID)).Find(&users) - 如果
Orders还有关联Items,就得继续链式传:Preload("Orders.Items", ByTenant(tenantID)) - 更稳妥的做法是封装 DAO 方法:
userDAO.WithOrders(tenantID).WithItems(tenantID).Paginate(page, size),把租户注入收口到方法签名里,避免漏传
真正兜底的不是“写了分页”,而是每一条 SQL 的 WHERE 子句里是否稳定出现 tenant_id = ? —— 它得出现在主表、关联表、总数查询、软删除判断、甚至后台任务的 Raw 查询里。少一次,就是一次越权。











