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:
@@ -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