From 4767caf7b8b6db2beaae144c55a82d8711e588c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ask=20Bj=C3=B8rn=20Hansen?= Date: Sat, 27 Sep 2025 14:46:02 -0700 Subject: [PATCH] feat(xff): add AddTrustedCIDR for custom proxies - Add AddTrustedCIDR() method to support non-Fastly proxies - Enable trusting custom CIDR ranges (e.g., 10.0.0.0/8) - Validate CIDR format before adding to trusted list - Maintain backward compatibility with Fastly-only usage Allows mixed proxy environments where requests pass through both Fastly CDN and custom internal proxies/load balancers. Uses precise CIDR terminology instead of generic "range". --- xff/fastlyxff/xff.go | 48 +++++++++++-- xff/fastlyxff/xff_test.go | 141 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 6 deletions(-) diff --git a/xff/fastlyxff/xff.go b/xff/fastlyxff/xff.go index 91a927d..2575f32 100644 --- a/xff/fastlyxff/xff.go +++ b/xff/fastlyxff/xff.go @@ -47,6 +47,23 @@ // // http.ListenAndServe(":8080", middleware(handler)) // +// # Net/HTTP with Additional Trusted Ranges +// +// fastlyRanges, err := fastlyxff.New("fastly.json") +// if err != nil { +// return err +// } +// +// // Add custom trusted CIDRs (e.g., internal load balancers) +// // Note: For Echo framework, use the ekko package for additional ranges +// err = fastlyRanges.AddTrustedCIDR("10.0.0.0/8") +// if err != nil { +// return err +// } +// +// middleware := fastlyRanges.HTTPMiddleware() +// handler := middleware(yourHandler) +// // The JSON file typically contains IP ranges in this format: // // { @@ -69,10 +86,11 @@ import ( // FastlyXFF represents Fastly's published IP ranges for their CDN edge servers. // This structure matches the JSON format provided by Fastly for their public IP ranges. -// It contains separate lists for IPv4 and IPv6 CIDR ranges. +// It contains separate lists for IPv4 and IPv6 CIDR ranges, plus additional trusted CIDRs. type FastlyXFF struct { - IPv4 []string `json:"addresses"` // IPv4 CIDR ranges (e.g., "23.235.32.0/20") - IPv6 []string `json:"ipv6_addresses"` // IPv6 CIDR ranges (e.g., "2a04:4e40::/32") + IPv4 []string `json:"addresses"` // IPv4 CIDR ranges (e.g., "23.235.32.0/20") + IPv6 []string `json:"ipv6_addresses"` // IPv6 CIDR ranges (e.g., "2a04:4e40::/32") + extraCIDRs []string // Additional trusted CIDRs added via AddTrustedCIDR } // TrustedNets holds parsed network prefixes for efficient IP range checking. @@ -138,15 +156,33 @@ func (xff *FastlyXFF) EchoTrustOption() ([]echo.TrustOption, error) { return ranges, nil } -// isTrustedProxy checks if the given IP address belongs to Fastly's trusted IP ranges. +// AddTrustedCIDR adds an additional CIDR to the list of trusted proxies. +// This allows trusting proxies beyond Fastly's published ranges. +// The cidr parameter must be a valid CIDR notation (e.g., "10.0.0.0/8", "192.168.1.0/24"). +// Returns an error if the CIDR format is invalid. +func (xff *FastlyXFF) AddTrustedCIDR(cidr string) error { + // Validate CIDR format + _, _, err := net.ParseCIDR(cidr) + if err != nil { + return err + } + + // Add to extra CIDRs + xff.extraCIDRs = append(xff.extraCIDRs, cidr) + return nil +} + +// isTrustedProxy checks if the given IP address belongs to Fastly's trusted IP ranges +// or any additional CIDRs added via AddTrustedCIDR. func (xff *FastlyXFF) isTrustedProxy(ip string) bool { addr, err := netip.ParseAddr(ip) if err != nil { return false } - // Check all IPv4 and IPv6 ranges - for _, s := range append(xff.IPv4, xff.IPv6...) { + // Check all IPv4 and IPv6 ranges (Fastly + additional) + allRanges := append(append(xff.IPv4, xff.IPv6...), xff.extraCIDRs...) + for _, s := range allRanges { _, cidr, err := net.ParseCIDR(s) if err != nil { continue diff --git a/xff/fastlyxff/xff_test.go b/xff/fastlyxff/xff_test.go index 213f6f1..84c2fa8 100644 --- a/xff/fastlyxff/xff_test.go +++ b/xff/fastlyxff/xff_test.go @@ -213,3 +213,144 @@ func TestGetRealIPWithoutMiddleware(t *testing.T) { t.Errorf("GetRealIP() = %s, expected %s", realIP, expected) } } + +func TestAddTrustedCIDR(t *testing.T) { + xff := &FastlyXFF{ + IPv4: []string{"192.0.2.0/24"}, + IPv6: []string{"2001:db8::/32"}, + } + + tests := []struct { + name string + cidr string + wantErr bool + }{ + {"valid IPv4 range", "10.0.0.0/8", false}, + {"valid IPv6 range", "fc00::/7", false}, + {"valid single IP", "203.0.113.1/32", false}, + {"invalid CIDR", "not-a-cidr", true}, + {"invalid format", "10.0.0.0/99", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := xff.AddTrustedCIDR(tt.cidr) + if (err != nil) != tt.wantErr { + t.Errorf("AddTrustedCIDR(%s) error = %v, wantErr %v", tt.cidr, err, tt.wantErr) + } + }) + } +} + +func TestCustomTrustedCIDRs(t *testing.T) { + xff := &FastlyXFF{ + IPv4: []string{"192.0.2.0/24"}, + IPv6: []string{"2001:db8::/32"}, + } + + // Add custom trusted CIDRs + err := xff.AddTrustedCIDR("10.0.0.0/8") + if err != nil { + t.Fatalf("Failed to add trusted CIDR: %v", err) + } + + err = xff.AddTrustedCIDR("172.16.0.0/12") + if err != nil { + t.Fatalf("Failed to add trusted CIDR: %v", err) + } + + tests := []struct { + ip string + expected bool + }{ + // Original Fastly ranges + {"192.0.2.1", true}, + {"2001:db8::1", true}, + // Custom CIDRs + {"10.1.2.3", true}, + {"172.16.1.1", true}, + // Not trusted + {"198.51.100.1", false}, + {"172.15.1.1", false}, + {"10.0.0.0", true}, // Network address should still match + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + result := xff.isTrustedProxy(tt.ip) + if result != tt.expected { + t.Errorf("isTrustedProxy(%s) = %v, expected %v", tt.ip, result, tt.expected) + } + }) + } +} + +func TestHTTPMiddlewareWithCustomCIDRs(t *testing.T) { + xff := &FastlyXFF{ + IPv4: []string{"192.0.2.0/24"}, + IPv6: []string{"2001:db8::/32"}, + } + + // Add custom trusted CIDR for internal proxies + err := xff.AddTrustedCIDR("10.0.0.0/8") + if err != nil { + t.Fatalf("Failed to add trusted CIDR: %v", err) + } + + middleware := xff.HTTPMiddleware() + + tests := []struct { + name string + remoteAddr string + xForwardedFor string + expectedRealIP string + }{ + { + name: "custom trusted proxy with XFF", + remoteAddr: "10.1.2.3:80", + xForwardedFor: "198.51.100.1", + expectedRealIP: "198.51.100.1", + }, + { + name: "fastly proxy with XFF", + remoteAddr: "192.0.2.1:80", + xForwardedFor: "198.51.100.1", + expectedRealIP: "198.51.100.1", + }, + { + name: "untrusted proxy ignored", + remoteAddr: "172.16.1.1:80", + xForwardedFor: "198.51.100.1", + expectedRealIP: "172.16.1.1", + }, + { + name: "chain through custom and fastly", + remoteAddr: "192.0.2.1:80", + xForwardedFor: "198.51.100.1, 10.1.2.3", + expectedRealIP: "198.51.100.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var capturedIP string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedIP = GetRealIP(r) + w.WriteHeader(http.StatusOK) + }) + + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = tt.remoteAddr + if tt.xForwardedFor != "" { + req.Header.Set("X-Forwarded-For", tt.xForwardedFor) + } + + rr := httptest.NewRecorder() + middleware(handler).ServeHTTP(rr, req) + + if capturedIP != tt.expectedRealIP { + t.Errorf("expected real IP %s, got %s", tt.expectedRealIP, capturedIP) + } + }) + } +}