feat(xff): add net/http middleware support
- Add HTTPMiddleware() method for standard net/http handlers - Add GetRealIP() helper to extract client IP from context - Update r.RemoteAddr with real IP and port 0 (proxy port invalid) - Support both IPv4 and IPv6 Fastly IP range validation - Maintain backward compatibility with existing Echo support The middleware extracts real client IPs from X-Forwarded-For headers when requests come from trusted Fastly proxy ranges.
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
// Package fastlyxff provides Fastly CDN IP range management for trusted proxy handling.
|
// 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
|
// This package parses Fastly's public IP ranges JSON file and provides middleware
|
||||||
// trust options for proper client IP extraction from X-Forwarded-For headers.
|
// for both Echo framework and standard net/http for proper client IP extraction
|
||||||
// It's designed specifically for services deployed behind Fastly's CDN that need
|
// from X-Forwarded-For headers. It's designed specifically for services deployed
|
||||||
// to identify real client IPs for logging, rate limiting, and security purposes.
|
// 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
|
// Fastly publishes their edge server IP ranges in a JSON format that this package
|
||||||
// consumes to automatically configure trusted proxy ranges. This ensures that
|
// consumes to automatically configure trusted proxy ranges. This ensures that
|
||||||
@@ -14,8 +15,38 @@
|
|||||||
// - Automatic parsing of Fastly's IP ranges JSON format
|
// - Automatic parsing of Fastly's IP ranges JSON format
|
||||||
// - Support for both IPv4 and IPv6 address ranges
|
// - Support for both IPv4 and IPv6 address ranges
|
||||||
// - Echo framework integration via TrustOption generation
|
// - Echo framework integration via TrustOption generation
|
||||||
|
// - Standard net/http middleware support
|
||||||
// - CIDR notation parsing and validation
|
// - 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:
|
// The JSON file typically contains IP ranges in this format:
|
||||||
//
|
//
|
||||||
// {
|
// {
|
||||||
@@ -25,10 +56,13 @@
|
|||||||
package fastlyxff
|
package fastlyxff
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
@@ -42,12 +76,15 @@ type FastlyXFF struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TrustedNets holds parsed network prefixes for efficient IP range checking.
|
// 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 {
|
type TrustedNets struct {
|
||||||
prefixes []netip.Prefix // Parsed network prefixes for efficient lookups
|
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.
|
// 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.
|
// 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
|
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
|
||||||
|
}
|
||||||
|
@@ -1,6 +1,11 @@
|
|||||||
package fastlyxff
|
package fastlyxff
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestFastlyIPRanges(t *testing.T) {
|
func TestFastlyIPRanges(t *testing.T) {
|
||||||
fastlyxff, err := New("fastly.json")
|
fastlyxff, err := New("fastly.json")
|
||||||
@@ -18,3 +23,193 @@ func TestFastlyIPRanges(t *testing.T) {
|
|||||||
t.Fail()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user