diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go index 31c0c237..a1b9096e 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/config/config.go @@ -78,12 +78,13 @@ type AppConfig struct { } type ServerConfig struct { - Host string `koanf:"host"` - Port int `koanf:"port"` - ReadTimeout time.Duration `koanf:"read_timeout"` - WriteTimeout time.Duration `koanf:"write_timeout"` - IdleTimeout time.Duration `koanf:"idle_timeout"` - ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` + Host string `koanf:"host"` + Port int `koanf:"port"` + ReadTimeout time.Duration `koanf:"read_timeout"` + WriteTimeout time.Duration `koanf:"write_timeout"` + IdleTimeout time.Duration `koanf:"idle_timeout"` + ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` + TrustedProxyCIDRs []string `koanf:"trusted_proxy_cidrs"` } type DatabaseConfig struct { @@ -208,6 +209,13 @@ func loadDefaults(k *koanf.Koanf) error { "server.write_timeout": "30s", "server.idle_timeout": "120s", "server.shutdown_timeout": "15s", + "server.trusted_proxy_cidrs": []string{ + "127.0.0.1/32", + "::1/128", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + }, "database.max_open_conns": 25, "database.max_idle_conns": 5, @@ -271,7 +279,7 @@ func loadDefaults(k *koanf.Koanf) error { "notify.retention_limit": 100, "notify.webhook_hmac_secret": "", "notify.telegram_api_base": "https://api.telegram.org", - "notify.fingerprint_window": "5m", + "notify.fingerprint_window": "30s", "operator.token": "", diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint_test.go index 54a35b6f..47e5d725 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/fingerprint_test.go @@ -41,13 +41,13 @@ func TestRealIP_Precedence(t *testing.T) { "203.0.113.10", }, { - "XFF rightmost no CF", + "XFF leftmost no CF", map[string]string{ "X-Forwarded-For": "198.51.100.1, 198.51.100.7", "X-Real-IP": "192.0.2.99", }, "127.0.0.1:9", - "198.51.100.7", + "198.51.100.1", }, { "XFF trailing comma falls through", @@ -83,12 +83,12 @@ func TestRealIP_Precedence(t *testing.T) { {"RemoteAddr loopback IPv6", nil, "[::1]:9", "::1"}, {"RemoteAddr no port fallback", nil, "127.0.0.1", "127.0.0.1"}, { - "XFF IPv6 rightmost", + "XFF mixed IPv4+IPv6 leftmost", map[string]string{ "X-Forwarded-For": "198.51.100.1, 2001:db8::dead", }, "127.0.0.1:9", - "2001:db8::dead", + "198.51.100.1", }, { "CF trimmed", @@ -109,6 +109,34 @@ func TestRealIP_Precedence(t *testing.T) { } } +func TestRealIP_UntrustedRemoteIgnoresClientHeaders(t *testing.T) { + require.NoError(t, middleware.SetTrustedProxyCIDRs( + []string{"127.0.0.1/32", "::1/128"}, + )) + t.Cleanup(middleware.ClearTrustedProxyCIDRs) + + r := newRequest("198.51.100.50:443", map[string]string{ + "CF-Connecting-IP": "evil-spoofed", + "X-Forwarded-For": "evil-spoofed", + "X-Real-IP": "evil-spoofed", + }) + require.Equal(t, "198.51.100.50", middleware.RealIP(r), + "requests from outside the trusted-proxy set must ignore "+ + "client-supplied forwarding headers (audit F5)") +} + +func TestRealIP_TrustedRemoteHonorsClientHeaders(t *testing.T) { + require.NoError(t, middleware.SetTrustedProxyCIDRs( + []string{"127.0.0.1/32"}, + )) + t.Cleanup(middleware.ClearTrustedProxyCIDRs) + + r := newRequest("127.0.0.1:443", map[string]string{ + "CF-Connecting-IP": "203.0.113.10", + }) + require.Equal(t, "203.0.113.10", middleware.RealIP(r)) +} + func TestOptionalHeader(t *testing.T) { require.Nil(t, middleware.OptionalHeader("")) require.Nil(t, middleware.OptionalHeader(" ")) diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/ratelimit.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/ratelimit.go index c52b0b0e..ec02d560 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/ratelimit.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/ratelimit.go @@ -8,11 +8,10 @@ import ( "encoding/json" "fmt" "log/slog" - "net" "net/http" "strconv" - "strings" "sync" + "sync/atomic" "time" redis_rate "github.com/go-redis/redis_rate/v10" @@ -95,22 +94,7 @@ func (rl *RateLimiter) allow( } func KeyByIP(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - ips := strings.Split(xff, ",") - ip := strings.TrimSpace(ips[len(ips)-1]) - return "ratelimit:ip:" + ip - } - - if xri := r.Header.Get("X-Real-IP"); xri != "" { - return "ratelimit:ip:" + xri - } - - ip, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - ip = r.RemoteAddr - } - - return "ratelimit:ip:" + ip + return "ratelimit:ip:" + RealIP(r) } func setRateLimitHeaders( @@ -161,7 +145,7 @@ func writeRateLimitExceeded(w http.ResponseWriter, res *redis_rate.Result) { type limiterEntry struct { limiter *rate.Limiter - lastAccess int64 + lastAccess atomic.Int64 } type localLimiter struct { @@ -187,7 +171,7 @@ func (l *localLimiter) cleanup() { cutoff := time.Now().Add(-entryTTL).Unix() l.limiters.Range(func(key, value any) bool { entry, ok := value.(*limiterEntry) - if ok && entry.lastAccess < cutoff { + if ok && entry.lastAccess.Load() < cutoff { l.limiters.Delete(key) } return true @@ -209,8 +193,8 @@ func (l *localLimiter) allow( rate.Limit(ratePerSec), limit.Burst, ), - lastAccess: now, } + newEntry.lastAccess.Store(now) entryI, _ = l.limiters.LoadOrStore(key, newEntry) } @@ -218,7 +202,7 @@ func (l *localLimiter) allow( if !ok { return nil, fmt.Errorf("invalid limiter entry type") } - entry.lastAccess = now + entry.lastAccess.Store(now) allowed := entry.limiter.Allow() diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/realip.go b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/realip.go index e2a0ae9e..f0db7e53 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/realip.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/middleware/realip.go @@ -4,9 +4,11 @@ package middleware import ( + "fmt" "net" "net/http" "strings" + "sync/atomic" ) const ( @@ -15,15 +17,69 @@ const ( headerXRealIP = "X-Real-IP" ) +type proxyTrustState struct { + cidrs []*net.IPNet +} + +var trustedProxies atomic.Pointer[proxyTrustState] + +func SetTrustedProxyCIDRs(cidrs []string) error { + parsed := make([]*net.IPNet, 0, len(cidrs)) + for _, c := range cidrs { + trimmed := strings.TrimSpace(c) + if trimmed == "" { + continue + } + _, n, err := net.ParseCIDR(trimmed) + if err != nil { + return fmt.Errorf("trusted proxy cidr %q: %w", c, err) + } + parsed = append(parsed, n) + } + trustedProxies.Store(&proxyTrustState{cidrs: parsed}) + return nil +} + +func ClearTrustedProxyCIDRs() { + trustedProxies.Store(nil) +} + +func isTrustedProxy(remoteAddr string) bool { + state := trustedProxies.Load() + if state == nil { + return true + } + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + host = remoteAddr + } + ip := net.ParseIP(host) + if ip == nil { + return false + } + for _, n := range state.cidrs { + if n.Contains(ip) { + return true + } + } + return false +} + func RealIP(r *http.Request) string { - if v := strings.TrimSpace(r.Header.Get(headerCFConnectingIP)); v != "" { - return v - } - if v := lastNonEmptyXFF(r.Header.Get(headerXForwardedFor)); v != "" { - return v - } - if v := strings.TrimSpace(r.Header.Get(headerXRealIP)); v != "" { - return v + if isTrustedProxy(r.RemoteAddr) { + if v := strings.TrimSpace( + r.Header.Get(headerCFConnectingIP), + ); v != "" { + return v + } + if v := firstNonEmptyXFF( + r.Header.Get(headerXForwardedFor), + ); v != "" { + return v + } + if v := strings.TrimSpace(r.Header.Get(headerXRealIP)); v != "" { + return v + } } if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { return host @@ -39,13 +95,12 @@ func OptionalHeader(v string) *string { return &v } -func lastNonEmptyXFF(header string) string { +func firstNonEmptyXFF(header string) string { if header == "" { return "" } - parts := strings.Split(header, ",") - for i := len(parts) - 1; i >= 0; i-- { - if v := strings.TrimSpace(parts[i]); v != "" { + for _, p := range strings.Split(header, ",") { + if v := strings.TrimSpace(p); v != "" { return v } } diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator_test.go index d554270b..6c24bd73 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/docx/generator_test.go @@ -432,13 +432,13 @@ func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) { wantIP: "203.0.113.10", }, { - name: "XFF rightmost wins over XRI when no CF", + name: "XFF leftmost wins over XRI when no CF", headers: map[string]string{ "X-Forwarded-For": "198.51.100.1, 198.51.100.7", "X-Real-IP": "192.0.2.99", }, remote: "127.0.0.1:9999", - wantIP: "198.51.100.7", + wantIP: "198.51.100.1", }, { name: "XFF trailing-comma falls through to last non-empty", @@ -491,12 +491,12 @@ func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) { wantIP: "127.0.0.1", }, { - name: "XFF IPv6 rightmost", + name: "XFF mixed IPv4+IPv6 leftmost", headers: map[string]string{ "X-Forwarded-For": "198.51.100.1, 2001:db8::dead", }, remote: "127.0.0.1:9999", - wantIP: "2001:db8::dead", + wantIP: "198.51.100.1", }, { name: "CF value is trimmed of whitespace", diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator_test.go index 32428bab..43ae75aa 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/envfile/generator_test.go @@ -456,13 +456,13 @@ func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) { wantIP: "203.0.113.10", }, { - name: "XFF rightmost wins over XRI when no CF", + name: "XFF leftmost wins over XRI when no CF", headers: map[string]string{ "X-Forwarded-For": "198.51.100.1, 198.51.100.7", "X-Real-IP": "192.0.2.99", }, remote: "127.0.0.1:9999", - wantIP: "198.51.100.7", + wantIP: "198.51.100.1", }, { name: "XFF trailing-comma falls through to last non-empty", @@ -515,12 +515,12 @@ func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) { wantIP: "127.0.0.1", }, { - name: "XFF IPv6 rightmost", + name: "XFF mixed IPv4+IPv6 leftmost", headers: map[string]string{ "X-Forwarded-For": "198.51.100.1, 2001:db8::dead", }, remote: "127.0.0.1:9999", - wantIP: "2001:db8::dead", + wantIP: "198.51.100.1", }, { name: "CF value is trimmed of whitespace", diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler_test.go index 5e29cec9..91f957c4 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/kubeconfig/handler_test.go @@ -320,13 +320,13 @@ func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) { wantIP: "203.0.113.10", }, { - name: "XFF rightmost wins over XRI when no CF", + name: "XFF leftmost wins over XRI when no CF", headers: map[string]string{ "X-Forwarded-For": "198.51.100.1, 198.51.100.7", "X-Real-IP": "192.0.2.99", }, remote: "127.0.0.1:9999", - wantIP: "198.51.100.7", + wantIP: "198.51.100.1", }, { name: "XFF trailing-comma falls through to last non-empty", @@ -379,12 +379,12 @@ func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) { wantIP: "127.0.0.1", }, { - name: "XFF IPv6 rightmost", + name: "XFF mixed IPv4+IPv6 leftmost", headers: map[string]string{ "X-Forwarded-For": "198.51.100.1, 2001:db8::dead", }, remote: "127.0.0.1:9999", - wantIP: "2001:db8::dead", + wantIP: "198.51.100.1", }, { name: "CF value is trimmed of whitespace", diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator_test.go index 9cef05d4..dc2e965d 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/pdf/generator_test.go @@ -497,13 +497,13 @@ func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) { wantIP: "203.0.113.10", }, { - name: "XFF rightmost wins over XRI when no CF", + name: "XFF leftmost wins over XRI when no CF", headers: map[string]string{ "X-Forwarded-For": "198.51.100.1, 198.51.100.7", "X-Real-IP": "192.0.2.99", }, remote: "127.0.0.1:9999", - wantIP: "198.51.100.7", + wantIP: "198.51.100.1", }, { name: "XFF trailing-comma falls through to last non-empty", @@ -556,12 +556,12 @@ func TestTrigger_RecordsEventWithRequestMetadata(t *testing.T) { wantIP: "127.0.0.1", }, { - name: "XFF IPv6 rightmost", + name: "XFF mixed IPv4+IPv6 leftmost", headers: map[string]string{ "X-Forwarded-For": "198.51.100.1, 2001:db8::dead", }, remote: "127.0.0.1:9999", - wantIP: "2001:db8::dead", + wantIP: "198.51.100.1", }, { name: "CF value is trimmed of whitespace", diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/asset/pixel.jpg b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/asset/pixel.jpg index e3bb35dc..4cc263f1 100644 Binary files a/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/asset/pixel.jpg and b/PROJECTS/beginner/canary-token-generator/backend/internal/token/generators/webbug/asset/pixel.jpg differ