Files
3x-ui/web/controller/login_limiter.go
Farhad H. P. Shirvan 10ebc6cbdc Implement CSRF protection and security hardening across the application (#4179)
* Implement CSRF protection and security hardening across the application

- Added CSRF token handling in axios requests and HTML templates.
- Introduced CSRF middleware to validate tokens for unsafe HTTP methods.
- Implemented login limiter to prevent brute-force attacks.
- Enhanced security headers in middleware for improved response security.
- Updated login notification to include safe metadata without passwords.
- Added tests for CSRF middleware and login limiter functionality.

* fix
2026-05-07 23:36:11 +02:00

100 lines
2.4 KiB
Go

package controller
import (
"strings"
"sync"
"time"
)
const (
loginLimitMaxFailures = 5
loginLimitWindow = 5 * time.Minute
loginLimitCooldown = 15 * time.Minute
)
var defaultLoginLimiter = newLoginLimiter(loginLimitMaxFailures, loginLimitWindow, loginLimitCooldown)
type loginLimiter struct {
mu sync.Mutex
now func() time.Time
maxFailures int
window time.Duration
cooldown time.Duration
attempts map[string]*loginLimitRecord
}
type loginLimitRecord struct {
failures []time.Time
blockedUntil time.Time
}
func newLoginLimiter(maxFailures int, window, cooldown time.Duration) *loginLimiter {
return &loginLimiter{
now: time.Now,
maxFailures: maxFailures,
window: window,
cooldown: cooldown,
attempts: make(map[string]*loginLimitRecord),
}
}
func (l *loginLimiter) allow(ip, username string) (time.Time, bool) {
l.mu.Lock()
defer l.mu.Unlock()
key := loginLimitKey(ip, username)
record := l.attempts[key]
if record == nil {
return time.Time{}, true
}
now := l.now()
if now.Before(record.blockedUntil) {
return record.blockedUntil, false
}
record.blockedUntil = time.Time{}
record.failures = pruneLoginFailures(record.failures, now.Add(-l.window))
if len(record.failures) == 0 {
delete(l.attempts, key)
}
return time.Time{}, true
}
func (l *loginLimiter) registerFailure(ip, username string) (time.Time, bool) {
l.mu.Lock()
defer l.mu.Unlock()
key := loginLimitKey(ip, username)
record := l.attempts[key]
if record == nil {
record = &loginLimitRecord{}
l.attempts[key] = record
}
now := l.now()
record.failures = pruneLoginFailures(record.failures, now.Add(-l.window))
record.failures = append(record.failures, now)
if len(record.failures) >= l.maxFailures {
record.failures = nil
record.blockedUntil = now.Add(l.cooldown)
return record.blockedUntil, true
}
return time.Time{}, false
}
func (l *loginLimiter) registerSuccess(ip, username string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.attempts, loginLimitKey(ip, username))
}
func loginLimitKey(ip, username string) string {
return strings.TrimSpace(ip) + "\x00" + strings.ToLower(strings.TrimSpace(username))
}
func pruneLoginFailures(failures []time.Time, cutoff time.Time) []time.Time {
keepFrom := 0
for keepFrom < len(failures) && failures[keepFrom].Before(cutoff) {
keepFrom++
}
return failures[keepFrom:]
}