From 6ed25e8e4b67bcd2ddccd2be4f570a1d1878663b Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Mon, 21 Sep 2026 11:14:48 +0800 Subject: [PATCH] =?UTF-8?q?fix(ratelimit):=20=E5=BC=BA=E5=88=B6=E6=B8=85?= =?UTF-8?q?=E7=90=86=E6=8C=89=E6=9C=80=E6=97=A7=E6=B4=BB=E8=B7=83=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E6=8E=92=E5=BA=8F,=E9=81=BF=E5=85=8D=E9=9A=8F?= =?UTF-8?q?=E6=9C=BA=E5=88=A0=E6=B4=BB=E8=B7=83=E7=94=A8=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原版用 'for k := range rl.requests' 直接删除超限条目,Go map 遍历顺序 随机,可能命中 timestamp 仍在窗口内的活跃用户,导致其下次请求拿到新配额 (限流语义被破坏)。 同时存在 abuse vector: 攻击者用大量伪造 IP 撑爆 map 触发清理,反而 '清洗'掉自己留的活跃条目,绕过限流。 修复: - 收集 (key, lastTs) 到 slice,按 lastTs 升序排(最旧在前) - lastTs 取 timestamps 末尾(Allow 总 append 到末尾,即最近请求时间) - 从前往后逐个 delete,直到 len(rl.requests) <= MaxRateLimiterEntries/2 - 阈值/触发条件/warning 日志保持不变 复杂度 O(n log n),仅在超 MaxRateLimiterEntries(10w)时触发,代价可接受。 cleanup 仍在 Allow 入口的 rl.mutex.Lock() 区域内,无需额外锁。 --- middleware/ratelimit.go | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go index 8cddfc4..9a98c23 100644 --- a/middleware/ratelimit.go +++ b/middleware/ratelimit.go @@ -5,6 +5,7 @@ import ( "net" "net/http" "os" + "sort" "strconv" "strings" "sync" @@ -128,11 +129,32 @@ func (rl *RateLimiter) cleanup() { if len(rl.requests) > common.MaxRateLimiterEntries { log.Printf("警告: 限流器条目数 %d 超过上限 %d,触发强制清理", len(rl.requests), common.MaxRateLimiterEntries) - for k := range rl.requests { + // 【修复】原版用 `for k := range rl.requests` 删,Go map 遍历顺序随机, + // 会随机删掉活跃用户(其条目 timestamp 仍在窗口内),导致该用户下次请求 + // 拿到新配额 — 攻击者可用大量伪造 IP 撑爆 map 触发清理,反而"清洗" + // 自己留的活跃条目,绕过限流。 + // 修复:按"最近一次请求时间(lastTs)"升序排序,删最旧的(最可能已离开/低频), + // 保留最活跃用户,语义符合"限流器只淘汰冷条目"的预期。 + // 排序复杂度 O(n log n),但只在超 10w 条目时触发,代价可接受。 + type entry struct { + key string + lastTs time.Time + } + entries := make([]entry, 0, len(rl.requests)) + for k, v := range rl.requests { + // 走到这里 v 一定非空(cleanup 第一阶段会把空 timestamps 删掉), + // 取最后一个 timestamp 作为"最近活跃时间"。 + lastTs := v[len(v)-1] + entries = append(entries, entry{key: k, lastTs: lastTs}) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].lastTs.Before(entries[j].lastTs) + }) + for _, e := range entries { if len(rl.requests) <= common.MaxRateLimiterEntries/2 { break } - delete(rl.requests, k) + delete(rl.requests, e.key) } } }