diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index 388eb0d..19999c3 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -17,6 +17,15 @@ type bucket struct { updated time.Time } +// maxBuckets 是 buckets map 的硬上限——防御性 backstop:哪怕 SetTrustedProxies(nil) +// 已经堵死"伪造 X-Forwarded-For 绕过限流"这条路,socket 对端 IP 本身也可能被 +// 大量真实/伪装源地址(如僵尸网络)刷出海量不同 IP,惰性 sweep(见 sweepLocked) +// 只回收超过 10min 不活跃的桶,短时间集中攻击仍可能在 sweep 生效前把 map 撑爆。 +// 100k 条 * 单桶几十字节量级,内存占用可控(几 MB),对当前单机部署(阿里云, +// ~1-2GB 内存假设,与 pangolin 单机 Runbook 同量级)足够安全。超限时宁可误限 +// (安全侧),也不无界建桶 OOM。 +const maxBuckets = 100_000 + // IPRateLimiter 简单内存令牌桶,按客户端 IP 分桶限速——单实例部署足够(jiu 反馈波, // P3 v2 限流纵深)。多实例横向扩展需换外置存储(Redis INCR+EXPIRE 等)统一计数, // 当前 pay 只跑单进程,内存方案够用且零额外依赖。 @@ -47,6 +56,16 @@ func (l *IPRateLimiter) allow(ip string) bool { l.mu.Lock() b, ok := l.buckets[ip] if !ok { + if len(l.buckets) >= maxBuckets { + // map 已到硬上限:先尝试一次 sweep 腾陈旧桶的位置。 + l.sweepLocked(now) + } + if len(l.buckets) >= maxBuckets { + // sweep 后仍超限(短时间内大量活跃桶):拒绝新建桶,按限流命中处理 + // (429)而不是无界增长——安全侧,宁可误限未知新 IP 也不 OOM。 + l.mu.Unlock() + return false + } b = &bucket{tokens: l.burst, updated: now} l.buckets[ip] = b } diff --git a/internal/middleware/ratelimit_internal_test.go b/internal/middleware/ratelimit_internal_test.go new file mode 100644 index 0000000..6c7dcbb --- /dev/null +++ b/internal/middleware/ratelimit_internal_test.go @@ -0,0 +1,40 @@ +package middleware + +import ( + "fmt" + "testing" +) + +// jiu 反馈波安全 review item 2:攻击者可用无限伪造 IP(哪怕已按 SetTrustedProxies(nil) +// 锁死了 XFF 伪造这条路,真实的分布式来源/僵尸网络仍能刷出海量不同 socket 源 IP)撑爆 +// buckets map。这条白盒测试直接灌入超过 maxBuckets 的不同 IP,断言 map 有硬上限、 +// 不会无限增长,且超限后的新 IP 被安全拒绝(而不是继续建桶或 panic)。 +func TestIPRateLimiterBucketMapBounded(t *testing.T) { + rl := NewIPRateLimiter(1) + + for i := 0; i < maxBuckets; i++ { + rl.allow(fmt.Sprintf("spoofed-ip-%d", i)) + } + + rl.mu.Lock() + sizeAtCap := len(rl.buckets) + rl.mu.Unlock() + if sizeAtCap != maxBuckets { + t.Fatalf("灌满硬上限后 map size 应等于 maxBuckets(%d), got %d", maxBuckets, sizeAtCap) + } + + // 硬上限已满、且都是刚创建的活跃桶(sweep 回收不了任何一个):新 IP 应被拒绝 + // (安全侧,宁可误限也不无界建桶),不应放行/建桶。 + for i := 0; i < 100; i++ { + if rl.allow(fmt.Sprintf("overflow-ip-%d", i)) { + t.Fatalf("硬上限已满时,超限的新 IP(overflow-ip-%d)应被拒绝而非放行", i) + } + } + + rl.mu.Lock() + sizeAfterOverflow := len(rl.buckets) + rl.mu.Unlock() + if sizeAfterOverflow > maxBuckets { + t.Fatalf("超限请求后 buckets map 不应继续增长: size=%d max=%d", sizeAfterOverflow, maxBuckets) + } +} diff --git a/internal/middleware/ratelimit_test.go b/internal/middleware/ratelimit_test.go index eb1ab79..ff46475 100644 --- a/internal/middleware/ratelimit_test.go +++ b/internal/middleware/ratelimit_test.go @@ -1,6 +1,7 @@ package middleware_test import ( + "fmt" "net/http" "net/http/httptest" "testing" @@ -67,6 +68,66 @@ func TestIPRateLimiterPerIPIsolated(t *testing.T) { } } +// jiu 反馈波安全 review:gin 默认 trustedProxies=0.0.0.0/0,不显式 SetTrustedProxies(nil) +// 的话 c.ClientIP() 会信任客户端可控的 X-Forwarded-For——攻击者每次伪造不同 XFF 即可 +// 让限流器把每次请求都当"新 IP"的新桶,burst 永不触发(绕过限流)。 +// 这条测试用同一个 RemoteAddr(模拟同一 socket 源)、每次带不同的伪造 XFF 头发请求: +// - 修复前(未调用 SetTrustedProxies(nil),沿用 gin 默认信任所有代理):ClientIP() 读 +// 伪造的 XFF,每次都是"新 IP"→ 新桶 → 全部放行,本测试会失败(RED)。 +// - 修复后(main.go 起 engine 时调用 r.SetTrustedProxies(nil)):ClientIP() 退回 +// RemoteIP()(socket 对端地址,请求头伪造不了)→ 同一来源共用一个桶 → burst 耗尽后 +// 触发 429,本测试通过(GREEN)。 +func TestIPRateLimiterSpoofedXFFCannotBypass(t *testing.T) { + gin.SetMode(gin.TestMode) + rl := middleware.NewIPRateLimiter(3) + r := gin.New() + if err := r.SetTrustedProxies(nil); err != nil { + t.Fatalf("SetTrustedProxies(nil) 失败: %v", err) + } + r.GET("/x", rl.Gin(), func(c *gin.Context) { c.Status(http.StatusOK) }) + + const sameSocketSource = "198.51.100.7:54321" + var codes []int + for i := 0; i < 4; i++ { + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.RemoteAddr = sameSocketSource + // 每次伪造一个不同的、格式合法的公网 IP——修复前的攻击手法。 + req.Header.Set("X-Forwarded-For", fmt.Sprintf("203.0.113.%d", i+1)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + codes = append(codes, w.Code) + } + if codes[0] != http.StatusOK || codes[1] != http.StatusOK || codes[2] != http.StatusOK { + t.Fatalf("同一 socket 源前 3 次(=burst)应放行, got codes=%v", codes) + } + if codes[3] != http.StatusTooManyRequests { + t.Fatalf("同一 socket 源第 4 次应共桶触发 429(伪造 XFF 不能绕过限流), got codes=%v", codes) + } +} + +// 不同 socket 源(不同 RemoteAddr)哪怕带相同的伪造 XFF,也应各自独立计桶—— +// 佐证限流键确实取自 c.ClientIP()(修复后=RemoteIP()),不是请求头。 +func TestIPRateLimiterDifferentSocketSourceIsolatedEvenWithSameXFF(t *testing.T) { + gin.SetMode(gin.TestMode) + rl := middleware.NewIPRateLimiter(1) + r := gin.New() + if err := r.SetTrustedProxies(nil); err != nil { + t.Fatalf("SetTrustedProxies(nil) 失败: %v", err) + } + r.GET("/x", rl.Gin(), func(c *gin.Context) { c.Status(http.StatusOK) }) + + for _, addr := range []string{"198.51.100.10:1111", "198.51.100.11:2222"} { + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.RemoteAddr = addr + req.Header.Set("X-Forwarded-For", "203.0.113.99") // 两次都带同一个伪造 XFF + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("socket 源 %s 首次应 200(独立桶,不受相同 XFF 影响), got %d", addr, w.Code) + } + } +} + // perMinute<=0 兜底成默认 30/min(config.RateLimitConfig 零值语义:"零值=默认开")。 func TestNewIPRateLimiterZeroDefaultsTo30(t *testing.T) { rl := middleware.NewIPRateLimiter(0) diff --git a/main.go b/main.go index dcebd5a..a655f44 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,18 @@ func main() { gin.SetMode(config.C.Server.Mode) r := gin.New() r.Use(gin.Logger(), gin.Recovery()) + // 安全review发现:当前部署(阿里云单机,无 nginx/网关前置)不存在真实反向代理, + // 但 gin 默认 trustedProxies=0.0.0.0/0(信任任意来源的 X-Forwarded-For/X-Real-IP)。 + // 不关掉的话,c.ClientIP() 会读客户端可控的请求头——攻击者每次伪造不同 XFF 即可 + // 让限流中间件(internal/middleware/ratelimit.go)把每次请求都当"新 IP"的新桶, + // burst 永不触发,且伪造 IP 数量无上限还能撑爆 buckets map。 + // SetTrustedProxies(nil) 让 engine.trustedCIDRs=nil → isTrustedProxy 恒 false → + // ClientIP() 退回 RemoteIP()(即 net/http 拿到的 socket 对端地址,请求头伪造不了)。 + // 若未来加 nginx/API 网关反代,这里要改成该反代的真实出口 IP/CIDR 列表, + // 不能再传 nil(否则又会把加固之后合法的下游 IP 直接当客户端源用)。 + if err := r.SetTrustedProxies(nil); err != nil { + log.Fatalf("SetTrustedProxies(nil) 失败: %v", err) + } orderSvc := router.Setup(r, db, reg)