fix: SECURITY_TODO #27 注册接口按 IP 限流(10 次/小时)防批量注册

register/api 未认证端点可被无限批量注册:bcrypt cost 12 与用户名唯一
检查不构成成本屏障。新增 WindowRateLimiter(固定窗口、有界 map、与
#10 相同的淘汰策略),Register 按 IP 限流 10 次/小时,超限 429 +
i18n register_locked(中英)。

- handlers/rate_limit.go: 通用固定窗口限流器(可注入时钟便于测试)
- handlers/auth.go: Register(db, limiter),键前缀与登录限流器区分
- main.go: 注册限流器接线;测试环境同步
- 测试: TestWindowLimiterFixedWindow / TestRegisterRateLimited
  (同 IP 10 次成功、第 11 次 429、其他 IP 不受影响)

注: #28 评论限流将复用同一限流器,在下一个 commit 实施。
This commit is contained in:
dsh
2026-08-27 21:38:49 +08:00
parent 2e3888d5b1
commit 4c32267b47
7 files changed
+244 -14

No files matched your search

+6 -5
View File
@@ -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`
- **问题**: 登录有限流器(#10IP+用户名),注册完全没有。`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;正常节奏不受影响
---
+10 -2
View File
@@ -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 {
+98
View File
@@ -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)
}
}
+113
View File
@@ -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())
}
}
+9 -6
View File
@@ -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 {
+2
View File
@@ -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": "用户不存在。",
},
}
+6 -1
View File
@@ -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))
}