v2.30.1 1983e1b

clientip.go RawHistoryFolder

1package middleware
2
3import (
4	"net"
5	"net/http"
6	"strings"
7)
8
9// cloudflareNets contains Cloudflare's published IPv4 and IPv6 ranges.
10// Cf-Connecting-Ip is only trusted when the immediate upstream matches these.
11// Source: https://www.cloudflare.com/ips/
12var cloudflareNets = ParseTrustedProxies([]string{
13	// IPv4
14	"173.245.48.0/20",
15	"103.21.244.0/22",
16	"103.22.200.0/22",
17	"103.31.4.0/22",
18	"141.101.64.0/18",
19	"108.162.192.0/18",
20	"190.93.240.0/20",
21	"188.114.96.0/20",
22	"197.234.240.0/22",
23	"198.41.128.0/17",
24	"162.158.0.0/15",
25	"104.16.0.0/13",
26	"104.24.0.0/14",
27	"172.64.0.0/13",
28	"131.0.72.0/22",
29	// IPv6
30	"2400:cb00::/32",
31	"2606:4700::/32",
32	"2803:f800::/32",
33	"2405:b500::/32",
34	"2405:8100::/32",
35	"2a06:98c0::/29",
36	"2c0f:f248::/32",
37})
38
39func normalizeIP(raw string) string {
40	ip := strings.TrimSpace(raw)
41	if ip == "" {
42		return ""
43	}
44
45	if host, _, err := net.SplitHostPort(ip); err == nil {
46		ip = host
47	}
48
49	parsed := net.ParseIP(ip)
50	if parsed == nil {
51		return ""
52	}
53
54	return parsed.String()
55}
56
57// ParseTrustedProxies converts a list of IP addresses and CIDR ranges to net.IPNet.
58// It accepts both single IPs (e.g., "10.0.0.1") and CIDR notation (e.g., "10.0.0.0/8").
59// Single IPs are converted to /32 (IPv4) or /128 (IPv6) CIDR blocks.
60func ParseTrustedProxies(proxies []string) []*net.IPNet {
61	var nets []*net.IPNet
62	for _, proxy := range proxies {
63		// Try parsing as CIDR
64		_, ipNet, err := net.ParseCIDR(proxy)
65		if err == nil {
66			nets = append(nets, ipNet)
67			continue
68		}
69		// Try parsing as single IP
70		ip := net.ParseIP(proxy)
71		if ip != nil {
72			// Convert single IP to /32 or /128 CIDR
73			bits := 32
74			if ip.To4() == nil {
75				bits = 128
76			}
77			nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
78		}
79	}
80	return nets
81}
82
83// IsTrustedProxy checks if the given IP address is from a trusted proxy.
84// Returns false if trustedNets is empty or the IP cannot be parsed.
85func IsTrustedProxy(ip string, trustedNets []*net.IPNet) bool {
86	if len(trustedNets) == 0 {
87		return false
88	}
89	parsedIP := net.ParseIP(ip)
90	if parsedIP == nil {
91		return false
92	}
93	for _, ipNet := range trustedNets {
94		if ipNet.Contains(parsedIP) {
95			return true
96		}
97	}
98	return false
99}
100
101// GetClientIP extracts the client IP address from the request.
102// It only honors X-Forwarded-For and X-Real-IP headers when the request
103// originates from a trusted proxy. This prevents attackers from spoofing
104// their IP address when Gordon is exposed directly without a reverse proxy.
105func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string {
106	// Extract the direct connection IP from RemoteAddr
107	remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
108	if err != nil {
109		remoteIP = r.RemoteAddr
110	}
111	if normalized := normalizeIP(remoteIP); normalized != "" {
112		remoteIP = normalized
113	}
114
115	// Only honor proxy headers if the request comes from a trusted proxy
116	if IsTrustedProxy(remoteIP, trustedNets) {
117		// Cf-Connecting-IP is set by Cloudflare to the true client IP.
118		// Only trust it when the immediate upstream is a known Cloudflare IP,
119		// not any generic trusted proxy (e.g. local LB/nginx), because a
120		// non-Cloudflare proxy would blindly forward a spoofed header.
121		if IsTrustedProxy(remoteIP, cloudflareNets) {
122			if cfIP := r.Header.Get("Cf-Connecting-Ip"); cfIP != "" {
123				if ip := normalizeIP(cfIP); ip != "" {
124					return ip
125				}
126			}
127		}
128
129		// Check X-Forwarded-For for proxied requests.
130		// SECURITY: Parse from right to left and return the first untrusted IP.
131		// This prevents spoofing when a trusted proxy appends to an attacker-
132		// supplied XFF chain.
133		if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
134			parts := strings.Split(xff, ",")
135			validChain := make([]string, 0, len(parts))
136			for _, part := range parts {
137				if ip := normalizeIP(part); ip != "" {
138					validChain = append(validChain, ip)
139				}
140			}
141
142			for i := len(validChain) - 1; i >= 0; i-- {
143				if !IsTrustedProxy(validChain[i], trustedNets) {
144					return validChain[i]
145				}
146			}
147
148			if len(validChain) > 0 {
149				return validChain[0]
150			}
151		}
152
153		// Check X-Real-IP
154		if xri := r.Header.Get("X-Real-IP"); xri != "" {
155			if ip := normalizeIP(xri); ip != "" {
156				return ip
157			}
158		}
159	}
160
161	// Fall back to RemoteAddr (direct connection IP)
162	return remoteIP
163}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close