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:
2026-03-12 17:44:01 -07:00
parent f262ca7b4e
commit ec7c966ad2
31 changed files with 799 additions and 250 deletions

View File

@@ -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")

View File

@@ -6,6 +6,7 @@ import (
"crypto/ed25519"
"crypto/rand"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"testing"
@@ -271,7 +272,7 @@ func TestRequireRoleNoClaims(t *testing.T) {
}
func TestRateLimitAllows(t *testing.T) {
handler := RateLimit(10, 5)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := RateLimit(10, 5, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
@@ -289,7 +290,7 @@ func TestRateLimitAllows(t *testing.T) {
}
func TestRateLimitBlocks(t *testing.T) {
handler := RateLimit(0.1, 2)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
handler := RateLimit(0.1, 2, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
@@ -340,3 +341,124 @@ func TestExtractBearerToken(t *testing.T) {
})
}
}
// TestClientIP verifies the proxy-aware IP extraction logic.
func TestClientIP(t *testing.T) {
proxy := net.ParseIP("10.0.0.1")
tests := []struct {
name string
remoteAddr string
xForwardedFor string
xRealIP string
trustedProxy net.IP
want string
}{
{
name: "no proxy configured: uses RemoteAddr",
remoteAddr: "203.0.113.5:54321",
want: "203.0.113.5",
},
{
name: "proxy configured but request not from proxy: uses RemoteAddr",
remoteAddr: "198.51.100.9:12345",
xForwardedFor: "203.0.113.99",
trustedProxy: proxy,
want: "198.51.100.9",
},
{
name: "request from trusted proxy with X-Real-IP: uses X-Real-IP",
remoteAddr: "10.0.0.1:8080",
xRealIP: "203.0.113.42",
trustedProxy: proxy,
want: "203.0.113.42",
},
{
name: "request from trusted proxy with X-Forwarded-For: uses first entry",
remoteAddr: "10.0.0.1:8080",
xForwardedFor: "203.0.113.77, 10.0.0.2",
trustedProxy: proxy,
want: "203.0.113.77",
},
{
name: "X-Real-IP takes precedence over X-Forwarded-For",
remoteAddr: "10.0.0.1:8080",
xRealIP: "203.0.113.11",
xForwardedFor: "203.0.113.22",
trustedProxy: proxy,
want: "203.0.113.11",
},
{
name: "proxy request with invalid X-Real-IP falls back to X-Forwarded-For",
remoteAddr: "10.0.0.1:8080",
xRealIP: "not-an-ip",
xForwardedFor: "203.0.113.55",
trustedProxy: proxy,
want: "203.0.113.55",
},
{
name: "proxy request with no forwarding headers falls back to RemoteAddr host",
remoteAddr: "10.0.0.1:8080",
trustedProxy: proxy,
want: "10.0.0.1",
},
{
// Security: attacker fakes X-Forwarded-For but connects directly.
name: "spoofed X-Forwarded-For from non-proxy IP is ignored",
remoteAddr: "198.51.100.99:9999",
xForwardedFor: "127.0.0.1",
trustedProxy: proxy,
want: "198.51.100.99",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = tc.remoteAddr
if tc.xForwardedFor != "" {
req.Header.Set("X-Forwarded-For", tc.xForwardedFor)
}
if tc.xRealIP != "" {
req.Header.Set("X-Real-IP", tc.xRealIP)
}
got := ClientIP(req, tc.trustedProxy)
if got != tc.want {
t.Errorf("ClientIP = %q, want %q", got, tc.want)
}
})
}
}
// TestRateLimitTrustedProxy verifies that rate limiting uses the forwarded IP
// when the request originates from a trusted proxy.
func TestRateLimitTrustedProxy(t *testing.T) {
proxy := net.ParseIP("10.0.0.1")
// Very low rps and burst=1 so any two requests from the same IP are blocked.
handler := RateLimit(0.001, 1, proxy)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Two requests from the same real client IP, forwarded by the proxy.
// Both carry the same X-Real-IP; the second should be rate-limited.
for i, wantStatus := range []int{http.StatusOK, http.StatusTooManyRequests} {
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil)
req.RemoteAddr = "10.0.0.1:5000" // from the trusted proxy
req.Header.Set("X-Real-IP", "203.0.113.5")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != wantStatus {
t.Errorf("request %d: status = %d, want %d", i+1, rr.Code, wantStatus)
}
}
// A different real client (different X-Real-IP) should still be allowed.
req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", nil)
req.RemoteAddr = "10.0.0.1:5001"
req.Header.Set("X-Real-IP", "203.0.113.99")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("distinct client: status = %d, want 200 (separate bucket)", rr.Code)
}
}