Files
lmvpn_server/internal/middleware/ratelimit.go
T
kevin 129c6c7a96 fix: 密码长度上限校验 + 请求体大小限制防 DoS
- 新增 validatePassword 校验函数(6-72 字节,bcrypt 硬上限),统一应用于 ChangePassword/CreateUser/UpdateUser,超长密码返回清晰提示而非迷惑性的"密码加密失败"
- 新增 BodyLimit 中间件(http.MaxBytesReader),对所有 /api JSON 端点限制 1 MiB 请求体,防止内存耗尽型 DoS;/ws 走连接劫持不受影响
2026-07-09 15:51:10 +08:00

96 lines
1.9 KiB
Go

package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type RateLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
limit int
window time.Duration
}
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
attempts: make(map[string][]time.Time),
limit: limit,
window: window,
}
go rl.cleanup()
return rl
}
func (rl *RateLimiter) Allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
cutoff := now.Add(-rl.window)
var recent []time.Time
for _, t := range rl.attempts[key] {
if t.After(cutoff) {
recent = append(recent, t)
}
}
if len(recent) >= rl.limit {
rl.attempts[key] = recent
return false
}
recent = append(recent, now)
rl.attempts[key] = recent
return true
}
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(rl.window)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
cutoff := time.Now().Add(-rl.window)
for key, times := range rl.attempts {
var recent []time.Time
for _, t := range times {
if t.After(cutoff) {
recent = append(recent, t)
}
}
if len(recent) == 0 {
delete(rl.attempts, key)
} else {
rl.attempts[key] = recent
}
}
rl.mu.Unlock()
}
}
func LoginRateLimit() gin.HandlerFunc {
limiter := NewRateLimiter(5, time.Minute)
return func(c *gin.Context) {
key := c.ClientIP()
if !limiter.Allow(key) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"})
c.Abort()
return
}
c.Next()
}
}
// BodyLimit 限制请求体大小,防止内存耗尽型 DoS。
// 对所有 /api JSON 端点生效;WebSocket 走连接劫持,握手阶段不受影响。
func BodyLimit(max int64) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, max)
c.Next()
}
}