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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user