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:
+3
-3
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": "用户不存在。",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
// 受保护的后台路由(仅管理员角色)。
|
||||
|
||||
Reference in New Issue
Block a user