trusted proxy, TOTP replay protection, new tests
- Trusted proxy config option for proxy-aware IP extraction used by rate limiting and audit logs; validates proxy IP before trusting X-Forwarded-For / X-Real-IP headers - TOTP replay protection via counter-based validation to reject reused codes within the same time step (±30s) - RateLimit middleware updated to extract client IP from proxy headers without IP spoofing risk - New tests for ClientIP proxy logic (spoofed headers, fallback) and extended rate-limit proxy coverage - HTMX error banner script integrated into web UI base - .gitignore updated for mciasdb build artifact Security: resolves CRIT-01 (TOTP replay attack) and DEF-03 (proxy-unaware rate limiting); gRPC TOTP enrollment aligned with REST via StorePendingTOTP Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -176,15 +176,62 @@ type ipRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ClientIP returns the real client IP for a request, optionally trusting a
|
||||
// single reverse-proxy address.
|
||||
//
|
||||
// Security (DEF-03): X-Forwarded-For and X-Real-IP headers can be forged by
|
||||
// any client. This function only honours them when the immediate TCP peer
|
||||
// (r.RemoteAddr) matches trustedProxy exactly. When trustedProxy is nil or
|
||||
// the peer address does not match, r.RemoteAddr is used unconditionally.
|
||||
//
|
||||
// This prevents IP-spoofing attacks: an attacker who sends a fake
|
||||
// X-Forwarded-For header from their own connection still has their real IP
|
||||
// used for rate limiting, because their RemoteAddr will not match the proxy.
|
||||
//
|
||||
// Only the first (leftmost) value in X-Forwarded-For is used, as that is the
|
||||
// client-supplied address as appended by the outermost proxy. If neither
|
||||
// header is present, RemoteAddr is used as a fallback even when the request
|
||||
// comes from the proxy.
|
||||
func ClientIP(r *http.Request, trustedProxy net.IP) string {
|
||||
remoteHost, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
remoteHost = r.RemoteAddr
|
||||
}
|
||||
|
||||
if trustedProxy != nil {
|
||||
remoteIP := net.ParseIP(remoteHost)
|
||||
if remoteIP != nil && remoteIP.Equal(trustedProxy) {
|
||||
// Request is from the trusted proxy; extract the real client IP.
|
||||
// Prefer X-Real-IP (single value) over X-Forwarded-For (may be a
|
||||
// comma-separated list when multiple proxies are chained).
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
if ip := net.ParseIP(strings.TrimSpace(xri)); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// Take the first (leftmost) address — the original client.
|
||||
first, _, _ := strings.Cut(xff, ",")
|
||||
if ip := net.ParseIP(strings.TrimSpace(first)); ip != nil {
|
||||
return ip.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return remoteHost
|
||||
}
|
||||
|
||||
// RateLimit returns middleware implementing a per-IP token bucket.
|
||||
// rps is the sustained request rate (tokens refilled per second).
|
||||
// burst is the maximum burst size (initial and maximum token count).
|
||||
// trustedProxy, if non-nil, enables proxy-aware client IP extraction via
|
||||
// ClientIP; pass nil when not running behind a reverse proxy.
|
||||
//
|
||||
// Security: Rate limiting is applied at the IP level. In production, the
|
||||
// server should be behind a reverse proxy that sets X-Forwarded-For; this
|
||||
// middleware uses RemoteAddr directly which may be the proxy IP. For single-
|
||||
// instance deployment without a proxy, RemoteAddr is the client IP.
|
||||
func RateLimit(rps float64, burst int) func(http.Handler) http.Handler {
|
||||
// Security (DEF-03): when trustedProxy is set, real client IPs are extracted
|
||||
// from X-Forwarded-For/X-Real-IP headers but only for requests whose
|
||||
// RemoteAddr matches the trusted proxy, preventing IP-spoofing.
|
||||
func RateLimit(rps float64, burst int, trustedProxy net.IP) func(http.Handler) http.Handler {
|
||||
limiter := &ipRateLimiter{
|
||||
rps: rps,
|
||||
burst: float64(burst),
|
||||
@@ -197,10 +244,7 @@ func RateLimit(rps float64, burst int) func(http.Handler) http.Handler {
|
||||
|
||||
return func(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
|
||||
}
|
||||
ip := ClientIP(r, trustedProxy)
|
||||
|
||||
if !limiter.allow(ip) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
|
||||
Reference in New Issue
Block a user