fix: SECURITY_TODO #28 评论提交按 IP 限流(5 条/分钟)防灌水刷屏

评论端点未认证即可提交(默认 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”为产品决策,未随本项实施。
This commit is contained in:
dsh
2026-08-27 21:39:28 +08:00
parent 4c32267b47
commit 52ca450ddd
6 changed files with 59 additions and 8 deletions
+3 -3
View File
@@ -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 不受影响
---
+8 -1
View File
@@ -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
+39
View File
@@ -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())
}
}
+4 -2
View File
@@ -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 {
+2
View File
@@ -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": "用户不存在。",
},
}
+3 -2
View File
@@ -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))
}
// 受保护的后台路由(仅管理员角色)。