From 52ca450ddd14606a76dcce8d29ff06bf0a76e755 Mon Sep 17 00:00:00 2001 From: dsh Date: Thu, 27 Aug 2026 21:39:28 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20SECURITY=5FTODO=20#28=20=E8=AF=84?= =?UTF-8?q?=E8=AE=BA=E6=8F=90=E4=BA=A4=E6=8C=89=20IP=20=E9=99=90=E6=B5=81?= =?UTF-8?q?=EF=BC=885=20=E6=9D=A1/=E5=88=86=E9=92=9F=EF=BC=89=E9=98=B2?= =?UTF-8?q?=E7=81=8C=E6=B0=B4=E5=88=B7=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评论端点未认证即可提交(默认 AllowGuest=true 且即时公开),配合默认 GuestRequireApproval=false 开箱即用状态可被灌水机刷屏。PostComment 复用 #27 的 WindowRateLimiter 按 IP 限流 5 条/分钟,超限 429 + i18n comments_locked(中英)。 - handlers/comment.go: PostComment(db, limiter),键前缀区分 - main.go / security_test.go: 评论限流器接线 - 测试: TestCommentRateLimited(同 IP 5 次成功、第 6 次 429、 其他 IP 不受影响) 注: 可选项“新部署默认 GuestRequireApproval=true”为产品决策,未随本项实施。 --- SECURITY_TODO.md | 6 +++--- handlers/comment.go | 9 ++++++++- handlers/ratelimit_test.go | 39 ++++++++++++++++++++++++++++++++++++++ handlers/security_test.go | 6 ++++-- i18n/i18n.go | 2 ++ main.go | 5 +++-- 6 files changed, 59 insertions(+), 8 deletions(-) diff --git a/SECURITY_TODO.md b/SECURITY_TODO.md index 17058d4..07089f8 100644 --- a/SECURITY_TODO.md +++ b/SECURITY_TODO.md @@ -133,13 +133,13 @@ - [x] Register 按 IP 限流注册(10 次/小时/IP),超限 429 + i18n 新增 `register_locked`(中英) - **验证**: ✅ `TestRegisterRateLimited`(同 IP 连续 10 次成功、第 11 次 429/register_locked、其他 IP 不受影响)、`TestWindowLimiterFixedWindow`(窗口内超限拒绝 / 窗口翻转重置 / 键隔离) -### [ ] 28. 评论提交无速率限制(2026-08-27 API 化复审新发现) +### [x] 28. 评论提交无速率限制(2026-08-27 API 化复审新发现)✅ 2026-08-27 - **位置**: `handlers/comment.go`(PostComment)、`models/seed.go`(默认策略) - **问题**: 未认证即可提交评论(默认 `AllowGuest=true`),且无任何频率限制;配合默认 `GuestRequireApproval=false`(即时公开显示),开箱即用状态可被灌水机刷屏,同时放大 #26 的攻击面。 - **修复**: - - [ ] PostComment 按 IP 限流评论提交(5 条/分钟/IP),超限 429 + i18n 新增 `comments_locked`(中英) + - [x] PostComment 按 IP 限流评论提交(5 条/分钟/IP,复用 #27 的 `WindowRateLimiter`),超限 429 + i18n 新增 `comments_locked`(中英) - [ ] (可选,产品决策)新部署默认 `GuestRequireApproval=true`——留待产品确认,未随本项实施 -- **验证**: [ ] 测试:同 IP 高频提交 → 429;正常节奏不受影响 +- **验证**: ✅ `TestCommentRateLimited`(同 IP 5 次成功、第 6 次 429/comments_locked、其他 IP 不受影响) --- diff --git a/handlers/comment.go b/handlers/comment.go index 68cdb2f..62387b0 100644 --- a/handlers/comment.go +++ b/handlers/comment.go @@ -90,8 +90,15 @@ func emailHash(email string) string { } // PostComment 处理在文章上提交新评论(或回复)。 -func PostComment(db *gorm.DB) gin.HandlerFunc { +// 它对每个 IP 实施速率限制(SECURITY_TODO #28),防止灌水机刷屏。 +func PostComment(db *gorm.DB, limiter *WindowRateLimiter) gin.HandlerFunc { return func(c *gin.Context) { + // SECURITY_TODO #28:按 IP 限流评论提交(5 条/分钟),超限 429。 + if !limiter.Allow("comment\x00" + GetClientIP(c)) { + APIError(c, http.StatusTooManyRequests, "comments_locked") + return + } + slug := c.Param("slug") var article models.Article diff --git a/handlers/ratelimit_test.go b/handlers/ratelimit_test.go index 7db3180..e547ce1 100644 --- a/handlers/ratelimit_test.go +++ b/handlers/ratelimit_test.go @@ -111,3 +111,42 @@ func TestRegisterRateLimited(t *testing.T) { t.Fatalf("register from other IP: status = %d, body %s", w.Code, w.Body.String()) } } + +// TestCommentRateLimited 覆盖 SECURITY_TODO #28:同 IP 高频提交评论超过 +// 阈值(5 条/分钟)后返回 429/comments_locked;其他 IP 不受影响。 +func TestCommentRateLimited(t *testing.T) { + e := newSecurityTestEnv(t) + + comment := func(ip string) *httptest.ResponseRecorder { + cookie, token := guestSessionAndToken(e) + if token == "" { + t.Fatal("login page did not render a CSRF token") + } + return postJSONFrom(e, http.MethodPost, "/api/article/alice-post/comments", cookie, token, ip, gin.H{ + "name": "guest", + "email": "guest@example.com", + "content": "nice post", + }) + } + + const ipA = "198.51.100.20" + for i := 0; i < commentLimitPerMin; i++ { + w := comment(ipA) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("comment %d: status = %d, body %s", i+1, w.Code, w.Body.String()) + } + } + + // 第六次提交被限流。 + w := comment(ipA) + if w.Code != http.StatusTooManyRequests || respCode(w) != "comments_locked" { + t.Fatalf("rate-limited comment: status = %d, code = %q, want 429/comments_locked", + w.Code, respCode(w)) + } + + // 其他 IP 不受影响。 + w = comment("198.51.100.21") + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("comment from other IP: status = %d, body %s", w.Code, w.Body.String()) + } +} diff --git a/handlers/security_test.go b/handlers/security_test.go index e76d9da..7df2cc4 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -34,6 +34,7 @@ type securityTestEnv struct { storageDir string limiter *LoginRateLimiter registerLimiter *WindowRateLimiter + commentLimiter *WindowRateLimiter } var csrfTokenRe = regexp.MustCompile(`name="_csrf" value="([^"]+)"`) @@ -80,6 +81,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { store := cookie.NewStore([]byte("test-secret")) limiter := NewLoginLimiter() registerLimiter := NewWindowLimiter(registerLimitPerHour, registerWindow) + commentLimiter := NewWindowLimiter(commentLimitPerMin, commentWindow) r.Use(sessions.Sessions("blog_session", store)) r.Use(middleware.SetUserContext(db)) r.Use(middleware.BodyLimit()) @@ -94,7 +96,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { api.POST("/auth/login", Login(db, limiter)) api.POST("/auth/logout", Logout()) api.POST("/auth/register", Register(db, registerLimiter)) - api.POST("/article/:slug/comments", PostComment(db)) + api.POST("/article/:slug/comments", PostComment(db, commentLimiter)) } protected := r.Group("/my", middleware.AuthRequired(db)) @@ -150,7 +152,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { } return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter, - registerLimiter: registerLimiter} + registerLimiter: registerLimiter, commentLimiter: commentLimiter} } func mustUser(t *testing.T, db *gorm.DB, username, role string) models.User { diff --git a/i18n/i18n.go b/i18n/i18n.go index 7adf235..2a8e320 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -452,6 +452,7 @@ var translations = map[Lang]map[string]string{ "api_invalid_request": "Invalid request body.", "request_too_large": "The request body exceeds the size limit.", "register_locked": "Too many registration attempts from your address. Please try again later.", + "comments_locked": "Too many comments from your address. Please wait a moment and try again.", "user_not_found": "User not found.", }, ZH: { @@ -890,6 +891,7 @@ var translations = map[Lang]map[string]string{ "api_invalid_request": "请求参数格式不正确。", "request_too_large": "请求体超过大小限制。", "register_locked": "来自该地址的注册次数过多,请稍后再试。", + "comments_locked": "评论提交过于频繁,请稍后再试。", "user_not_found": "用户不存在。", }, } diff --git a/main.go b/main.go index 51ed63c..c19793c 100644 --- a/main.go +++ b/main.go @@ -164,9 +164,10 @@ func main() { // 签名包含 db 与 loginLimiter,但注册阶段不会触碰它们(handler 是惰性工厂), // 因此冒烟测试可传 nil。 func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLimiter *handlers.LoginRateLimiter) { - // 注册限流器(SECURITY_TODO #27):固定窗口、进程内存、map 有界。 + // 注册/评论限流器(SECURITY_TODO #27/#28):固定窗口、进程内存、map 有界。 // 单实例部署无需共享存储。 registerLimiter := handlers.NewWindowLimiter(10, time.Hour) + commentLimiter := handlers.NewWindowLimiter(5, time.Minute) // 公开页面。 router.GET("/", handlers.HomePage(db)) @@ -184,7 +185,7 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi api.POST("/auth/login", handlers.Login(db, loginLimiter)) api.POST("/auth/register", handlers.Register(db, registerLimiter)) api.POST("/auth/logout", handlers.Logout()) - api.POST("/article/:slug/comments", handlers.PostComment(db)) + api.POST("/article/:slug/comments", handlers.PostComment(db, commentLimiter)) } // 受保护的后台路由(仅管理员角色)。