Files
nadir-agent/internal/auth/throttle.go
T
2026-06-22 16:06:57 +02:00

111 lines
3.4 KiB
Go

package auth
import (
"context"
"net"
"net/http"
"strings"
"sync"
"time"
)
// failLimiter throttles repeated login failures keyed by "username|ip". After
// max failures it imposes a cooldown of window before any further attempt for
// that key is accepted, blunting brute force against PAM/shadow.
//
// ponytail: in-memory, single-process, fixed cooldown - correct for a single
// app instance. State is lost on restart, but only an operator restarts the
// process (an attacker can't), so persisting it would buy nothing. Source
// spoofing is handled by the network (VPN-only + trusted proxy sets XFF), not
// here. Reach for pam_faillock only if a failed web login should also lock the
// OS account against ssh/console - a different layer we deliberately don't span.
type failLimiter struct {
mu sync.Mutex
attempts map[string]*attemptState
max int
window time.Duration
}
type attemptState struct {
count int
until time.Time
}
// maxTrackedKeys bounds memory: an attacker rotating username/IP can't grow the
// map without limit. When exceeded we drop all throttle state - a crude reset
// that briefly forgets cooldowns, acceptable for a single-node panel.
const maxTrackedKeys = 10000
func newFailLimiter(max int, window time.Duration) *failLimiter {
return &failLimiter{attempts: map[string]*attemptState{}, max: max, window: window}
}
// blocked reports whether the key is currently in cooldown.
func (l *failLimiter) blocked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
s := l.attempts[key]
return s != nil && time.Now().Before(s.until)
}
// fail records a failed attempt and starts a cooldown once max is reached.
func (l *failLimiter) fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
if len(l.attempts) > maxTrackedKeys {
l.attempts = map[string]*attemptState{}
}
s := l.attempts[key]
if s == nil {
s = &attemptState{}
l.attempts[key] = s
}
s.count++
if s.count >= l.max {
s.until = time.Now().Add(l.window)
s.count = 0 // restart the window after the cooldown is set
}
}
// reset clears state for a key after a successful login.
func (l *failLimiter) reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.attempts, key)
}
type ctxKey int
const clientIPKey ctxKey = 0
// WithClientIP wraps a handler so the client's IP is available downstream via
// ClientIP, so the login throttle can key on source IP without coupling to a
// specific HTTP adapter.
//
// When trustProxy is set, the IP is taken from the first X-Forwarded-For hop
// (the original client behind the reverse proxy). X-Forwarded-For is fully
// caller-controlled, so this is only honored when an admin has opted in by
// putting nadir behind a proxy - and nadir must then be reachable only by that
// proxy. When trustProxy is false the header is ignored and RemoteAddr wins.
func WithClientIP(trustProxy bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ip = r.RemoteAddr
}
if trustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first, _, _ := strings.Cut(xff, ",")
ip = strings.TrimSpace(first)
}
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientIPKey, ip)))
})
}
// ClientIP returns the IP recorded by WithClientIP, or "" if absent.
func ClientIP(ctx context.Context) string {
ip, _ := ctx.Value(clientIPKey).(string)
return ip
}