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:
7 files changed
+244
-14
No files matched your search
+6
-5
@@ -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;正常节奏不受影响
|
||||
|
||||
---
|
||||
|
||||
+10
-2
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": "用户不存在。",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user