fix(posts): 钳制 page 上限,防止无界 OFFSET 与缓存键扇出

clamp_pagination 原先仅限制 per_page,page 无上限。攻击者可用海量不同
page 值撑大缓存键空间(缓存污染)并触发无意义的超大 OFFSET 扫描。新增
MAX_PAGE=10_000,对任何实际博客都足够宽裕(配合 MAX_PER_PAGE 最多覆盖
50 万篇文章),同时把缓存键空间限制在有限范围。
This commit is contained in:
xfy 2026-06-15 11:38:24 +08:00
parent 6d32664020
commit eaa7118e09

View File

@ -20,11 +20,21 @@ use crate::db::pool::get_conn;
/// 攻击者可传入巨大值迫使数据库扫描并实例化超大 Vec造成内存放大与拒绝服务。 /// 攻击者可传入巨大值迫使数据库扫描并实例化超大 Vec造成内存放大与拒绝服务。
const MAX_PER_PAGE: i32 = 50; const MAX_PER_PAGE: i32 = 50;
/// 将分页参数钳制到安全范围:页码至少为 1每页 1`MAX_PER_PAGE`。 /// 允许的最大页码。
///
/// `page` 无上限时,攻击者可用海量不同 `page` 值撑大缓存键空间(缓存污染),
/// 并触发无意义的超大 `OFFSET` 扫描。10_000 对任何实际博客都足够宽裕
/// (配合 `MAX_PER_PAGE` 最多覆盖 50 万篇文章),同时把缓存键空间限制在有限范围。
const MAX_PAGE: i32 = 10_000;
/// 将分页参数钳制到安全范围:页码 1`MAX_PAGE`,每页 1`MAX_PER_PAGE`。
/// ///
/// 注意:返回值必须同时用于缓存键与 SQL 查询,避免同一逻辑页落入不同缓存条目。 /// 注意:返回值必须同时用于缓存键与 SQL 查询,避免同一逻辑页落入不同缓存条目。
fn clamp_pagination(page: i32, per_page: i32) -> (i32, i32) { fn clamp_pagination(page: i32, per_page: i32) -> (i32, i32) {
(page.max(1), per_page.clamp(1, MAX_PER_PAGE)) (
page.clamp(1, MAX_PAGE),
per_page.clamp(1, MAX_PER_PAGE),
)
} }
/// 获取已发布文章分页列表。 /// 获取已发布文章分页列表。
@ -243,6 +253,19 @@ mod tests {
assert_eq!(clamp_pagination(1, -100), (1, 1)); assert_eq!(clamp_pagination(1, -100), (1, 1));
} }
#[test]
fn clamp_pagination_clamps_oversized_page() {
// 巨大 page 必须被压回上限,避免无界 OFFSET 扫描与缓存键扇出。
assert_eq!(clamp_pagination(i32::MAX, 10), (MAX_PAGE, 10));
assert_eq!(clamp_pagination(MAX_PAGE + 1, 10), (MAX_PAGE, 10));
}
#[test]
fn clamp_pagination_max_page_boundary() {
assert_eq!(clamp_pagination(MAX_PAGE, 10), (MAX_PAGE, 10));
assert_eq!(clamp_pagination(MAX_PAGE - 1, 10), (MAX_PAGE - 1, 10));
}
#[test] #[test]
fn clamp_pagination_max_per_page_boundary() { fn clamp_pagination_max_per_page_boundary() {
assert_eq!(clamp_pagination(1, MAX_PER_PAGE), (1, MAX_PER_PAGE)); assert_eq!(clamp_pagination(1, MAX_PER_PAGE), (1, MAX_PER_PAGE));