1package middleware
2
3import (
4 "net"
5 "net/http"
6 "strings"
7
8 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
9)
10
11// cloudflareNets contains Cloudflare's published IPv4 and IPv6 ranges.
12// Cf-Connecting-Ip is only trusted when the immediate upstream matches these.
13// Source: https://www.cloudflare.com/ips/
14var cloudflareNets = httphelper.ParseTrustedProxies([]string{
15 // IPv4
16 "173.245.48.0/20",
17 "103.21.244.0/22",
18 "103.22.200.0/22",
19 "103.31.4.0/22",
20 "141.101.64.0/18",
21 "108.162.192.0/18",
22 "190.93.240.0/20",
23 "188.114.96.0/20",
24 "197.234.240.0/22",
25 "198.41.128.0/17",
26 "162.158.0.0/15",
27 "104.16.0.0/13",
28 "104.24.0.0/14",
29 "172.64.0.0/13",
30 "131.0.72.0/22",
31 // IPv6
32 "2400:cb00::/32",
33 "2606:4700::/32",
34 "2803:f800::/32",
35 "2405:b500::/32",
36 "2405:8100::/32",
37 "2a06:98c0::/29",
38 "2c0f:f248::/32",
39})
40
41func normalizeIP(raw string) string {
42 ip := strings.TrimSpace(raw)
43 if ip == "" {
44 return ""
45 }
46
47 if host, _, err := net.SplitHostPort(ip); err == nil {
48 ip = host
49 }
50
51 parsed := net.ParseIP(ip)
52 if parsed == nil {
53 return ""
54 }
55
56 return parsed.String()
57}
58
59// GetClientIP extracts the client IP address from the request.
60// It only honors X-Forwarded-For and X-Real-IP headers when the request
61// originates from a trusted proxy. This prevents attackers from spoofing
62// their IP address when Gordon is exposed directly without a reverse proxy.
63func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string {
64 // Extract the direct connection IP from RemoteAddr
65 remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
66 if err != nil {
67 remoteIP = r.RemoteAddr
68 }
69 if normalized := normalizeIP(remoteIP); normalized != "" {
70 remoteIP = normalized
71 }
72
73 // Only honor proxy headers if the request comes from a trusted proxy
74 if httphelper.IsTrustedProxy(remoteIP, trustedNets) {
75 // Cf-Connecting-IP is set by Cloudflare to the true client IP.
76 // Only trust it when the immediate upstream is a known Cloudflare IP,
77 // not any generic trusted proxy (e.g. local LB/nginx), because a
78 // non-Cloudflare proxy would blindly forward a spoofed header.
79 if httphelper.IsTrustedProxy(remoteIP, cloudflareNets) {
80 if cfIP := r.Header.Get("Cf-Connecting-Ip"); cfIP != "" {
81 if ip := normalizeIP(cfIP); ip != "" {
82 return ip
83 }
84 }
85 }
86
87 if ip := forwardedForClientIP(r, trustedNets); ip != "" {
88 return ip
89 }
90
91 if ip := realIPHeader(r); ip != "" {
92 return ip
93 }
94 }
95
96 // Fall back to RemoteAddr (direct connection IP)
97 return remoteIP
98}
99
100// forwardedForClientIP returns the right-most untrusted X-Forwarded-For hop.
101// SECURITY: Parse from right to left so a trusted proxy appending to an
102// attacker-supplied chain cannot be spoofed. All header lines are joined, since
103// some proxies add a new X-Forwarded-For line instead of appending to the last.
104func forwardedForClientIP(r *http.Request, trustedNets []*net.IPNet) string {
105 values := r.Header.Values("X-Forwarded-For")
106 if len(values) == 0 {
107 return ""
108 }
109
110 parts := strings.Split(strings.Join(values, ","), ",")
111 validChain := make([]string, 0, len(parts))
112 for _, part := range parts {
113 if ip := normalizeIP(part); ip != "" {
114 validChain = append(validChain, ip)
115 }
116 }
117
118 for i := len(validChain) - 1; i >= 0; i-- {
119 if !httphelper.IsTrustedProxy(validChain[i], trustedNets) {
120 return validChain[i]
121 }
122 }
123
124 if len(validChain) > 0 {
125 return validChain[0]
126 }
127 return ""
128}
129
130// realIPHeader returns X-Real-IP only when it is set exactly once, since a
131// repeated header means one of the values may come from the client.
132func realIPHeader(r *http.Request) string {
133 values := r.Header.Values("X-Real-IP")
134 if len(values) != 1 {
135 return ""
136 }
137 return normalizeIP(values[0])
138}
139
140// IsTrustedCloudflarePeer reports whether the request's immediate peer is both
141// a trusted proxy and a published Cloudflare address. Only then does
142// Cf-Connecting-Ip carry a client IP set by Cloudflare rather than by the client.
143func IsTrustedCloudflarePeer(r *http.Request, trustedNets []*net.IPNet) bool {
144 return httphelper.IsTrustedSource(r, trustedNets) && httphelper.IsTrustedSource(r, cloudflareNets)
145}