mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-16 01:26:07 +00:00
fbad71d620
Second-pass review of the 54-commit self-correcting audit. Each item below was confirmed by reading the surrounding source (and, where practical, the pre-fix code) before being changed; regression tests are included for every behavioral fix. Concurrency: - eventbus: Bus.Subscribe called wg.Add with no synchronization against a concurrent Bus.Stop's wg.Wait, a real "WaitGroup misuse" panic risk (e.g. a Telegram-bot settings save racing panel shutdown/restart). Stop now flips a mu-guarded `stopped` flag before waiting, and Subscribe checks it under the same lock, so Add and Wait can no longer race. Security: - login_limiter: evictForRoom's fallback eviction picked an arbitrary map key, including ones still under an active cooldown - an attacker flooding /login with fresh usernames could evict their own (or anyone's) blocked record and reset the lockout. The fallback now skips actively-blocked records, only falling back to an unconditional evict if the map is somehow entirely full of active blocks (preserves the hard memory cap). Subscription-endpoint panics (reachable by any client hitting /sub): - internal/sub/service.go: applyPathAndHostParams/Obj (ws/httpupgrade/xhttp with no path settings object) and the TLS alpn readers in three places used unchecked type assertions - exactly the bug classabab7cd0patched elsewhere in the same switch statements, just not these call sites. - internal/sub/json_service.go, clash_service.go: the externalProxy loops in the JSON and Clash generators used unchecked assertions on a legacy/admin-supplied field (missing "port", non-object entry, etc.). - internal/sub/json_service.go: realityData's shortId/serverName selection could assert a non-string array element. Other correctness: - client_traffic.go: ResetAllTraffics (touched by3eb214d0) still skipped clearing NodeClientTraffic node-sync baselines, unlike its sibling reset paths in the same file - a node's next sync would re-add pre-reset delta on top of the freshly-zeroed counter. - inbound_traffic.go: the traffic-tick tx's Commit/Rollback errors were silently discarded; now logged so a backend-level commit failure (e.g. an aborted Postgres tx from a best-effort helper) doesn't masquerade as a successful tick. - outbound_subscription.go: the new subscriptionFetchClient doc comment was wedged between fetchAndStore's existing comment and fetchAndStore itself, leaving fetchAndStore undocumented and the comment describing the wrong function. Convention cleanup: - Removed narrative // comments added by the audit that violate this repo's no-inline-comment rule (mostly narrating the specific bug/fix rather than a lasting contract, and mostly on new Test functions, which this repo's existing tests never comment) - calibrated against this exact codebase's own pre-existing comment style so legitimate godoc-style doc comments were left alone.
140 lines
3.5 KiB
Go
140 lines
3.5 KiB
Go
package controller
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
loginLimitMaxFailures = 5
|
|
loginLimitWindow = 5 * time.Minute
|
|
loginLimitCooldown = 15 * time.Minute
|
|
// Hard ceiling on tracked (ip, username) records. The key includes the
|
|
// caller-supplied username, so an unauthenticated attacker rotating
|
|
// usernames would otherwise grow the map without bound.
|
|
loginLimitMaxRecords = 10000
|
|
)
|
|
|
|
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()
|
|
|
|
now := l.now()
|
|
key := loginLimitKey(ip, username)
|
|
record := l.attempts[key]
|
|
if record == nil {
|
|
l.evictForRoom(now)
|
|
record = &loginLimitRecord{}
|
|
l.attempts[key] = record
|
|
}
|
|
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))
|
|
}
|
|
|
|
// evictForRoom keeps the attempts map bounded before inserting a new record.
|
|
// It first reclaims records that are no longer blocked and whose failures have
|
|
// aged out of the window; if the map is still at the ceiling (a genuine
|
|
// broad flood), it drops one arbitrary record so memory can never grow past the
|
|
// cap. Callers hold l.mu.
|
|
func (l *loginLimiter) evictForRoom(now time.Time) {
|
|
if len(l.attempts) < loginLimitMaxRecords {
|
|
return
|
|
}
|
|
cutoff := now.Add(-l.window)
|
|
for key, record := range l.attempts {
|
|
if now.Before(record.blockedUntil) {
|
|
continue
|
|
}
|
|
record.failures = pruneLoginFailures(record.failures, cutoff)
|
|
if len(record.failures) == 0 {
|
|
delete(l.attempts, key)
|
|
}
|
|
}
|
|
if len(l.attempts) < loginLimitMaxRecords {
|
|
return
|
|
}
|
|
for key, record := range l.attempts {
|
|
if now.Before(record.blockedUntil) {
|
|
continue
|
|
}
|
|
delete(l.attempts, key)
|
|
return
|
|
}
|
|
for key := range l.attempts {
|
|
delete(l.attempts, key)
|
|
return
|
|
}
|
|
}
|
|
|
|
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:]
|
|
}
|