feat: enhance security with IP banning, ownership checks, and SSRF protection
Add comprehensive security improvements across the application: - **IP-based login protection**: Implement IP ban system tracking login failures, auto-banning after threshold exceeded - **Ownership verification**: Add userID parameter to Delete/Update operations for posts and comments to prevent unauthorized modifications - **SSRF protection**: Add URL and resolved host validation for image URLs in chat and OpenAI client - **SQL injection prevention**: Add EscapeLikeWildcard utility to escape special characters in LIKE queries - **HTTP security**: Configure server timeouts and add security headers middleware - **Rate limiting**: Refactor to support configurable duration and per-endpoint rate limits for auth routes - **Error handling**: Standardize error responses using HandleError and proper error types
This commit is contained in:
@@ -75,8 +75,29 @@ func (rl *RateLimiter) isAllowed(key string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) count(key string) int {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
times := rl.requests[key]
|
||||
|
||||
var valid []time.Time
|
||||
for _, t := range times {
|
||||
if now.Sub(t) < rl.window {
|
||||
valid = append(valid, t)
|
||||
}
|
||||
}
|
||||
rl.requests[key] = valid
|
||||
return len(valid)
|
||||
}
|
||||
|
||||
func RateLimit(requestsPerMinute int) gin.HandlerFunc {
|
||||
limiter := NewRateLimiter(requestsPerMinute, time.Minute)
|
||||
return RateLimitWithDuration(requestsPerMinute, time.Minute)
|
||||
}
|
||||
|
||||
func RateLimitWithDuration(limit int, window time.Duration) gin.HandlerFunc {
|
||||
limiter := NewRateLimiter(limit, window)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
@@ -95,7 +116,11 @@ func RateLimit(requestsPerMinute int) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
func RateLimitWithKey(requestsPerMinute int, keyFunc func(c *gin.Context) string) gin.HandlerFunc {
|
||||
limiter := NewRateLimiter(requestsPerMinute, time.Minute)
|
||||
return RateLimitWithDurationAndKey(requestsPerMinute, time.Minute, keyFunc)
|
||||
}
|
||||
|
||||
func RateLimitWithDurationAndKey(limit int, window time.Duration, keyFunc func(c *gin.Context) string) gin.HandlerFunc {
|
||||
limiter := NewRateLimiter(limit, window)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
key := keyFunc(c)
|
||||
@@ -115,3 +140,176 @@ func RateLimitWithKey(requestsPerMinute int, keyFunc func(c *gin.Context) string
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// IP 封禁与登录失败追踪
|
||||
// ============================================================
|
||||
|
||||
const (
|
||||
DefaultMaxLoginFailures = 10
|
||||
DefaultBanDuration = 15 * time.Minute
|
||||
DefaultFailureWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
type ipBanEntry struct {
|
||||
unbanAt time.Time
|
||||
}
|
||||
|
||||
type loginFailureEntry struct {
|
||||
count int
|
||||
firstAt time.Time
|
||||
lastAt time.Time
|
||||
}
|
||||
|
||||
type IPBanStore struct {
|
||||
mu sync.RWMutex
|
||||
bans map[string]*ipBanEntry
|
||||
failures map[string]*loginFailureEntry
|
||||
|
||||
maxFailures int
|
||||
banDuration time.Duration
|
||||
failWindow time.Duration
|
||||
}
|
||||
|
||||
var defaultIPBanStore = NewIPBanStore(DefaultMaxLoginFailures, DefaultBanDuration, DefaultFailureWindow)
|
||||
|
||||
func NewIPBanStore(maxFailures int, banDuration, failWindow time.Duration) *IPBanStore {
|
||||
store := &IPBanStore{
|
||||
bans: make(map[string]*ipBanEntry),
|
||||
failures: make(map[string]*loginFailureEntry),
|
||||
maxFailures: maxFailures,
|
||||
banDuration: banDuration,
|
||||
failWindow: failWindow,
|
||||
}
|
||||
|
||||
go store.cleanupLoop()
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func (s *IPBanStore) cleanupLoop() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
s.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IPBanStore) cleanup() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for ip, entry := range s.bans {
|
||||
if now.After(entry.unbanAt) {
|
||||
delete(s.bans, ip)
|
||||
}
|
||||
}
|
||||
|
||||
for ip, entry := range s.failures {
|
||||
if now.Sub(entry.firstAt) > s.failWindow && now.Sub(entry.lastAt) > s.failWindow {
|
||||
delete(s.failures, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IPBanStore) IsBanned(ip string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
entry, exists := s.bans[ip]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Now().Before(entry.unbanAt)
|
||||
}
|
||||
|
||||
func (s *IPBanStore) RecordFailure(ip string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
entry, exists := s.failures[ip]
|
||||
if !exists || now.Sub(entry.firstAt) > s.failWindow {
|
||||
entry = &loginFailureEntry{
|
||||
count: 1,
|
||||
firstAt: now,
|
||||
lastAt: now,
|
||||
}
|
||||
s.failures[ip] = entry
|
||||
return false
|
||||
}
|
||||
|
||||
entry.count++
|
||||
entry.lastAt = now
|
||||
|
||||
if entry.count >= s.maxFailures {
|
||||
s.bans[ip] = &ipBanEntry{
|
||||
unbanAt: now.Add(s.banDuration),
|
||||
}
|
||||
delete(s.failures, ip)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *IPBanStore) ResetFailures(ip string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.failures, ip)
|
||||
}
|
||||
|
||||
func RecordLoginFailure(ip string) bool {
|
||||
return defaultIPBanStore.RecordFailure(ip)
|
||||
}
|
||||
|
||||
func ResetLoginFailures(ip string) {
|
||||
defaultIPBanStore.ResetFailures(ip)
|
||||
}
|
||||
|
||||
func IPBanMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
|
||||
if defaultIPBanStore.IsBanned(ip) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"code": 429,
|
||||
"message": "your IP has been temporarily banned due to too many failed attempts, please try again later",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 预配置的速率限制中间件
|
||||
// ============================================================
|
||||
|
||||
func LoginRateLimit() gin.HandlerFunc {
|
||||
return RateLimitWithDuration(10, time.Minute)
|
||||
}
|
||||
|
||||
func RegisterRateLimit() gin.HandlerFunc {
|
||||
return RateLimitWithDuration(5, time.Hour)
|
||||
}
|
||||
|
||||
func PasswordResetRateLimit() gin.HandlerFunc {
|
||||
return RateLimitWithDuration(3, time.Hour)
|
||||
}
|
||||
|
||||
func SendCodeRateLimit() gin.HandlerFunc {
|
||||
return RateLimitWithDuration(5, time.Hour)
|
||||
}
|
||||
|
||||
func GlobalAuthRateLimit() gin.HandlerFunc {
|
||||
return RateLimitWithDuration(30, time.Minute)
|
||||
}
|
||||
|
||||
20
internal/middleware/security_headers.go
Normal file
20
internal/middleware/security_headers.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
c.Header("X-XSS-Protection", "0")
|
||||
c.Header("Cross-Origin-Opener-Policy", "same-origin")
|
||||
c.Header("Cross-Origin-Resource-Policy", "same-origin")
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user