detached 6e4d05d

fix(proxy): send a trustworthy client IP to upstream containers

Replace client-supplied X-Real-IP, X-Forwarded-For and Cf-Connecting-Ip
(including underscore spellings) with the client IP resolved by
GetClientIP, using the same trusted-proxy rules as logs and rate limits.
Cf-Connecting-Ip is only kept for trusted Cloudflare peers when it
matches the resolved IP.

GetClientIP now joins all X-Forwarded-For lines and ignores a repeated
X-Real-IP, so a proxy adding a new XFF line cannot be spoofed.

Refs #17
commit
6e4d05dd8aeb706ce815da479fd9fbb770b7ce23
author
brice · · 2026-10-16 12:52 UTC
parent
db3477e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +298 -28
M docs/config/server.md +19 -0
@@ -362,6 +362,25 @@ proxy_allowed_ips = [
362362
363363 **Important:** Cloudflare publishes their IP ranges at [cloudflare.com/ips](https://www.cloudflare.com/ips/). Keep your allowlist updated when Cloudflare adds new ranges.
364364
365+## Client IP Headers
366+
367+Gordon sets the client IP on every request it proxies to a container. Apps can trust these headers without any extra proxy in front, as long as the container is only reachable through Gordon (no published host ports):
368+
369+| Header | Value sent to the container |
370+|--------|-----------------------------|
371+| `X-Real-IP` | The resolved client IP. Any value sent by the client is replaced, including `X_Real_IP`-style spellings. |
372+| `X-Forwarded-For` | The same single resolved client IP. |
373+| `Cf-Connecting-Ip` | Kept only when the peer is a trusted Cloudflare address and the value is the resolved client IP, otherwise removed. |
374+| `X-Forwarded-Proto` / `X-Forwarded-Host` | Request scheme and route host. A trusted proxy's `X-Forwarded-Proto` is kept. |
375+
376+The client IP is resolved with the same rules as Gordon's logs and rate limiter, using [`api.rate_limit.trusted_proxies`](./rate-limiting.md#trusted_proxies):
377+
378+- **Untrusted peer:** the peer address.
379+- **Trusted Cloudflare peer:** a valid `Cf-Connecting-Ip`, otherwise the trusted proxy rules below.
380+- **Other trusted proxy:** the right-most untrusted `X-Forwarded-For` hop (the left-most hop if all are trusted), then a single `X-Real-IP`, then the peer address.
381+
382+IPv4-mapped IPv6 addresses are sent as plain IPv4. For apps that read the client IP from a header (for example `ADDRESS_HEADER=x-real-ip` in SvelteKit's adapter-node), use `X-Real-IP`.
383+
365384 ## Registry Listen Address
366385
367386 The `registry_listen_address` controls which interface the registry server binds to. By default it binds to all interfaces. Set to `127.0.0.1` to restrict registry access to localhost only, preventing containers from reaching the registry via the Docker bridge gateway.
@@ -362,6 +362,25 @@ proxy_allowed_ips = [
362 362
363 **Important:** Cloudflare publishes their IP ranges at [cloudflare.com/ips](https://www.cloudflare.com/ips/). Keep your allowlist updated when Cloudflare adds new ranges.363 **Important:** Cloudflare publishes their IP ranges at [cloudflare.com/ips](https://www.cloudflare.com/ips/). Keep your allowlist updated when Cloudflare adds new ranges.
364 364
365+## Client IP Headers
366+
367+Gordon sets the client IP on every request it proxies to a container. Apps can trust these headers without any extra proxy in front, as long as the container is only reachable through Gordon (no published host ports):
368+
369+| Header | Value sent to the container |
370+|--------|-----------------------------|
371+| `X-Real-IP` | The resolved client IP. Any value sent by the client is replaced, including `X_Real_IP`-style spellings. |
372+| `X-Forwarded-For` | The same single resolved client IP. |
373+| `Cf-Connecting-Ip` | Kept only when the peer is a trusted Cloudflare address and the value is the resolved client IP, otherwise removed. |
374+| `X-Forwarded-Proto` / `X-Forwarded-Host` | Request scheme and route host. A trusted proxy's `X-Forwarded-Proto` is kept. |
375+
376+The client IP is resolved with the same rules as Gordon's logs and rate limiter, using [`api.rate_limit.trusted_proxies`](./rate-limiting.md#trusted_proxies):
377+
378+- **Untrusted peer:** the peer address.
379+- **Trusted Cloudflare peer:** a valid `Cf-Connecting-Ip`, otherwise the trusted proxy rules below.
380+- **Other trusted proxy:** the right-most untrusted `X-Forwarded-For` hop (the left-most hop if all are trusted), then a single `X-Real-IP`, then the peer address.
381+
382+IPv4-mapped IPv6 addresses are sent as plain IPv4. For apps that read the client IP from a header (for example `ADDRESS_HEADER=x-real-ip` in SvelteKit's adapter-node), use `X-Real-IP`.
383+
365 ## Registry Listen Address384 ## Registry Listen Address
366 385
367 The `registry_listen_address` controls which interface the registry server binds to. By default it binds to all interfaces. Set to `127.0.0.1` to restrict registry access to localhost only, preventing containers from reaching the registry via the Docker bridge gateway.386 The `registry_listen_address` controls which interface the registry server binds to. By default it binds to all interfaces. Set to `127.0.0.1` to restrict registry access to localhost only, preventing containers from reaching the registry via the Docker bridge gateway.
M internal/adapters/in/http/middleware/clientip.go +51 -27
@@ -84,38 +84,62 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string {
8484 }
8585 }
8686
87- // Check X-Forwarded-For for proxied requests.
88- // SECURITY: Parse from right to left and return the first untrusted IP.
89- // This prevents spoofing when a trusted proxy appends to an attacker-
90- // supplied XFF chain.
91- if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
92- parts := strings.Split(xff, ",")
93- validChain := make([]string, 0, len(parts))
94- for _, part := range parts {
95- if ip := normalizeIP(part); ip != "" {
96- validChain = append(validChain, ip)
97- }
98- }
99-
100- for i := len(validChain) - 1; i >= 0; i-- {
101- if !httphelper.IsTrustedProxy(validChain[i], trustedNets) {
102- return validChain[i]
103- }
104- }
105-
106- if len(validChain) > 0 {
107- return validChain[0]
108- }
87+ if ip := forwardedForClientIP(r, trustedNets); ip != "" {
88+ return ip
10989 }
11090
111- // Check X-Real-IP
112- if xri := r.Header.Get("X-Real-IP"); xri != "" {
113- if ip := normalizeIP(xri); ip != "" {
114- return ip
115- }
91+ if ip := realIPHeader(r); ip != "" {
92+ return ip
11693 }
11794 }
11895
11996 // Fall back to RemoteAddr (direct connection IP)
12097 return remoteIP
12198 }
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.
104+func 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.
132+func 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.
143+func IsTrustedCloudflarePeer(r *http.Request, trustedNets []*net.IPNet) bool {
144+ return httphelper.IsTrustedSource(r, trustedNets) && httphelper.IsTrustedSource(r, cloudflareNets)
145+}
@@ -84,38 +84,62 @@ func GetClientIP(r *http.Request, trustedNets []*net.IPNet) string {
84 }84 }
85 }85 }
86 86
87- // Check X-Forwarded-For for proxied requests.87+ if ip := forwardedForClientIP(r, trustedNets); ip != "" {
88- // SECURITY: Parse from right to left and return the first untrusted IP.88+ return ip
89- // This prevents spoofing when a trusted proxy appends to an attacker-
90- // supplied XFF chain.
91- if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
92- parts := strings.Split(xff, ",")
93- validChain := make([]string, 0, len(parts))
94- for _, part := range parts {
95- if ip := normalizeIP(part); ip != "" {
96- validChain = append(validChain, ip)
97- }
98- }
99-
100- for i := len(validChain) - 1; i >= 0; i-- {
101- if !httphelper.IsTrustedProxy(validChain[i], trustedNets) {
102- return validChain[i]
103- }
104- }
105-
106- if len(validChain) > 0 {
107- return validChain[0]
108- }
109 }89 }
110 90
111- // Check X-Real-IP91+ if ip := realIPHeader(r); ip != "" {
112- if xri := r.Header.Get("X-Real-IP"); xri != "" {92+ return ip
113- if ip := normalizeIP(xri); ip != "" {
114- return ip
115- }
116 }93 }
117 }94 }
118 95
119 // Fall back to RemoteAddr (direct connection IP)96 // Fall back to RemoteAddr (direct connection IP)
120 return remoteIP97 return remoteIP
121 }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.
104+func 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.
132+func 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.
143+func IsTrustedCloudflarePeer(r *http.Request, trustedNets []*net.IPNet) bool {
144+ return httphelper.IsTrustedSource(r, trustedNets) && httphelper.IsTrustedSource(r, cloudflareNets)
145+}
M internal/adapters/in/http/proxy/forwarder.go +44 -1
@@ -10,6 +10,7 @@ import (
1010 "net/http"
1111 "net/http/httputil"
1212 "net/url"
13+ "slices"
1314 "strconv"
1415 "strings"
1516 "time"
@@ -17,6 +18,7 @@ import (
1718 "github.com/bnema/zerowrap"
1819
1920 gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
21+ "github.com/bnema/gordon/internal/adapters/in/http/middleware"
2022 "github.com/bnema/gordon/internal/domain"
2123 )
2224
@@ -235,13 +237,15 @@ func targetHostHeader(target *domain.ProxyTarget, targetURL *url.URL) string {
235237 // headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
236238 //
237239 // incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the
238-// request originates from a trusted upstream proxy.
240+// request originates from a trusted upstream proxy, and to resolve the client IP
241+// sent upstream in X-Real-IP and X-Forwarded-For.
239242 func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy {
240243 return &httputil.ReverseProxy{
241244 Rewrite: func(pr *httputil.ProxyRequest) {
242245 existingProto := pr.In.Header.Get("X-Forwarded-Proto")
243246 pr.SetURL(opts.targetURL)
244247 pr.SetXForwarded()
248+ setClientIPHeaders(pr, opts.trustedNets)
245249 if opts.hostHeader != "" {
246250 pr.Out.Host = opts.hostHeader
247251 }
@@ -262,6 +266,45 @@ func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy {
262266 }
263267 }
264268
269+// clientIPHeaders are the headers carrying a client IP that Gordon rewrites
270+// before proxying to a container, in canonical form.
271+var clientIPHeaders = []string{"X-Real-Ip", "X-Forwarded-For", "Cf-Connecting-Ip"}
272+
273+// setClientIPHeaders replaces client-supplied IP headers with the client IP
274+// resolved by the same trusted-proxy rules Gordon uses for its own logs and
275+// rate limits, so upstream apps can always trust X-Real-IP and X-Forwarded-For.
276+// Cf-Connecting-Ip is only kept when it is the IP resolved from a trusted
277+// Cloudflare peer.
278+func setClientIPHeaders(pr *httputil.ProxyRequest, trustedNets []*net.IPNet) {
279+ cfIP := pr.Out.Header.Get("Cf-Connecting-Ip")
280+ deleteClientIPHeaders(pr.Out.Header)
281+
282+ clientIP := middleware.GetClientIP(pr.In, trustedNets)
283+ parsed := net.ParseIP(clientIP)
284+ if parsed == nil {
285+ // No usable peer address (e.g. a Unix socket): send no client IP at all.
286+ return
287+ }
288+ pr.Out.Header.Set("X-Real-IP", clientIP)
289+ pr.Out.Header.Set("X-Forwarded-For", clientIP)
290+ if middleware.IsTrustedCloudflarePeer(pr.In, trustedNets) && parsed.Equal(net.ParseIP(strings.TrimSpace(cfIP))) {
291+ pr.Out.Header.Set("Cf-Connecting-Ip", clientIP)
292+ }
293+}
294+
295+// deleteClientIPHeaders removes every client IP header, including spellings
296+// with underscores (X_Real_IP). Go keeps those under a separate key, but
297+// CGI-style servers map both "-" and "_" to the same HTTP_X_REAL_IP variable,
298+// so a client could otherwise override the value set by Gordon.
299+func deleteClientIPHeaders(h http.Header) {
300+ for key := range h {
301+ normalized := http.CanonicalHeaderKey(strings.ReplaceAll(key, "_", "-"))
302+ if slices.Contains(clientIPHeaders, normalized) {
303+ delete(h, key)
304+ }
305+ }
306+}
307+
265308 // modifyResponse returns a function that adds proxy headers and enforces response size limits.
266309 func modifyResponse(maxResponseSize int64) func(*http.Response) error {
267310 return func(resp *http.Response) error {
@@ -10,6 +10,7 @@ import (
10 "net/http"10 "net/http"
11 "net/http/httputil"11 "net/http/httputil"
12 "net/url"12 "net/url"
13+ "slices"
13 "strconv"14 "strconv"
14 "strings"15 "strings"
15 "time"16 "time"
@@ -17,6 +18,7 @@ import (
17 "github.com/bnema/zerowrap"18 "github.com/bnema/zerowrap"
18 19
19 gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper"20 gordonhttp "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
21+ "github.com/bnema/gordon/internal/adapters/in/http/middleware"
20 "github.com/bnema/gordon/internal/domain"22 "github.com/bnema/gordon/internal/domain"
21 )23 )
22 24
@@ -235,13 +237,15 @@ func targetHostHeader(target *domain.ProxyTarget, targetURL *url.URL) string {
235 // headers after hop-by-hop removal, ensuring headers like Authorization are preserved.237 // headers after hop-by-hop removal, ensuring headers like Authorization are preserved.
236 //238 //
237 // incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the239 // incomingReq and trustedNets are used to preserve X-Forwarded-Proto when the
238-// request originates from a trusted upstream proxy.240+// request originates from a trusted upstream proxy, and to resolve the client IP
241+// sent upstream in X-Real-IP and X-Forwarded-For.
239 func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy {242 func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy {
240 return &httputil.ReverseProxy{243 return &httputil.ReverseProxy{
241 Rewrite: func(pr *httputil.ProxyRequest) {244 Rewrite: func(pr *httputil.ProxyRequest) {
242 existingProto := pr.In.Header.Get("X-Forwarded-Proto")245 existingProto := pr.In.Header.Get("X-Forwarded-Proto")
243 pr.SetURL(opts.targetURL)246 pr.SetURL(opts.targetURL)
244 pr.SetXForwarded()247 pr.SetXForwarded()
248+ setClientIPHeaders(pr, opts.trustedNets)
245 if opts.hostHeader != "" {249 if opts.hostHeader != "" {
246 pr.Out.Host = opts.hostHeader250 pr.Out.Host = opts.hostHeader
247 }251 }
@@ -262,6 +266,45 @@ func newReverseProxy(opts reverseProxyOptions) *httputil.ReverseProxy {
262 }266 }
263 }267 }
264 268
269+// clientIPHeaders are the headers carrying a client IP that Gordon rewrites
270+// before proxying to a container, in canonical form.
271+var clientIPHeaders = []string{"X-Real-Ip", "X-Forwarded-For", "Cf-Connecting-Ip"}
272+
273+// setClientIPHeaders replaces client-supplied IP headers with the client IP
274+// resolved by the same trusted-proxy rules Gordon uses for its own logs and
275+// rate limits, so upstream apps can always trust X-Real-IP and X-Forwarded-For.
276+// Cf-Connecting-Ip is only kept when it is the IP resolved from a trusted
277+// Cloudflare peer.
278+func setClientIPHeaders(pr *httputil.ProxyRequest, trustedNets []*net.IPNet) {
279+ cfIP := pr.Out.Header.Get("Cf-Connecting-Ip")
280+ deleteClientIPHeaders(pr.Out.Header)
281+
282+ clientIP := middleware.GetClientIP(pr.In, trustedNets)
283+ parsed := net.ParseIP(clientIP)
284+ if parsed == nil {
285+ // No usable peer address (e.g. a Unix socket): send no client IP at all.
286+ return
287+ }
288+ pr.Out.Header.Set("X-Real-IP", clientIP)
289+ pr.Out.Header.Set("X-Forwarded-For", clientIP)
290+ if middleware.IsTrustedCloudflarePeer(pr.In, trustedNets) && parsed.Equal(net.ParseIP(strings.TrimSpace(cfIP))) {
291+ pr.Out.Header.Set("Cf-Connecting-Ip", clientIP)
292+ }
293+}
294+
295+// deleteClientIPHeaders removes every client IP header, including spellings
296+// with underscores (X_Real_IP). Go keeps those under a separate key, but
297+// CGI-style servers map both "-" and "_" to the same HTTP_X_REAL_IP variable,
298+// so a client could otherwise override the value set by Gordon.
299+func deleteClientIPHeaders(h http.Header) {
300+ for key := range h {
301+ normalized := http.CanonicalHeaderKey(strings.ReplaceAll(key, "_", "-"))
302+ if slices.Contains(clientIPHeaders, normalized) {
303+ delete(h, key)
304+ }
305+ }
306+}
307+
265 // modifyResponse returns a function that adds proxy headers and enforces response size limits.308 // modifyResponse returns a function that adds proxy headers and enforces response size limits.
266 func modifyResponse(maxResponseSize int64) func(*http.Response) error {309 func modifyResponse(maxResponseSize int64) func(*http.Response) error {
267 return func(resp *http.Response) error {310 return func(resp *http.Response) error {
M internal/adapters/in/http/proxy/forwarder_test.go +184 -0
@@ -370,3 +370,187 @@ func TestNewReverseProxy_XForwardedProto(t *testing.T) {
370370 })
371371 }
372372 }
373+
374+func TestNewReverseProxy_ClientIPHeaders(t *testing.T) {
375+ trustedNets := gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8", "fd00::/8", "173.245.48.0/20"})
376+
377+ tests := []struct {
378+ name string
379+ remoteAddr string
380+ headers http.Header
381+ nets []*net.IPNet
382+ wantIP string // expected X-Real-IP and X-Forwarded-For, empty = absent
383+ wantCF string // expected Cf-Connecting-Ip, empty = absent
384+ }{
385+ {
386+ name: "untrusted IPv4 peer forged headers replaced",
387+ remoteAddr: "203.0.113.7:4321",
388+ headers: http.Header{
389+ "X-Real-IP": {"1.2.3.4"},
390+ "X-Forwarded-For": {"1.2.3.4"},
391+ "Cf-Connecting-Ip": {"1.2.3.4"}},
392+ nets: trustedNets,
393+ wantIP: "203.0.113.7",
394+ },
395+ {
396+ name: "untrusted IPv6 peer forged X-Real-IP replaced",
397+ remoteAddr: "[2001:db8::7]:4321",
398+ headers: http.Header{"X-Real-IP": {"1.2.3.4"}},
399+ nets: trustedNets,
400+ wantIP: "2001:db8::7",
401+ },
402+ {
403+ name: "untrusted IPv4-mapped IPv6 peer normalized to IPv4",
404+ remoteAddr: "[::ffff:203.0.113.7]:4321",
405+ headers: http.Header{"X-Real-IP": {"1.2.3.4"}},
406+ nets: trustedNets,
407+ wantIP: "203.0.113.7",
408+ },
409+ {
410+ name: "no trusted nets ignores proxy headers",
411+ remoteAddr: "10.0.0.1:4321",
412+ headers: http.Header{"X-Forwarded-For": {"198.51.100.9"}},
413+ nets: nil,
414+ wantIP: "10.0.0.1",
415+ },
416+ {
417+ name: "trusted Cloudflare peer uses Cf-Connecting-Ip",
418+ remoteAddr: "173.245.48.10:4321",
419+ headers: http.Header{
420+ "Cf-Connecting-Ip": {"198.51.100.9"},
421+ "X-Real-IP": {"1.2.3.4"},
422+ "X-Forwarded-For": {"1.2.3.4"}},
423+ nets: trustedNets,
424+ wantIP: "198.51.100.9",
425+ wantCF: "198.51.100.9",
426+ },
427+ {
428+ name: "trusted Cloudflare peer with IPv6 client",
429+ remoteAddr: "173.245.48.10:4321",
430+ headers: http.Header{"Cf-Connecting-Ip": {"2001:db8::9"}},
431+ nets: trustedNets,
432+ wantIP: "2001:db8::9",
433+ wantCF: "2001:db8::9",
434+ },
435+ {
436+ name: "trusted non-Cloudflare proxy uses right-most untrusted XFF hop",
437+ remoteAddr: "10.0.0.1:4321",
438+ headers: http.Header{
439+ "X-Forwarded-For": {"1.2.3.4, 198.51.100.9, 10.0.0.2"},
440+ "Cf-Connecting-Ip": {"1.2.3.4"}},
441+ nets: trustedNets,
442+ wantIP: "198.51.100.9",
443+ },
444+ {
445+ name: "trusted IPv6 proxy with IPv4-mapped XFF hop",
446+ remoteAddr: "[fd00::1]:4321",
447+ headers: http.Header{"X-Forwarded-For": {"::ffff:198.51.100.9"}},
448+ nets: trustedNets,
449+ wantIP: "198.51.100.9",
450+ },
451+ {
452+ name: "trusted proxy X-Real-IP honored without XFF",
453+ remoteAddr: "10.0.0.1:4321",
454+ headers: http.Header{"X-Real-IP": {"2001:db8::9"}},
455+ nets: trustedNets,
456+ wantIP: "2001:db8::9",
457+ },
458+ {
459+ name: "trusted proxy adding a new XFF line uses that line",
460+ remoteAddr: "10.0.0.1:4321",
461+ headers: http.Header{"X-Forwarded-For": {"1.2.3.4", "198.51.100.9"}},
462+ nets: trustedNets,
463+ wantIP: "198.51.100.9",
464+ },
465+ {
466+ name: "trusted proxy repeated X-Real-IP ignored",
467+ remoteAddr: "10.0.0.1:4321",
468+ headers: http.Header{"X-Real-IP": {"1.2.3.4", "198.51.100.9"}},
469+ nets: trustedNets,
470+ wantIP: "10.0.0.1",
471+ },
472+ {
473+ name: "underscore header variants removed",
474+ remoteAddr: "203.0.113.7:4321",
475+ headers: http.Header{
476+ "X_Real_IP": {"1.2.3.4"},
477+ "X_Forwarded_For": {"1.2.3.4"},
478+ "Cf_Connecting_Ip": {"1.2.3.4"},
479+ },
480+ nets: trustedNets,
481+ wantIP: "203.0.113.7",
482+ },
483+ {
484+ name: "Cloudflare peer not in trusted proxies strips Cf-Connecting-Ip",
485+ remoteAddr: "173.245.48.10:4321",
486+ headers: http.Header{"Cf-Connecting-Ip": {"198.51.100.9"}},
487+ nets: gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8"}),
488+ wantIP: "173.245.48.10",
489+ },
490+ {
491+ name: "IPv4-mapped trusted Cloudflare peer uses Cf-Connecting-Ip",
492+ remoteAddr: "[::ffff:173.245.48.10]:4321",
493+ headers: http.Header{"Cf-Connecting-Ip": {"198.51.100.9"}},
494+ nets: trustedNets,
495+ wantIP: "198.51.100.9",
496+ wantCF: "198.51.100.9",
497+ },
498+ {
499+ name: "trusted Cloudflare peer with invalid Cf-Connecting-Ip falls back",
500+ remoteAddr: "173.245.48.10:4321",
501+ headers: http.Header{
502+ "Cf-Connecting-Ip": {"not-an-ip"},
503+ "X-Forwarded-For": {"198.51.100.9"},
504+ },
505+ nets: trustedNets,
506+ wantIP: "198.51.100.9",
507+ },
508+ {
509+ name: "unparsable peer sends no client IP",
510+ remoteAddr: "@",
511+ headers: http.Header{"X-Real-IP": {"1.2.3.4"}, "X-Forwarded-For": {"1.2.3.4"}},
512+ nets: trustedNets,
513+ },
514+ }
515+
516+ for _, tt := range tests {
517+ t.Run(tt.name, func(t *testing.T) {
518+ var got http.Header
519+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
520+ got = r.Header.Clone()
521+ w.WriteHeader(http.StatusOK)
522+ }))
523+ defer backend.Close()
524+
525+ backendURL, err := url.Parse(backend.URL)
526+ require.NoError(t, err)
527+
528+ incomingReq := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
529+ incomingReq.RemoteAddr = tt.remoteAddr
530+ for k, v := range tt.headers {
531+ incomingReq.Header[http.CanonicalHeaderKey(k)] = v
532+ }
533+
534+ proxy := newReverseProxy(reverseProxyOptions{
535+ targetURL: backendURL,
536+ transport: http.DefaultTransport,
537+ incomingReq: incomingReq,
538+ trustedNets: tt.nets,
539+ errorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
540+ http.Error(w, err.Error(), http.StatusBadGateway)
541+ },
542+ })
543+
544+ rec := httptest.NewRecorder()
545+ proxy.ServeHTTP(rec, incomingReq)
546+ require.Equal(t, http.StatusOK, rec.Code)
547+
548+ assert.Equal(t, tt.wantIP, got.Get("X-Real-IP"))
549+ assert.Equal(t, tt.wantIP, got.Get("X-Forwarded-For"))
550+ assert.Equal(t, tt.wantCF, got.Get("Cf-Connecting-Ip"))
551+ for key := range got {
552+ assert.NotContains(t, key, "_", "underscore header %q forwarded", key)
553+ }
554+ })
555+ }
556+}
@@ -370,3 +370,187 @@ func TestNewReverseProxy_XForwardedProto(t *testing.T) {
370 })370 })
371 }371 }
372 }372 }
373+
374+func TestNewReverseProxy_ClientIPHeaders(t *testing.T) {
375+ trustedNets := gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8", "fd00::/8", "173.245.48.0/20"})
376+
377+ tests := []struct {
378+ name string
379+ remoteAddr string
380+ headers http.Header
381+ nets []*net.IPNet
382+ wantIP string // expected X-Real-IP and X-Forwarded-For, empty = absent
383+ wantCF string // expected Cf-Connecting-Ip, empty = absent
384+ }{
385+ {
386+ name: "untrusted IPv4 peer forged headers replaced",
387+ remoteAddr: "203.0.113.7:4321",
388+ headers: http.Header{
389+ "X-Real-IP": {"1.2.3.4"},
390+ "X-Forwarded-For": {"1.2.3.4"},
391+ "Cf-Connecting-Ip": {"1.2.3.4"}},
392+ nets: trustedNets,
393+ wantIP: "203.0.113.7",
394+ },
395+ {
396+ name: "untrusted IPv6 peer forged X-Real-IP replaced",
397+ remoteAddr: "[2001:db8::7]:4321",
398+ headers: http.Header{"X-Real-IP": {"1.2.3.4"}},
399+ nets: trustedNets,
400+ wantIP: "2001:db8::7",
401+ },
402+ {
403+ name: "untrusted IPv4-mapped IPv6 peer normalized to IPv4",
404+ remoteAddr: "[::ffff:203.0.113.7]:4321",
405+ headers: http.Header{"X-Real-IP": {"1.2.3.4"}},
406+ nets: trustedNets,
407+ wantIP: "203.0.113.7",
408+ },
409+ {
410+ name: "no trusted nets ignores proxy headers",
411+ remoteAddr: "10.0.0.1:4321",
412+ headers: http.Header{"X-Forwarded-For": {"198.51.100.9"}},
413+ nets: nil,
414+ wantIP: "10.0.0.1",
415+ },
416+ {
417+ name: "trusted Cloudflare peer uses Cf-Connecting-Ip",
418+ remoteAddr: "173.245.48.10:4321",
419+ headers: http.Header{
420+ "Cf-Connecting-Ip": {"198.51.100.9"},
421+ "X-Real-IP": {"1.2.3.4"},
422+ "X-Forwarded-For": {"1.2.3.4"}},
423+ nets: trustedNets,
424+ wantIP: "198.51.100.9",
425+ wantCF: "198.51.100.9",
426+ },
427+ {
428+ name: "trusted Cloudflare peer with IPv6 client",
429+ remoteAddr: "173.245.48.10:4321",
430+ headers: http.Header{"Cf-Connecting-Ip": {"2001:db8::9"}},
431+ nets: trustedNets,
432+ wantIP: "2001:db8::9",
433+ wantCF: "2001:db8::9",
434+ },
435+ {
436+ name: "trusted non-Cloudflare proxy uses right-most untrusted XFF hop",
437+ remoteAddr: "10.0.0.1:4321",
438+ headers: http.Header{
439+ "X-Forwarded-For": {"1.2.3.4, 198.51.100.9, 10.0.0.2"},
440+ "Cf-Connecting-Ip": {"1.2.3.4"}},
441+ nets: trustedNets,
442+ wantIP: "198.51.100.9",
443+ },
444+ {
445+ name: "trusted IPv6 proxy with IPv4-mapped XFF hop",
446+ remoteAddr: "[fd00::1]:4321",
447+ headers: http.Header{"X-Forwarded-For": {"::ffff:198.51.100.9"}},
448+ nets: trustedNets,
449+ wantIP: "198.51.100.9",
450+ },
451+ {
452+ name: "trusted proxy X-Real-IP honored without XFF",
453+ remoteAddr: "10.0.0.1:4321",
454+ headers: http.Header{"X-Real-IP": {"2001:db8::9"}},
455+ nets: trustedNets,
456+ wantIP: "2001:db8::9",
457+ },
458+ {
459+ name: "trusted proxy adding a new XFF line uses that line",
460+ remoteAddr: "10.0.0.1:4321",
461+ headers: http.Header{"X-Forwarded-For": {"1.2.3.4", "198.51.100.9"}},
462+ nets: trustedNets,
463+ wantIP: "198.51.100.9",
464+ },
465+ {
466+ name: "trusted proxy repeated X-Real-IP ignored",
467+ remoteAddr: "10.0.0.1:4321",
468+ headers: http.Header{"X-Real-IP": {"1.2.3.4", "198.51.100.9"}},
469+ nets: trustedNets,
470+ wantIP: "10.0.0.1",
471+ },
472+ {
473+ name: "underscore header variants removed",
474+ remoteAddr: "203.0.113.7:4321",
475+ headers: http.Header{
476+ "X_Real_IP": {"1.2.3.4"},
477+ "X_Forwarded_For": {"1.2.3.4"},
478+ "Cf_Connecting_Ip": {"1.2.3.4"},
479+ },
480+ nets: trustedNets,
481+ wantIP: "203.0.113.7",
482+ },
483+ {
484+ name: "Cloudflare peer not in trusted proxies strips Cf-Connecting-Ip",
485+ remoteAddr: "173.245.48.10:4321",
486+ headers: http.Header{"Cf-Connecting-Ip": {"198.51.100.9"}},
487+ nets: gordonhttp.ParseTrustedProxies([]string{"10.0.0.0/8"}),
488+ wantIP: "173.245.48.10",
489+ },
490+ {
491+ name: "IPv4-mapped trusted Cloudflare peer uses Cf-Connecting-Ip",
492+ remoteAddr: "[::ffff:173.245.48.10]:4321",
493+ headers: http.Header{"Cf-Connecting-Ip": {"198.51.100.9"}},
494+ nets: trustedNets,
495+ wantIP: "198.51.100.9",
496+ wantCF: "198.51.100.9",
497+ },
498+ {
499+ name: "trusted Cloudflare peer with invalid Cf-Connecting-Ip falls back",
500+ remoteAddr: "173.245.48.10:4321",
501+ headers: http.Header{
502+ "Cf-Connecting-Ip": {"not-an-ip"},
503+ "X-Forwarded-For": {"198.51.100.9"},
504+ },
505+ nets: trustedNets,
506+ wantIP: "198.51.100.9",
507+ },
508+ {
509+ name: "unparsable peer sends no client IP",
510+ remoteAddr: "@",
511+ headers: http.Header{"X-Real-IP": {"1.2.3.4"}, "X-Forwarded-For": {"1.2.3.4"}},
512+ nets: trustedNets,
513+ },
514+ }
515+
516+ for _, tt := range tests {
517+ t.Run(tt.name, func(t *testing.T) {
518+ var got http.Header
519+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
520+ got = r.Header.Clone()
521+ w.WriteHeader(http.StatusOK)
522+ }))
523+ defer backend.Close()
524+
525+ backendURL, err := url.Parse(backend.URL)
526+ require.NoError(t, err)
527+
528+ incomingReq := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
529+ incomingReq.RemoteAddr = tt.remoteAddr
530+ for k, v := range tt.headers {
531+ incomingReq.Header[http.CanonicalHeaderKey(k)] = v
532+ }
533+
534+ proxy := newReverseProxy(reverseProxyOptions{
535+ targetURL: backendURL,
536+ transport: http.DefaultTransport,
537+ incomingReq: incomingReq,
538+ trustedNets: tt.nets,
539+ errorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
540+ http.Error(w, err.Error(), http.StatusBadGateway)
541+ },
542+ })
543+
544+ rec := httptest.NewRecorder()
545+ proxy.ServeHTTP(rec, incomingReq)
546+ require.Equal(t, http.StatusOK, rec.Code)
547+
548+ assert.Equal(t, tt.wantIP, got.Get("X-Real-IP"))
549+ assert.Equal(t, tt.wantIP, got.Get("X-Forwarded-For"))
550+ assert.Equal(t, tt.wantCF, got.Get("Cf-Connecting-Ip"))
551+ for key := range got {
552+ assert.NotContains(t, key, "_", "underscore header %q forwarded", key)
553+ }
554+ })
555+ }
556+}

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