feat(middleware): extract shared IP extraction logic
- commit
- 0c68ab93cfc0aeadc35b8441871b8df8c115480b
- author
- bnema · · 2026-01-18 03:16 UTC
- parent
- 4117ca5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +313 -0
A internal/adapters/in/http/middleware/clientip.go +83 -0
| @@ -0,0 +1,83 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net" | |
| 5 | + | "net/http" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | |
| 10 | + | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | |
| 11 | + | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | |
| 12 | + | func ParseTrustedProxies(proxies []string) []*net.IPNet { | |
| 13 | + | var nets []*net.IPNet | |
| 14 | + | for _, proxy := range proxies { | |
| 15 | + | // Try parsing as CIDR | |
| 16 | + | _, ipNet, err := net.ParseCIDR(proxy) | |
| 17 | + | if err == nil { | |
| 18 | + | nets = append(nets, ipNet) | |
| 19 | + | continue | |
| 20 | + | } | |
| 21 | + | // Try parsing as single IP | |
| 22 | + | ip := net.ParseIP(proxy) | |
| 23 | + | if ip != nil { | |
| 24 | + | // Convert single IP to /32 or /128 CIDR | |
| 25 | + | bits := 32 | |
| 26 | + | if ip.To4() == nil { | |
| 27 | + | bits = 128 | |
| 28 | + | } | |
| 29 | + | nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | return nets | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // IsTrustedProxy checks if the given IP address is from a trusted proxy. | |
| 36 | + | // Returns false if trustedNets is empty or the IP cannot be parsed. | |
| 37 | + | func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool { | |
| 38 | + | if len(trustedNets) == 0 { | |
| 39 | + | return false | |
| 40 | + | } | |
| 41 | + | parsedIP := net.ParseIP(ip) | |
| 42 | + | if parsedIP == nil { | |
| 43 | + | return false | |
| 44 | + | } | |
| 45 | + | for _, ipNet := range trustedNets { | |
| 46 | + | if ipNet.Contains(parsedIP) { | |
| 47 | + | return true | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | return false | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // GetClientIP extracts the client IP address from the request. | |
| 54 | + | // It only honors X-Forwarded-For and X-Real-IP headers when the request | |
| 55 | + | // originates from a trusted proxy. This prevents attackers from spoofing | |
| 56 | + | // their IP address when Gordon is exposed directly without a reverse proxy. | |
| 57 | + | func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |
| 58 | + | // Extract the direct connection IP from RemoteAddr | |
| 59 | + | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |
| 60 | + | if err != nil { | |
| 61 | + | remoteIP = r.RemoteAddr | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // Only honor proxy headers if the request comes from a trusted proxy | |
| 65 | + | if IsTrustedProxy(remoteIP, trustedNets) { | |
| 66 | + | // Check X-Forwarded-For for proxied requests | |
| 67 | + | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | |
| 68 | + | // Take first IP in chain (original client) | |
| 69 | + | if first, _, found := strings.Cut(xff, ","); found { | |
| 70 | + | return strings.TrimSpace(first) | |
| 71 | + | } | |
| 72 | + | return strings.TrimSpace(xff) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Check X-Real-IP | |
| 76 | + | if xri := r.Header.Get("X-Real-IP"); xri != "" { | |
| 77 | + | return xri | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Fall back to RemoteAddr (direct connection IP) | |
| 82 | + | return remoteIP | |
| 83 | + | } | |
| @@ -0,0 +1,83 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "net/http" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet. | |||
| 10 | + | // It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8"). | |||
| 11 | + | // Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks. | |||
| 12 | + | func ParseTrustedProxies(proxies []string) []*net.IPNet { | |||
| 13 | + | var nets []*net.IPNet | |||
| 14 | + | for _, proxy := range proxies { | |||
| 15 | + | // Try parsing as CIDR | |||
| 16 | + | _, ipNet, err := net.ParseCIDR(proxy) | |||
| 17 | + | if err == nil { | |||
| 18 | + | nets = append(nets, ipNet) | |||
| 19 | + | continue | |||
| 20 | + | } | |||
| 21 | + | // Try parsing as single IP | |||
| 22 | + | ip := net.ParseIP(proxy) | |||
| 23 | + | if ip != nil { | |||
| 24 | + | // Convert single IP to /32 or /128 CIDR | |||
| 25 | + | bits := 32 | |||
| 26 | + | if ip.To4() == nil { | |||
| 27 | + | bits = 128 | |||
| 28 | + | } | |||
| 29 | + | nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | return nets | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // IsTrustedProxy checks if the given IP address is from a trusted proxy. | |||
| 36 | + | // Returns false if trustedNets is empty or the IP cannot be parsed. | |||
| 37 | + | func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool { | |||
| 38 | + | if len(trustedNets) == 0 { | |||
| 39 | + | return false | |||
| 40 | + | } | |||
| 41 | + | parsedIP := net.ParseIP(ip) | |||
| 42 | + | if parsedIP == nil { | |||
| 43 | + | return false | |||
| 44 | + | } | |||
| 45 | + | for _, ipNet := range trustedNets { | |||
| 46 | + | if ipNet.Contains(parsedIP) { | |||
| 47 | + | return true | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | return false | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // GetClientIP extracts the client IP address from the request. | |||
| 54 | + | // It only honors X-Forwarded-For and X-Real-IP headers when the request | |||
| 55 | + | // originates from a trusted proxy. This prevents attackers from spoofing | |||
| 56 | + | // their IP address when Gordon is exposed directly without a reverse proxy. | |||
| 57 | + | func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string { | |||
| 58 | + | // Extract the direct connection IP from RemoteAddr | |||
| 59 | + | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) | |||
| 60 | + | if err != nil { | |||
| 61 | + | remoteIP = r.RemoteAddr | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Only honor proxy headers if the request comes from a trusted proxy | |||
| 65 | + | if IsTrustedProxy(remoteIP, trustedNets) { | |||
| 66 | + | // Check X-Forwarded-For for proxied requests | |||
| 67 | + | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | |||
| 68 | + | // Take first IP in chain (original client) | |||
| 69 | + | if first, _, found := strings.Cut(xff, ","); found { | |||
| 70 | + | return strings.TrimSpace(first) | |||
| 71 | + | } | |||
| 72 | + | return strings.TrimSpace(xff) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Check X-Real-IP | |||
| 76 | + | if xri := r.Header.Get("X-Real-IP"); xri != "" { | |||
| 77 | + | return xri | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Fall back to RemoteAddr (direct connection IP) | |||
| 82 | + | return remoteIP | |||
| 83 | + | } | |||
A internal/adapters/in/http/middleware/clientip_test.go +230 -0
| @@ -0,0 +1,230 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net" | |
| 5 | + | "net/http" | |
| 6 | + | "net/http/httptest" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestGetClientIP(t *testing.T) { | |
| 13 | + | // Parse trusted proxies for tests | |
| 14 | + | trustedNets := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) | |
| 15 | + | noTrustedNets := []*net.IPNet{} | |
| 16 | + | ||
| 17 | + | tests := []struct { | |
| 18 | + | name string | |
| 19 | + | remoteAddr string | |
| 20 | + | xff string | |
| 21 | + | xRealIP string | |
| 22 | + | trustedNets []*net.IPNet | |
| 23 | + | wantIP string | |
| 24 | + | }{ | |
| 25 | + | { | |
| 26 | + | name: "from RemoteAddr with port", | |
| 27 | + | remoteAddr: "192.168.1.100:12345", | |
| 28 | + | trustedNets: noTrustedNets, | |
| 29 | + | wantIP: "192.168.1.100", | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | name: "from RemoteAddr without port", | |
| 33 | + | remoteAddr: "192.168.1.100", | |
| 34 | + | trustedNets: noTrustedNets, | |
| 35 | + | wantIP: "192.168.1.100", | |
| 36 | + | }, | |
| 37 | + | { | |
| 38 | + | name: "XFF ignored when no trusted proxies", | |
| 39 | + | remoteAddr: "192.168.1.100:12345", | |
| 40 | + | xff: "203.0.113.50", | |
| 41 | + | trustedNets: noTrustedNets, | |
| 42 | + | wantIP: "192.168.1.100", | |
| 43 | + | }, | |
| 44 | + | { | |
| 45 | + | name: "XFF ignored when remote is not trusted", | |
| 46 | + | remoteAddr: "192.168.1.100:12345", | |
| 47 | + | xff: "203.0.113.50", | |
| 48 | + | trustedNets: trustedNets, // trusts 127.0.0.1 and 10.0.0.0/8 | |
| 49 | + | wantIP: "192.168.1.100", | |
| 50 | + | }, | |
| 51 | + | { | |
| 52 | + | name: "XFF honored from trusted proxy (single IP)", | |
| 53 | + | remoteAddr: "127.0.0.1:12345", | |
| 54 | + | xff: "203.0.113.50", | |
| 55 | + | trustedNets: trustedNets, | |
| 56 | + | wantIP: "203.0.113.50", | |
| 57 | + | }, | |
| 58 | + | { | |
| 59 | + | name: "XFF honored from trusted proxy (multiple IPs)", | |
| 60 | + | remoteAddr: "127.0.0.1:12345", | |
| 61 | + | xff: "203.0.113.50, 10.0.0.1, 172.16.0.1", | |
| 62 | + | trustedNets: trustedNets, | |
| 63 | + | wantIP: "203.0.113.50", | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "XFF honored from trusted CIDR", | |
| 67 | + | remoteAddr: "10.1.2.3:12345", | |
| 68 | + | xff: "203.0.113.50", | |
| 69 | + | trustedNets: trustedNets, | |
| 70 | + | wantIP: "203.0.113.50", | |
| 71 | + | }, | |
| 72 | + | { | |
| 73 | + | name: "X-Real-IP honored from trusted proxy", | |
| 74 | + | remoteAddr: "127.0.0.1:12345", | |
| 75 | + | xRealIP: "203.0.113.50", | |
| 76 | + | trustedNets: trustedNets, | |
| 77 | + | wantIP: "203.0.113.50", | |
| 78 | + | }, | |
| 79 | + | { | |
| 80 | + | name: "X-Real-IP ignored when not trusted", | |
| 81 | + | remoteAddr: "192.168.1.100:12345", | |
| 82 | + | xRealIP: "203.0.113.50", | |
| 83 | + | trustedNets: trustedNets, | |
| 84 | + | wantIP: "192.168.1.100", | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | name: "XFF takes precedence over X-Real-IP", | |
| 88 | + | remoteAddr: "127.0.0.1:12345", | |
| 89 | + | xff: "203.0.113.50", | |
| 90 | + | xRealIP: "203.0.113.60", | |
| 91 | + | trustedNets: trustedNets, | |
| 92 | + | wantIP: "203.0.113.50", | |
| 93 | + | }, | |
| 94 | + | { | |
| 95 | + | name: "IPv6 RemoteAddr", | |
| 96 | + | remoteAddr: "[::1]:12345", | |
| 97 | + | trustedNets: noTrustedNets, | |
| 98 | + | wantIP: "::1", | |
| 99 | + | }, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | for _, tt := range tests { | |
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |
| 104 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 105 | + | req.RemoteAddr = tt.remoteAddr | |
| 106 | + | if tt.xff != "" { | |
| 107 | + | req.Header.Set("X-Forwarded-For", tt.xff) | |
| 108 | + | } | |
| 109 | + | if tt.xRealIP != "" { | |
| 110 | + | req.Header.Set("X-Real-IP", tt.xRealIP) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | ip := GetClientIP(req, tt.trustedNets) | |
| 114 | + | assert.Equal(t, tt.wantIP, ip) | |
| 115 | + | }) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestParseTrustedProxies(t *testing.T) { | |
| 120 | + | tests := []struct { | |
| 121 | + | name string | |
| 122 | + | proxies []string | |
| 123 | + | testIP string | |
| 124 | + | want bool | |
| 125 | + | }{ | |
| 126 | + | { | |
| 127 | + | name: "empty list", | |
| 128 | + | proxies: []string{}, | |
| 129 | + | testIP: "192.168.1.1", | |
| 130 | + | want: false, | |
| 131 | + | }, | |
| 132 | + | { | |
| 133 | + | name: "single IP match", | |
| 134 | + | proxies: []string{"192.168.1.1"}, | |
| 135 | + | testIP: "192.168.1.1", | |
| 136 | + | want: true, | |
| 137 | + | }, | |
| 138 | + | { | |
| 139 | + | name: "single IP no match", | |
| 140 | + | proxies: []string{"192.168.1.1"}, | |
| 141 | + | testIP: "192.168.1.2", | |
| 142 | + | want: false, | |
| 143 | + | }, | |
| 144 | + | { | |
| 145 | + | name: "CIDR match", | |
| 146 | + | proxies: []string{"10.0.0.0/8"}, | |
| 147 | + | testIP: "10.1.2.3", | |
| 148 | + | want: true, | |
| 149 | + | }, | |
| 150 | + | { | |
| 151 | + | name: "CIDR no match", | |
| 152 | + | proxies: []string{"10.0.0.0/8"}, | |
| 153 | + | testIP: "192.168.1.1", | |
| 154 | + | want: false, | |
| 155 | + | }, | |
| 156 | + | { | |
| 157 | + | name: "mixed IP and CIDR", | |
| 158 | + | proxies: []string{"127.0.0.1", "10.0.0.0/8", "172.16.0.0/12"}, | |
| 159 | + | testIP: "172.20.1.1", | |
| 160 | + | want: true, | |
| 161 | + | }, | |
| 162 | + | { | |
| 163 | + | name: "invalid entries ignored", | |
| 164 | + | proxies: []string{"not-an-ip", "10.0.0.0/8"}, | |
| 165 | + | testIP: "10.1.2.3", | |
| 166 | + | want: true, | |
| 167 | + | }, | |
| 168 | + | { | |
| 169 | + | name: "IPv6 single IP", | |
| 170 | + | proxies: []string{"::1"}, | |
| 171 | + | testIP: "::1", | |
| 172 | + | want: true, | |
| 173 | + | }, | |
| 174 | + | { | |
| 175 | + | name: "IPv6 CIDR", | |
| 176 | + | proxies: []string{"fd00::/8"}, | |
| 177 | + | testIP: "fd12:3456::1", | |
| 178 | + | want: true, | |
| 179 | + | }, | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | for _, tt := range tests { | |
| 183 | + | t.Run(tt.name, func(t *testing.T) { | |
| 184 | + | nets := ParseTrustedProxies(tt.proxies) | |
| 185 | + | got := IsTrustedProxy(tt.testIP, nets) | |
| 186 | + | assert.Equal(t, tt.want, got) | |
| 187 | + | }) | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func TestIsTrustedProxy(t *testing.T) { | |
| 192 | + | tests := []struct { | |
| 193 | + | name string | |
| 194 | + | ip string | |
| 195 | + | trustedNets []*net.IPNet | |
| 196 | + | want bool | |
| 197 | + | }{ | |
| 198 | + | { | |
| 199 | + | name: "empty trusted nets", | |
| 200 | + | ip: "192.168.1.1", | |
| 201 | + | trustedNets: []*net.IPNet{}, | |
| 202 | + | want: false, | |
| 203 | + | }, | |
| 204 | + | { | |
| 205 | + | name: "nil trusted nets", | |
| 206 | + | ip: "192.168.1.1", | |
| 207 | + | trustedNets: nil, | |
| 208 | + | want: false, | |
| 209 | + | }, | |
| 210 | + | { | |
| 211 | + | name: "invalid IP", | |
| 212 | + | ip: "not-an-ip", | |
| 213 | + | trustedNets: ParseTrustedProxies([]string{"192.168.1.0/24"}), | |
| 214 | + | want: false, | |
| 215 | + | }, | |
| 216 | + | { | |
| 217 | + | name: "empty IP", | |
| 218 | + | ip: "", | |
| 219 | + | trustedNets: ParseTrustedProxies([]string{"192.168.1.0/24"}), | |
| 220 | + | want: false, | |
| 221 | + | }, | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | for _, tt := range tests { | |
| 225 | + | t.Run(tt.name, func(t *testing.T) { | |
| 226 | + | got := IsTrustedProxy(tt.ip, tt.trustedNets) | |
| 227 | + | assert.Equal(t, tt.want, got) | |
| 228 | + | }) | |
| 229 | + | } | |
| 230 | + | } | |
| @@ -0,0 +1,230 @@ | |||||
| 1 | + | package middleware | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestGetClientIP(t *testing.T) { | |||
| 13 | + | // Parse trusted proxies for tests | |||
| 14 | + | trustedNets := ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) | |||
| 15 | + | noTrustedNets := []*net.IPNet{} | |||
| 16 | + | ||||
| 17 | + | tests := []struct { | |||
| 18 | + | name string | |||
| 19 | + | remoteAddr string | |||
| 20 | + | xff string | |||
| 21 | + | xRealIP string | |||
| 22 | + | trustedNets []*net.IPNet | |||
| 23 | + | wantIP string | |||
| 24 | + | }{ | |||
| 25 | + | { | |||
| 26 | + | name: "from RemoteAddr with port", | |||
| 27 | + | remoteAddr: "192.168.1.100:12345", | |||
| 28 | + | trustedNets: noTrustedNets, | |||
| 29 | + | wantIP: "192.168.1.100", | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "from RemoteAddr without port", | |||
| 33 | + | remoteAddr: "192.168.1.100", | |||
| 34 | + | trustedNets: noTrustedNets, | |||
| 35 | + | wantIP: "192.168.1.100", | |||
| 36 | + | }, | |||
| 37 | + | { | |||
| 38 | + | name: "XFF ignored when no trusted proxies", | |||
| 39 | + | remoteAddr: "192.168.1.100:12345", | |||
| 40 | + | xff: "203.0.113.50", | |||
| 41 | + | trustedNets: noTrustedNets, | |||
| 42 | + | wantIP: "192.168.1.100", | |||
| 43 | + | }, | |||
| 44 | + | { | |||
| 45 | + | name: "XFF ignored when remote is not trusted", | |||
| 46 | + | remoteAddr: "192.168.1.100:12345", | |||
| 47 | + | xff: "203.0.113.50", | |||
| 48 | + | trustedNets: trustedNets, // trusts 127.0.0.1 and 10.0.0.0/8 | |||
| 49 | + | wantIP: "192.168.1.100", | |||
| 50 | + | }, | |||
| 51 | + | { | |||
| 52 | + | name: "XFF honored from trusted proxy (single IP)", | |||
| 53 | + | remoteAddr: "127.0.0.1:12345", | |||
| 54 | + | xff: "203.0.113.50", | |||
| 55 | + | trustedNets: trustedNets, | |||
| 56 | + | wantIP: "203.0.113.50", | |||
| 57 | + | }, | |||
| 58 | + | { | |||
| 59 | + | name: "XFF honored from trusted proxy (multiple IPs)", | |||
| 60 | + | remoteAddr: "127.0.0.1:12345", | |||
| 61 | + | xff: "203.0.113.50, 10.0.0.1, 172.16.0.1", | |||
| 62 | + | trustedNets: trustedNets, | |||
| 63 | + | wantIP: "203.0.113.50", | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "XFF honored from trusted CIDR", | |||
| 67 | + | remoteAddr: "10.1.2.3:12345", | |||
| 68 | + | xff: "203.0.113.50", | |||
| 69 | + | trustedNets: trustedNets, | |||
| 70 | + | wantIP: "203.0.113.50", | |||
| 71 | + | }, | |||
| 72 | + | { | |||
| 73 | + | name: "X-Real-IP honored from trusted proxy", | |||
| 74 | + | remoteAddr: "127.0.0.1:12345", | |||
| 75 | + | xRealIP: "203.0.113.50", | |||
| 76 | + | trustedNets: trustedNets, | |||
| 77 | + | wantIP: "203.0.113.50", | |||
| 78 | + | }, | |||
| 79 | + | { | |||
| 80 | + | name: "X-Real-IP ignored when not trusted", | |||
| 81 | + | remoteAddr: "192.168.1.100:12345", | |||
| 82 | + | xRealIP: "203.0.113.50", | |||
| 83 | + | trustedNets: trustedNets, | |||
| 84 | + | wantIP: "192.168.1.100", | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | name: "XFF takes precedence over X-Real-IP", | |||
| 88 | + | remoteAddr: "127.0.0.1:12345", | |||
| 89 | + | xff: "203.0.113.50", | |||
| 90 | + | xRealIP: "203.0.113.60", | |||
| 91 | + | trustedNets: trustedNets, | |||
| 92 | + | wantIP: "203.0.113.50", | |||
| 93 | + | }, | |||
| 94 | + | { | |||
| 95 | + | name: "IPv6 RemoteAddr", | |||
| 96 | + | remoteAddr: "[::1]:12345", | |||
| 97 | + | trustedNets: noTrustedNets, | |||
| 98 | + | wantIP: "::1", | |||
| 99 | + | }, | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | for _, tt := range tests { | |||
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 104 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 105 | + | req.RemoteAddr = tt.remoteAddr | |||
| 106 | + | if tt.xff != "" { | |||
| 107 | + | req.Header.Set("X-Forwarded-For", tt.xff) | |||
| 108 | + | } | |||
| 109 | + | if tt.xRealIP != "" { | |||
| 110 | + | req.Header.Set("X-Real-IP", tt.xRealIP) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | ip := GetClientIP(req, tt.trustedNets) | |||
| 114 | + | assert.Equal(t, tt.wantIP, ip) | |||
| 115 | + | }) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestParseTrustedProxies(t *testing.T) { | |||
| 120 | + | tests := []struct { | |||
| 121 | + | name string | |||
| 122 | + | proxies []string | |||
| 123 | + | testIP string | |||
| 124 | + | want bool | |||
| 125 | + | }{ | |||
| 126 | + | { | |||
| 127 | + | name: "empty list", | |||
| 128 | + | proxies: []string{}, | |||
| 129 | + | testIP: "192.168.1.1", | |||
| 130 | + | want: false, | |||
| 131 | + | }, | |||
| 132 | + | { | |||
| 133 | + | name: "single IP match", | |||
| 134 | + | proxies: []string{"192.168.1.1"}, | |||
| 135 | + | testIP: "192.168.1.1", | |||
| 136 | + | want: true, | |||
| 137 | + | }, | |||
| 138 | + | { | |||
| 139 | + | name: "single IP no match", | |||
| 140 | + | proxies: []string{"192.168.1.1"}, | |||
| 141 | + | testIP: "192.168.1.2", | |||
| 142 | + | want: false, | |||
| 143 | + | }, | |||
| 144 | + | { | |||
| 145 | + | name: "CIDR match", | |||
| 146 | + | proxies: []string{"10.0.0.0/8"}, | |||
| 147 | + | testIP: "10.1.2.3", | |||
| 148 | + | want: true, | |||
| 149 | + | }, | |||
| 150 | + | { | |||
| 151 | + | name: "CIDR no match", | |||
| 152 | + | proxies: []string{"10.0.0.0/8"}, | |||
| 153 | + | testIP: "192.168.1.1", | |||
| 154 | + | want: false, | |||
| 155 | + | }, | |||
| 156 | + | { | |||
| 157 | + | name: "mixed IP and CIDR", | |||
| 158 | + | proxies: []string{"127.0.0.1", "10.0.0.0/8", "172.16.0.0/12"}, | |||
| 159 | + | testIP: "172.20.1.1", | |||
| 160 | + | want: true, | |||
| 161 | + | }, | |||
| 162 | + | { | |||
| 163 | + | name: "invalid entries ignored", | |||
| 164 | + | proxies: []string{"not-an-ip", "10.0.0.0/8"}, | |||
| 165 | + | testIP: "10.1.2.3", | |||
| 166 | + | want: true, | |||
| 167 | + | }, | |||
| 168 | + | { | |||
| 169 | + | name: "IPv6 single IP", | |||
| 170 | + | proxies: []string{"::1"}, | |||
| 171 | + | testIP: "::1", | |||
| 172 | + | want: true, | |||
| 173 | + | }, | |||
| 174 | + | { | |||
| 175 | + | name: "IPv6 CIDR", | |||
| 176 | + | proxies: []string{"fd00::/8"}, | |||
| 177 | + | testIP: "fd12:3456::1", | |||
| 178 | + | want: true, | |||
| 179 | + | }, | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | for _, tt := range tests { | |||
| 183 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 184 | + | nets := ParseTrustedProxies(tt.proxies) | |||
| 185 | + | got := IsTrustedProxy(tt.testIP, nets) | |||
| 186 | + | assert.Equal(t, tt.want, got) | |||
| 187 | + | }) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func TestIsTrustedProxy(t *testing.T) { | |||
| 192 | + | tests := []struct { | |||
| 193 | + | name string | |||
| 194 | + | ip string | |||
| 195 | + | trustedNets []*net.IPNet | |||
| 196 | + | want bool | |||
| 197 | + | }{ | |||
| 198 | + | { | |||
| 199 | + | name: "empty trusted nets", | |||
| 200 | + | ip: "192.168.1.1", | |||
| 201 | + | trustedNets: []*net.IPNet{}, | |||
| 202 | + | want: false, | |||
| 203 | + | }, | |||
| 204 | + | { | |||
| 205 | + | name: "nil trusted nets", | |||
| 206 | + | ip: "192.168.1.1", | |||
| 207 | + | trustedNets: nil, | |||
| 208 | + | want: false, | |||
| 209 | + | }, | |||
| 210 | + | { | |||
| 211 | + | name: "invalid IP", | |||
| 212 | + | ip: "not-an-ip", | |||
| 213 | + | trustedNets: ParseTrustedProxies([]string{"192.168.1.0/24"}), | |||
| 214 | + | want: false, | |||
| 215 | + | }, | |||
| 216 | + | { | |||
| 217 | + | name: "empty IP", | |||
| 218 | + | ip: "", | |||
| 219 | + | trustedNets: ParseTrustedProxies([]string{"192.168.1.0/24"}), | |||
| 220 | + | want: false, | |||
| 221 | + | }, | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | for _, tt := range tests { | |||
| 225 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 226 | + | got := IsTrustedProxy(tt.ip, tt.trustedNets) | |||
| 227 | + | assert.Equal(t, tt.want, got) | |||
| 228 | + | }) | |||
| 229 | + | } | |||
| 230 | + | } | |||