Skip to content

Commit cecc2c1

Browse files
lin-snowclaude
andcommitted
fix(echo): rate-limit + idempotent likes on public endpoint
匿名 PUT /echo/like/:id 此前无任何限流或去重,单 IP 可任意刷 fav_count 并触发 四键缓存失效(GHSA-pj6q-4vq4-r8cg)。新增通用 RateLimitWithIdempotency 中间件 组合两层防御:单 IP 令牌桶限速(2 rps / 5 burst)+ (IP, echoID) 维度的小时级 幂等窗口;窗口内重复请求按幂等处理,返回与正常成功路径形状一致的响应,客户端 无感知。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fd320fe commit cecc2c1

2 files changed

Lines changed: 121 additions & 14 deletions

File tree

internal/middleware/ratelimit.go

Lines changed: 106 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -65,29 +65,122 @@ func (rl *rateLimiter) allow(key string) bool {
6565

6666
func RateLimit(rps, burst int) gin.HandlerFunc {
6767
limiter := newRateLimiter(rps, burst)
68+
startBucketGC(limiter, 5*time.Minute, 10*time.Minute)
6869

70+
return func(c *gin.Context) {
71+
key := c.ClientIP()
72+
if !limiter.allow(key) {
73+
c.JSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
74+
c.Abort()
75+
return
76+
}
77+
c.Next()
78+
}
79+
}
80+
81+
// RateLimitWithIdempotency 在 RateLimit 基础上叠加幂等窗口,适用于"写多读少且天然幂等"的接口
82+
// (例如点赞)。
83+
//
84+
// - 单 IP 令牌桶限速:超过阈值返回 429。
85+
// - (IP, 资源 ID) 维度的去重窗口:同一 IP 在 dedupTTL 内对同一资源的请求被视作已处理,
86+
// 调用 onIdempotent 写出响应并中止后续处理,避免重复请求触发数据库写入与缓存失效。
87+
//
88+
// resourceParam 是 gin 路径参数名(如 "id")。当 IP 或资源 ID 缺失时跳过去重检查,
89+
// 由 handler 进行业务校验并返回业务错误,避免幂等逻辑掩盖参数问题。
90+
//
91+
// onIdempotent 必须自行写出响应并 Abort;推荐返回与正常成功路径形状一致的响应,
92+
// 使客户端无感知。
93+
func RateLimitWithIdempotency(
94+
rps, burst int,
95+
dedupTTL time.Duration,
96+
resourceParam string,
97+
onIdempotent gin.HandlerFunc,
98+
) gin.HandlerFunc {
99+
limiter := newRateLimiter(rps, burst)
100+
dedup := newIdempotencyStore(dedupTTL)
101+
102+
gcInterval := max(dedupTTL, time.Minute)
103+
startBucketGC(limiter, gcInterval, 10*time.Minute)
104+
startIdempotencyGC(dedup, gcInterval)
105+
106+
return func(c *gin.Context) {
107+
ip := c.ClientIP()
108+
if !limiter.allow(ip) {
109+
c.JSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
110+
c.Abort()
111+
return
112+
}
113+
114+
resourceID := c.Param(resourceParam)
115+
if ip != "" && resourceID != "" && !dedup.acquire(ip+"|"+resourceID, time.Now()) {
116+
onIdempotent(c)
117+
c.Abort()
118+
return
119+
}
120+
121+
c.Next()
122+
}
123+
}
124+
125+
func startBucketGC(rl *rateLimiter, interval, idle time.Duration) {
69126
go func() {
70-
ticker := time.NewTicker(5 * time.Minute)
127+
ticker := time.NewTicker(interval)
71128
defer ticker.Stop()
72129
for range ticker.C {
73-
limiter.mu.Lock()
74-
cutoff := time.Now().Add(-10 * time.Minute)
75-
for k, b := range limiter.buckets {
130+
rl.mu.Lock()
131+
cutoff := time.Now().Add(-idle)
132+
for k, b := range rl.buckets {
76133
if b.lastTime.Before(cutoff) {
77-
delete(limiter.buckets, k)
134+
delete(rl.buckets, k)
78135
}
79136
}
80-
limiter.mu.Unlock()
137+
rl.mu.Unlock()
81138
}
82139
}()
140+
}
83141

84-
return func(c *gin.Context) {
85-
key := c.ClientIP()
86-
if !limiter.allow(key) {
87-
c.JSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
88-
c.Abort()
89-
return
142+
// idempotencyStore 维护 (IP, 资源 ID) → 最近一次命中时间的映射,由后台 goroutine
143+
// 周期性回收过期条目,使内存占用与活跃来源数成正比而非历史累积。
144+
type idempotencyStore struct {
145+
mu sync.Mutex
146+
seen map[string]time.Time
147+
ttl time.Duration
148+
}
149+
150+
func newIdempotencyStore(ttl time.Duration) *idempotencyStore {
151+
return &idempotencyStore{
152+
seen: make(map[string]time.Time),
153+
ttl: ttl,
154+
}
155+
}
156+
157+
// acquire 在窗口内未命中时记录并返回 true(允许放行);命中时返回 false(应作幂等处理)。
158+
func (s *idempotencyStore) acquire(key string, now time.Time) bool {
159+
s.mu.Lock()
160+
defer s.mu.Unlock()
161+
if t, ok := s.seen[key]; ok && now.Sub(t) < s.ttl {
162+
return false
163+
}
164+
s.seen[key] = now
165+
return true
166+
}
167+
168+
func (s *idempotencyStore) gc(now time.Time) {
169+
s.mu.Lock()
170+
defer s.mu.Unlock()
171+
for k, t := range s.seen {
172+
if now.Sub(t) >= s.ttl {
173+
delete(s.seen, k)
90174
}
91-
c.Next()
92175
}
93176
}
177+
178+
func startIdempotencyGC(s *idempotencyStore, interval time.Duration) {
179+
go func() {
180+
ticker := time.NewTicker(interval)
181+
defer ticker.Stop()
182+
for range ticker.C {
183+
s.gc(time.Now())
184+
}
185+
}()
186+
}

internal/router/echo.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,29 @@
44
package router
55

66
import (
7+
"net/http"
8+
"time"
9+
10+
"github.com/gin-gonic/gin"
711
"github.com/lin-snow/ech0/internal/handler"
812
"github.com/lin-snow/ech0/internal/middleware"
913
authModel "github.com/lin-snow/ech0/internal/model/auth"
14+
commonModel "github.com/lin-snow/ech0/internal/model/common"
1015
)
1116

1217
// setupEchoRoutes 设置Echo路由
1318
func setupEchoRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) {
1419
// Public
15-
appRouterGroup.PublicRouterGroup.PUT("/echo/like/:id", h.EchoHandler.LikeEcho())
20+
// 点赞接口保持匿名可访问,但叠加 IP 维度的限速 + (IP, echoID) 维度的去重窗口,
21+
// 防止匿名调用方反复刷 fav_count、放大数据库与缓存压力。窗口内的重复请求按
22+
// 幂等处理,返回与正常成功路径形状一致的响应。
23+
appRouterGroup.PublicRouterGroup.PUT(
24+
"/echo/like/:id",
25+
middleware.RateLimitWithIdempotency(2, 5, time.Hour, "id", func(c *gin.Context) {
26+
c.JSON(http.StatusOK, commonModel.OK[any](nil, commonModel.LIKE_ECHO_SUCCESS))
27+
}),
28+
h.EchoHandler.LikeEcho(),
29+
)
1630
appRouterGroup.PublicRouterGroup.GET("/tags", h.EchoHandler.GetAllTags())
1731

1832
// Auth

0 commit comments

Comments
 (0)