diff --git a/xff/fastlyxff/xff.go b/xff/fastlyxff/xff.go index 97f784a..91a927d 100644 --- a/xff/fastlyxff/xff.go +++ b/xff/fastlyxff/xff.go @@ -1,9 +1,10 @@ // Package fastlyxff provides Fastly CDN IP range management for trusted proxy handling. // -// This package parses Fastly's public IP ranges JSON file and generates Echo framework -// trust options for proper client IP extraction from X-Forwarded-For headers. -// It's designed specifically for services deployed behind Fastly's CDN that need -// to identify real client IPs for logging, rate limiting, and security purposes. +// This package parses Fastly's public IP ranges JSON file and provides middleware +// for both Echo framework and standard net/http for proper client IP extraction +// from X-Forwarded-For headers. It's designed specifically for services deployed +// behind Fastly's CDN that need to identify real client IPs for logging, rate +// limiting, and security purposes. // // Fastly publishes their edge server IP ranges in a JSON format that this package // consumes to automatically configure trusted proxy ranges. This ensures that @@ -14,8 +15,38 @@ // - Automatic parsing of Fastly's IP ranges JSON format // - Support for both IPv4 and IPv6 address ranges // - Echo framework integration via TrustOption generation +// - Standard net/http middleware support // - CIDR notation parsing and validation // +// # Echo Framework Usage +// +// fastlyRanges, err := fastlyxff.New("fastly.json") +// if err != nil { +// return err +// } +// options, err := fastlyRanges.EchoTrustOption() +// if err != nil { +// return err +// } +// e.IPExtractor = echo.ExtractIPFromXFFHeader(options...) +// +// # Net/HTTP Usage +// +// fastlyRanges, err := fastlyxff.New("fastly.json") +// if err != nil { +// return err +// } +// middleware := fastlyRanges.HTTPMiddleware() +// +// handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// // Both methods work - middleware updates r.RemoteAddr (with port 0) and stores in context +// realIP := fastlyxff.GetRealIP(r) // Preferred method +// // OR: host, _, _ := net.SplitHostPort(r.RemoteAddr) // Direct access (port will be "0") +// fmt.Fprintf(w, "Real IP: %s\n", realIP) +// }) +// +// http.ListenAndServe(":8080", middleware(handler)) +// // The JSON file typically contains IP ranges in this format: // // { @@ -25,10 +56,13 @@ package fastlyxff import ( + "context" "encoding/json" "net" + "net/http" "net/netip" "os" + "strings" "github.com/labstack/echo/v4" ) @@ -42,12 +76,15 @@ type FastlyXFF struct { } // TrustedNets holds parsed network prefixes for efficient IP range checking. -// This type is currently unused but reserved for future optimizations -// where frequent IP range lookups might benefit from pre-parsed prefixes. type TrustedNets struct { prefixes []netip.Prefix // Parsed network prefixes for efficient lookups } +// contextKey is used for storing the real client IP in request context +type contextKey string + +const realIPKey contextKey = "fastly-real-ip" + // New loads and parses Fastly IP ranges from a JSON file. // The file should contain Fastly's published IP ranges in their standard JSON format. // @@ -100,3 +137,98 @@ func (xff *FastlyXFF) EchoTrustOption() ([]echo.TrustOption, error) { return ranges, nil } + +// isTrustedProxy checks if the given IP address belongs to Fastly's trusted IP ranges. +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...) { + _, cidr, err := net.ParseCIDR(s) + if err != nil { + continue + } + if cidr.Contains(net.IP(addr.AsSlice())) { + return true + } + } + return false +} + +// extractRealIP extracts the real client IP from X-Forwarded-For header. +// It returns the rightmost IP that is not from a trusted Fastly proxy. +func (xff *FastlyXFF) extractRealIP(r *http.Request) string { + // Get the immediate peer IP + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + + // If the immediate peer is not a trusted Fastly proxy, return it + if !xff.isTrustedProxy(host) { + return host + } + + // Check X-Forwarded-For header + xff_header := r.Header.Get("X-Forwarded-For") + if xff_header == "" { + return host + } + + // Parse comma-separated IP list + ips := strings.Split(xff_header, ",") + if len(ips) == 0 { + return host + } + + // Find the leftmost IP that is not from a trusted proxy + // This represents the original client IP + for i := 0; i < len(ips); i++ { + ip := strings.TrimSpace(ips[i]) + if ip != "" && !xff.isTrustedProxy(ip) { + return ip + } + } + + // Fallback to the immediate peer + return host +} + +// HTTPMiddleware returns a net/http middleware that extracts real client IP +// from X-Forwarded-For headers when the request comes from trusted Fastly proxies. +// The real IP is stored in the request context and also updates r.RemoteAddr +// with port 0 (since the original port is from the proxy, not the real client). +func (xff *FastlyXFF) HTTPMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + realIP := xff.extractRealIP(r) + + // Store in context for GetRealIP function + ctx := context.WithValue(r.Context(), realIPKey, realIP) + r = r.WithContext(ctx) + + // Update RemoteAddr to be consistent with extracted IP + // Use port 0 since the original port is from the proxy, not the real client + r.RemoteAddr = net.JoinHostPort(realIP, "0") + + next.ServeHTTP(w, r) + }) + } +} + +// GetRealIP retrieves the real client IP from the request context. +// This should be used after the HTTPMiddleware has processed the request. +// Returns the remote address if no real IP was extracted. +func GetRealIP(r *http.Request) string { + if ip, ok := r.Context().Value(realIPKey).(string); ok { + return ip + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/xff/fastlyxff/xff_test.go b/xff/fastlyxff/xff_test.go index b1b391a..213f6f1 100644 --- a/xff/fastlyxff/xff_test.go +++ b/xff/fastlyxff/xff_test.go @@ -1,6 +1,11 @@ package fastlyxff -import "testing" +import ( + "net" + "net/http" + "net/http/httptest" + "testing" +) func TestFastlyIPRanges(t *testing.T) { fastlyxff, err := New("fastly.json") @@ -18,3 +23,193 @@ func TestFastlyIPRanges(t *testing.T) { t.Fail() } } + +func TestHTTPMiddleware(t *testing.T) { + // Create a test FastlyXFF instance with known IP ranges + xff := &FastlyXFF{ + IPv4: []string{"192.0.2.0/24", "203.0.113.0/24"}, + IPv6: []string{"2001:db8::/32"}, + } + + middleware := xff.HTTPMiddleware() + + tests := []struct { + name string + remoteAddr string + xForwardedFor string + expectedRealIP string + }{ + { + name: "direct connection", + remoteAddr: "198.51.100.1:12345", + xForwardedFor: "", + expectedRealIP: "198.51.100.1", + }, + { + name: "trusted proxy with XFF", + remoteAddr: "192.0.2.1:80", + xForwardedFor: "198.51.100.1", + expectedRealIP: "198.51.100.1", + }, + { + name: "trusted proxy with multiple XFF", + remoteAddr: "192.0.2.1:80", + xForwardedFor: "198.51.100.1, 203.0.113.1", + expectedRealIP: "198.51.100.1", + }, + { + name: "untrusted proxy ignored", + remoteAddr: "198.51.100.2:80", + xForwardedFor: "10.0.0.1", + expectedRealIP: "198.51.100.2", + }, + { + name: "IPv6 trusted proxy", + remoteAddr: "[2001:db8::1]:80", + xForwardedFor: "198.51.100.1", + expectedRealIP: "198.51.100.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test handler that captures both GetRealIP and r.RemoteAddr + var capturedRealIP, capturedRemoteAddr string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedRealIP = GetRealIP(r) + capturedRemoteAddr = r.RemoteAddr + w.WriteHeader(http.StatusOK) + }) + + // Create request with middleware + 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) + + // Test GetRealIP function + if capturedRealIP != tt.expectedRealIP { + t.Errorf("GetRealIP: expected %s, got %s", tt.expectedRealIP, capturedRealIP) + } + + // Test that r.RemoteAddr is updated with real IP and port 0 + // (since the original port is from the proxy, not the real client) + expectedRemoteAddr := net.JoinHostPort(tt.expectedRealIP, "0") + if capturedRemoteAddr != expectedRemoteAddr { + t.Errorf("RemoteAddr: expected %s, got %s", expectedRemoteAddr, capturedRemoteAddr) + } + }) + } +} + +func TestIsTrustedProxy(t *testing.T) { + xff := &FastlyXFF{ + IPv4: []string{"192.0.2.0/24", "203.0.113.0/24"}, + IPv6: []string{"2001:db8::/32"}, + } + + tests := []struct { + ip string + expected bool + }{ + {"192.0.2.1", true}, + {"192.0.2.255", true}, + {"203.0.113.1", true}, + {"192.0.3.1", false}, + {"198.51.100.1", false}, + {"2001:db8::1", true}, + {"2001:db8:ffff::1", true}, + {"2001:db9::1", false}, + {"invalid-ip", false}, + } + + 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 TestExtractRealIP(t *testing.T) { + xff := &FastlyXFF{ + IPv4: []string{"192.0.2.0/24"}, + IPv6: []string{"2001:db8::/32"}, + } + + tests := []struct { + name string + remoteAddr string + xForwardedFor string + expected string + }{ + { + name: "no XFF header", + remoteAddr: "198.51.100.1:12345", + xForwardedFor: "", + expected: "198.51.100.1", + }, + { + name: "trusted proxy with single IP", + remoteAddr: "192.0.2.1:80", + xForwardedFor: "198.51.100.1", + expected: "198.51.100.1", + }, + { + name: "trusted proxy with multiple IPs", + remoteAddr: "192.0.2.1:80", + xForwardedFor: "198.51.100.1, 203.0.113.5", + expected: "198.51.100.1", + }, + { + name: "untrusted proxy", + remoteAddr: "198.51.100.1:80", + xForwardedFor: "10.0.0.1", + expected: "198.51.100.1", + }, + { + name: "empty XFF", + remoteAddr: "192.0.2.1:80", + xForwardedFor: "", + expected: "192.0.2.1", + }, + { + name: "malformed remote addr", + remoteAddr: "192.0.2.1", + xForwardedFor: "198.51.100.1", + expected: "198.51.100.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = tt.remoteAddr + if tt.xForwardedFor != "" { + req.Header.Set("X-Forwarded-For", tt.xForwardedFor) + } + + result := xff.extractRealIP(req) + if result != tt.expected { + t.Errorf("extractRealIP() = %s, expected %s", result, tt.expected) + } + }) + } +} + +func TestGetRealIPWithoutMiddleware(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "198.51.100.1:12345" + + realIP := GetRealIP(req) + expected := "198.51.100.1" + if realIP != expected { + t.Errorf("GetRealIP() = %s, expected %s", realIP, expected) + } +}