diff --git a/SECURITY_TODO.md b/SECURITY_TODO.md index 51a1a99..17058d4 100644 --- a/SECURITY_TODO.md +++ b/SECURITY_TODO.md @@ -125,19 +125,20 @@ - [x] 附带修复:processAvatar 依赖的 png/gif 解码器此前未注册(旧代码靠"失败回退"掩盖),补 blank import - **验证**: ✅ `TestAddUploadFileTypeRejectsDangerousExtensions`(6 组危险扩展拒绝 + .md 正常)、`TestUploadAvatarRejectsNonImage`(.html 拒绝 / 图片扩展名包 HTML 拒绝 / 正常 PNG 转存 .jpg)、`TestUpdateProfileAvatarRejectsNonImage`(表单头像同样拒绝 + 正常图片成功) -### [ ] 27. /api/auth/register 无速率限制——批量注册垃圾账户(2026-08-27 API 化复审新发现) +### [x] 27. /api/auth/register 无速率限制——批量注册垃圾账户(2026-08-27 API 化复审新发现)✅ 2026-08-27 - **位置**: `handlers/auth.go`(Register)、`handlers/login_ratelimit.go` - **问题**: 登录有限流器(#10,IP+用户名),注册完全没有。`allow_registration` 开启时机器人可无限批量注册用户;用户名唯一性检查与 bcrypt cost 12 都不构成成本屏障(注册不触发任何限流计数)。 - **修复**: - - [ ] 复用 LoginRateLimiter 思路,按 IP 限流注册(建议 10 次/小时/IP,键加前缀区分 login/register),超限 429 + i18n 新增 `register_locked` -- **验证**: [ ] 测试:同 IP 连续注册超阈值 → 429;其他 IP 不受影响;正常注册成功 + - [x] 新增 `handlers/rate_limit.go` `WindowRateLimiter`:固定窗口计数限流器(与 #10 相同的有界 map + 淘汰策略,键前缀区分 login/register) + - [x] Register 按 IP 限流注册(10 次/小时/IP),超限 429 + i18n 新增 `register_locked`(中英) +- **验证**: ✅ `TestRegisterRateLimited`(同 IP 连续 10 次成功、第 11 次 429/register_locked、其他 IP 不受影响)、`TestWindowLimiterFixedWindow`(窗口内超限拒绝 / 窗口翻转重置 / 键隔离) ### [ ] 28. 评论提交无速率限制(2026-08-27 API 化复审新发现) - **位置**: `handlers/comment.go`(PostComment)、`models/seed.go`(默认策略) - **问题**: 未认证即可提交评论(默认 `AllowGuest=true`),且无任何频率限制;配合默认 `GuestRequireApproval=false`(即时公开显示),开箱即用状态可被灌水机刷屏,同时放大 #26 的攻击面。 - **修复**: - - [ ] 按 IP(或 IP+文章)限流评论提交(建议 5 条/分钟/IP),超限 429 + i18n 新增 `comments_locked` - - [ ] (可选,产品决策)新部署默认 `GuestRequireApproval=true` + - [ ] PostComment 按 IP 限流评论提交(5 条/分钟/IP),超限 429 + i18n 新增 `comments_locked`(中英) + - [ ] (可选,产品决策)新部署默认 `GuestRequireApproval=true`——留待产品确认,未随本项实施 - **验证**: [ ] 测试:同 IP 高频提交 → 429;正常节奏不受影响 --- diff --git a/handlers/auth.go b/handlers/auth.go index 3a5b2ea..425e727 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -152,9 +152,17 @@ type registerRequest struct { DisplayName string `json:"display_name"` } -// Register 处理注册表单提交。 -func Register(db *gorm.DB) gin.HandlerFunc { +// Register 处理注册表单提交。它对每个 IP 实施速率限制 +// (SECURITY_TODO #27),防止批量注册垃圾账户。 +func Register(db *gorm.DB, limiter *WindowRateLimiter) gin.HandlerFunc { return func(c *gin.Context) { + // SECURITY_TODO #27:按 IP 限流注册(10 次/小时), + // 键加前缀与登录限流器区分。超限直接 429,不执行任何数据库工作。 + if !limiter.Allow("register\x00" + GetClientIP(c)) { + APIError(c, http.StatusTooManyRequests, "register_locked") + return + } + // 检查是否允许注册 var s models.SiteSetting if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration { diff --git a/handlers/rate_limit.go b/handlers/rate_limit.go new file mode 100644 index 0000000..2ff1477 --- /dev/null +++ b/handlers/rate_limit.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "sync" + "time" +) + +// 注册/评论的固定窗口限流参数(SECURITY_TODO #27/#28)。 +// 与 LoginRateLimiter 一样为进程内存实现:应用是单实例部署 +// (反向代理后的 unix socket),因此无需共享存储。 +const ( + registerLimitPerHour = 10 // 每 IP 每小时的注册上限 + registerWindow = time.Hour // 注册计数窗口 + commentLimitPerMin = 5 // 每 IP 每分钟的评论上限 + commentWindow = time.Minute // 评论计数窗口 +) + +// WindowRateLimiter 是固定时间窗口计数限流器:键在窗口内最多计数 limit 次, +// 超限时 Allow 返回 false。窗口过期后计数自动重置。 +type WindowRateLimiter struct { + mu sync.Mutex + entries map[string]*windowRateEntry + limit int + window time.Duration + nowFn func() time.Time +} + +type windowRateEntry struct { + count int + windowStart time.Time + lastSeen time.Time +} + +// NewWindowLimiter 创建固定窗口限流器(limit 次/每 window)。 +func NewWindowLimiter(limit int, window time.Duration) *WindowRateLimiter { + return &WindowRateLimiter{ + entries: make(map[string]*windowRateEntry), + limit: limit, + window: window, + nowFn: time.Now, + } +} + +// Allow 报告该键是否被允许再发一次请求(调用本身计入窗口计数; +// 窗口过期时计数重置为 1)。 +func (l *WindowRateLimiter) Allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := l.nowFn() + e, ok := l.entries[key] + if !ok || now.Sub(e.windowStart) >= l.window { + // 新窗口:重置计数。 + l.entries[key] = &windowRateEntry{count: 1, windowStart: now, lastSeen: now} + l.sweep(now) + return true + } + e.count++ + e.lastSeen = now + l.sweep(now) + return e.count <= l.limit +} + +// sweep 限制映射大小,防止攻击者通过大量键令限流器无限增长 +// (与 LoginRateLimiter.sweep 同一策略)。 +func (l *WindowRateLimiter) sweep(now time.Time) { + if len(l.entries) <= maxTrackedKeys { + return + } + // 第 1 轮:移除窗口已过且不再活跃的键。 + for k, e := range l.entries { + if now.Sub(e.lastSeen) > l.window { + delete(l.entries, k) + } + } + if len(l.entries) <= maxTrackedKeys { + return + } + // 第 2 轮:若仍然过大,按 lastSeen 逐出最旧的条目。 + cut := len(l.entries) - maxTrackedKeys + maxTrackedKeys/4 + var byOldest []struct { + key string + t time.Time + } + for k, e := range l.entries { + byOldest = append(byOldest, struct { + key string + t time.Time + }{k, e.lastSeen}) + } + for i := 1; i < len(byOldest); i++ { + for j := i; j > 0 && byOldest[j].t.Before(byOldest[j-1].t); j-- { + byOldest[j], byOldest[j-1] = byOldest[j-1], byOldest[j] + } + } + for _, o := range byOldest[:cut] { + delete(l.entries, o.key) + } +} diff --git a/handlers/ratelimit_test.go b/handlers/ratelimit_test.go new file mode 100644 index 0000000..7db3180 --- /dev/null +++ b/handlers/ratelimit_test.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "go_blog/models" +) + +// TestWindowLimiterFixedWindow 单元测试固定窗口行为:窗口内超限拒绝、 +// 窗口过期后计数重置、不同键互不影响、键有界(sweep 生效)。 +func TestWindowLimiterFixedWindow(t *testing.T) { + l := NewWindowLimiter(2, time.Minute) + now := time.Unix(1_000_000, 0) + l.nowFn = func() time.Time { return now } + + for i := 0; i < 2; i++ { + if !l.Allow("a") { + t.Fatalf("attempt %d: expect allowed within limit", i+1) + } + } + if l.Allow("a") { + t.Fatal("expect blocked after limit") + } + // 其他键不受影响。 + if !l.Allow("b") { + t.Fatal("different key must not be affected") + } + + // 窗口过期后计数重置。 + now = now.Add(time.Minute + time.Second) + if !l.Allow("a") { + t.Fatal("expect allowed after window rollover") + } +} + +// guestSessionAndToken 取一个匿名会话及其 CSRF 令牌。 +func guestSessionAndToken(e *securityTestEnv) (string, string) { + w := e.do(http.MethodGet, "/login", "", nil, "") + m := csrfTokenRe.FindStringSubmatch(w.Body.String()) + if m == nil { + return e.sessionCookie(w), "" + } + return e.sessionCookie(w), m[1] +} + +// postJSONFrom 与 postJSON 相同,但可指定客户端 RemoteAddr 以模拟不同来源 IP。 +func postJSONFrom(e *securityTestEnv, method, path, cookie, csrfToken, ip string, body interface{}) *httptest.ResponseRecorder { + var buf bytes.Buffer + _ = json.NewEncoder(&buf).Encode(body) + req := httptest.NewRequest(method, path, &buf) + req.Header.Set("Content-Type", "application/json") + if csrfToken != "" { + req.Header.Set("X-CSRF-Token", csrfToken) + } + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + req.RemoteAddr = ip + ":4321" + w := httptest.NewRecorder() + e.router.ServeHTTP(w, req) + return w +} + +// TestRegisterRateLimited 覆盖 SECURITY_TODO #27:同 IP 连续注册超过 +// 阈值(10 次/小时)后返回 429/register_locked;其他 IP 不受影响。 +func TestRegisterRateLimited(t *testing.T) { + e := newSecurityTestEnv(t) + if err := e.db.Model(&models.SiteSetting{}).Where("id = ?", 1).Update("allow_registration", true).Error; err != nil { + t.Fatalf("enable registration: %v", err) + } + + register := func(username, 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/auth/register", cookie, token, ip, gin.H{ + "username": username, + "password": "secret1", + "confirm_password": "secret1", + "email": username + "@example.com", + }) + } + + const ipA = "198.51.100.10" + for i := 0; i < registerLimitPerHour; i++ { + w := register(fmt.Sprintf("reg%d", i), ipA) + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("attempt %d: status = %d, body %s", i+1, w.Code, w.Body.String()) + } + } + + // 下一次尝试(即使输入合法)被限流。 + w := register("reg-over", ipA) + if w.Code != http.StatusTooManyRequests || respCode(w) != "register_locked" { + t.Fatalf("rate-limited register: status = %d, code = %q, want 429/register_locked", + w.Code, respCode(w)) + } + + // 其他 IP 不受影响。 + w = register("reg-other", "198.51.100.11") + if w.Code != http.StatusOK || !respOK(w) { + t.Fatalf("register 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 1bc2f8a..e76d9da 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -29,10 +29,11 @@ import ( // securityTestEnv 搭建与生产中间件链一致的路由器 // (sessions -> CSRF -> 用户上下文),外加待测路由。 type securityTestEnv struct { - router *gin.Engine - db *gorm.DB - storageDir string - limiter *LoginRateLimiter + router *gin.Engine + db *gorm.DB + storageDir string + limiter *LoginRateLimiter + registerLimiter *WindowRateLimiter } var csrfTokenRe = regexp.MustCompile(`name="_csrf" value="([^"]+)"`) @@ -78,6 +79,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { r.LoadHTMLGlob("../templates/**/*.html") store := cookie.NewStore([]byte("test-secret")) limiter := NewLoginLimiter() + registerLimiter := NewWindowLimiter(registerLimitPerHour, registerWindow) r.Use(sessions.Sessions("blog_session", store)) r.Use(middleware.SetUserContext(db)) r.Use(middleware.BodyLimit()) @@ -91,7 +93,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)) + api.POST("/auth/register", Register(db, registerLimiter)) api.POST("/article/:slug/comments", PostComment(db)) } @@ -147,7 +149,8 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { articleAPI.DELETE("/:id", ArticleDelete(db, "/admin/articles")) } - return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter} + return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter, + registerLimiter: registerLimiter} } func mustUser(t *testing.T, db *gorm.DB, username, role string) models.User { diff --git a/i18n/i18n.go b/i18n/i18n.go index ec0682b..7adf235 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -451,6 +451,7 @@ var translations = map[Lang]map[string]string{ "api_forbidden": "You do not have permission to perform this action.", "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.", "user_not_found": "User not found.", }, ZH: { @@ -888,6 +889,7 @@ var translations = map[Lang]map[string]string{ "api_forbidden": "您没有权限执行此操作。", "api_invalid_request": "请求参数格式不正确。", "request_too_large": "请求体超过大小限制。", + "register_locked": "来自该地址的注册次数过多,请稍后再试。", "user_not_found": "用户不存在。", }, } diff --git a/main.go b/main.go index 43041ba..51ed63c 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "path" "path/filepath" "strings" + "time" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" @@ -163,6 +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 有界。 + // 单实例部署无需共享存储。 + registerLimiter := handlers.NewWindowLimiter(10, time.Hour) + // 公开页面。 router.GET("/", handlers.HomePage(db)) router.GET("/search", handlers.SearchPage(db)) @@ -177,7 +182,7 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi { api.GET("/articles", handlers.HomeArticlesAPI(db)) api.POST("/auth/login", handlers.Login(db, loginLimiter)) - api.POST("/auth/register", handlers.Register(db)) + api.POST("/auth/register", handlers.Register(db, registerLimiter)) api.POST("/auth/logout", handlers.Logout()) api.POST("/article/:slug/comments", handlers.PostComment(db)) }