Skip to content

Commit ed2158e

Browse files
authored
Merge pull request #30 from techquestsdev/andre.nogueira/fix-ci-realip-deprecation
fix(ci): replace deprecated chi RealIP + rename gomodguard linter
2 parents b241b9b + eb062f6 commit ed2158e

7 files changed

Lines changed: 269 additions & 2 deletions

File tree

.golangci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@ output:
1414

1515
linters:
1616
default: all
17+
enable:
18+
- gomodguard_v2 # replaces deprecated gomodguard
1719
disable:
1820
# Deprecated
1921
- wsl
22+
- gomodguard # superseded by gomodguard_v2
2023

2124
# Too noisy / impractical
2225
- exhaustruct

cmd/api/server/server.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package server
33
import (
44
"context"
55
_ "embed"
6+
"net"
67
"net/http"
78
"time"
89

@@ -97,7 +98,7 @@ func newRouter(
9798

9899
// Middleware
99100
r.Use(middleware.RequestID)
100-
r.Use(middleware.RealIP)
101+
r.Use(authmw.RealIP(mustParseTrustedProxies(cfg.Server.TrustedProxies)))
101102
r.Use(middleware.Logger)
102103
r.Use(middleware.Recoverer)
103104
r.Use(middleware.Timeout(60 * time.Second))
@@ -286,6 +287,18 @@ func isNoOpAuthenticator(a authmw.Authenticator) bool {
286287
return ok
287288
}
288289

290+
// mustParseTrustedProxies parses the configured CIDRs and panics on invalid
291+
// input. Misconfiguration here is a deployment error that should fail fast at
292+
// startup rather than silently disabling client-IP resolution.
293+
func mustParseTrustedProxies(in []string) []*net.IPNet {
294+
nets, err := authmw.ParseTrustedProxies(in)
295+
if err != nil {
296+
panic("invalid server.trusted_proxies: " + err.Error())
297+
}
298+
299+
return nets
300+
}
301+
289302
func corsMiddleware(next http.Handler) http.Handler {
290303
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
291304
// When an Origin header is present, echo it back and allow credentials

config.example.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ server:
3232
addr: ":8080"
3333
read_timeout: 15s
3434
write_timeout: 60s
35+
# CIDRs (or bare IPs) whose X-Forwarded-For headers will be honored to
36+
# set r.RemoteAddr. Leave empty to keep the actual TCP peer address.
37+
# Set this to your ingress / load balancer CIDR when running behind one.
38+
# trusted_proxies:
39+
# - 10.0.0.0/8
40+
# - 172.16.0.0/12
3541

3642
# Database configuration
3743
# Env: CS_DATABASE_DRIVER, CS_DATABASE_URL, CS_DATABASE_MAX_OPEN_CONNS, CS_DATABASE_MAX_IDLE_CONNS, CS_DATABASE_CONN_MAX_LIFETIME

internal/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ type ServerConfig struct {
6262
Addr string `mapstructure:"addr"`
6363
ReadTimeout time.Duration `mapstructure:"read_timeout"`
6464
WriteTimeout time.Duration `mapstructure:"write_timeout"`
65+
// TrustedProxies is the list of CIDRs (or bare IPs) whose X-Forwarded-For
66+
// headers will be honored to populate r.RemoteAddr. Leave empty to keep
67+
// the actual TCP peer address — the safe default for direct exposure.
68+
// Set this to the ingress/load-balancer CIDR when running behind one.
69+
TrustedProxies []string `mapstructure:"trusted_proxies"`
6570
}
6671

6772
type DatabaseConfig struct {

internal/indexer/server.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ func (s *Server) Start() error {
6565

6666
// Middleware
6767
r.Use(middleware.RequestID)
68-
r.Use(middleware.RealIP)
6968
r.Use(middleware.Recoverer)
7069
r.Use(middleware.Timeout(60 * time.Second))
7170

internal/middleware/realip.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package middleware
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"net/http"
7+
"slices"
8+
"strings"
9+
)
10+
11+
// RealIP returns chi-compatible middleware that rewrites r.RemoteAddr to the
12+
// real client IP by walking the X-Forwarded-For chain — but ONLY when the
13+
// immediate TCP peer is in trustedProxies. This closes the spoofing vector
14+
// that caused chi's own middleware.RealIP to be deprecated
15+
// (GHSA-3fxj-6jh8-hvhx, GHSA-rjr7-jggh-pgcp, GHSA-9g5q-2w5x-hmxf).
16+
//
17+
// If trustedProxies is empty, the middleware is a no-op — r.RemoteAddr keeps
18+
// the actual TCP peer address. That is the safe default: better to log the
19+
// proxy IP than to trust a client-supplied header from an unknown network.
20+
func RealIP(trustedProxies []*net.IPNet) func(http.Handler) http.Handler {
21+
if len(trustedProxies) == 0 {
22+
return func(next http.Handler) http.Handler { return next }
23+
}
24+
25+
return func(next http.Handler) http.Handler {
26+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+
if client := realClientIP(r, trustedProxies); client != "" {
28+
r.RemoteAddr = client
29+
}
30+
31+
next.ServeHTTP(w, r)
32+
})
33+
}
34+
}
35+
36+
func realClientIP(r *http.Request, trusted []*net.IPNet) string {
37+
peerHost, _, err := net.SplitHostPort(r.RemoteAddr)
38+
if err != nil {
39+
peerHost = r.RemoteAddr
40+
}
41+
42+
peerIP := net.ParseIP(peerHost)
43+
if peerIP == nil || !ipInAny(peerIP, trusted) {
44+
return ""
45+
}
46+
47+
xff := r.Header.Get("X-Forwarded-For")
48+
if xff == "" {
49+
return ""
50+
}
51+
52+
parts := strings.Split(xff, ",")
53+
for _, raw := range slices.Backward(parts) {
54+
candidate := strings.TrimSpace(raw)
55+
56+
ip := net.ParseIP(candidate)
57+
if ip == nil {
58+
continue
59+
}
60+
61+
if !ipInAny(ip, trusted) {
62+
return candidate
63+
}
64+
}
65+
66+
return ""
67+
}
68+
69+
func ipInAny(ip net.IP, nets []*net.IPNet) bool {
70+
for _, n := range nets {
71+
if n.Contains(ip) {
72+
return true
73+
}
74+
}
75+
76+
return false
77+
}
78+
79+
// ParseTrustedProxies parses CIDR notation strings into []*net.IPNet.
80+
// Bare IPv4/IPv6 addresses are accepted and converted to single-host CIDRs.
81+
func ParseTrustedProxies(in []string) ([]*net.IPNet, error) {
82+
out := make([]*net.IPNet, 0, len(in))
83+
84+
for _, s := range in {
85+
s = strings.TrimSpace(s)
86+
if s == "" {
87+
continue
88+
}
89+
90+
if _, n, err := net.ParseCIDR(s); err == nil {
91+
out = append(out, n)
92+
continue
93+
}
94+
95+
if ip := net.ParseIP(s); ip != nil {
96+
suffix := "/32"
97+
if ip.To4() == nil {
98+
suffix = "/128"
99+
}
100+
101+
if _, n, err := net.ParseCIDR(s + suffix); err == nil {
102+
out = append(out, n)
103+
continue
104+
}
105+
}
106+
107+
return nil, fmt.Errorf("invalid trusted_proxy value %q: expected CIDR or IP", s)
108+
}
109+
110+
return out, nil
111+
}

internal/middleware/realip_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package middleware
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
)
8+
9+
func TestRealIP_NoTrustedProxies_IsNoOp(t *testing.T) {
10+
captured := ""
11+
h := RealIP(nil)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
12+
captured = r.RemoteAddr
13+
}))
14+
15+
r := httptest.NewRequest(http.MethodGet, "/", nil)
16+
r.RemoteAddr = "1.2.3.4:5555"
17+
r.Header.Set("X-Forwarded-For", "9.9.9.9")
18+
h.ServeHTTP(httptest.NewRecorder(), r)
19+
20+
if captured != "1.2.3.4:5555" {
21+
t.Fatalf("expected RemoteAddr untouched, got %q", captured)
22+
}
23+
}
24+
25+
func TestRealIP_TrustedPeer_UsesXFF(t *testing.T) {
26+
trusted, err := ParseTrustedProxies([]string{"10.0.0.0/8"})
27+
if err != nil {
28+
t.Fatal(err)
29+
}
30+
31+
captured := ""
32+
h := RealIP(trusted)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
33+
captured = r.RemoteAddr
34+
}))
35+
36+
r := httptest.NewRequest(http.MethodGet, "/", nil)
37+
r.RemoteAddr = "10.0.5.6:5555"
38+
r.Header.Set("X-Forwarded-For", "203.0.113.7")
39+
h.ServeHTTP(httptest.NewRecorder(), r)
40+
41+
if captured != "203.0.113.7" {
42+
t.Fatalf("expected client IP from XFF, got %q", captured)
43+
}
44+
}
45+
46+
func TestRealIP_UntrustedPeer_IgnoresXFF(t *testing.T) {
47+
trusted, err := ParseTrustedProxies([]string{"10.0.0.0/8"})
48+
if err != nil {
49+
t.Fatal(err)
50+
}
51+
52+
captured := ""
53+
h := RealIP(trusted)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
54+
captured = r.RemoteAddr
55+
}))
56+
57+
r := httptest.NewRequest(http.MethodGet, "/", nil)
58+
r.RemoteAddr = "8.8.8.8:5555"
59+
r.Header.Set("X-Forwarded-For", "203.0.113.7")
60+
h.ServeHTTP(httptest.NewRecorder(), r)
61+
62+
if captured != "8.8.8.8:5555" {
63+
t.Fatalf("expected RemoteAddr untouched for untrusted peer, got %q", captured)
64+
}
65+
}
66+
67+
func TestRealIP_ChainOfTrustedProxies(t *testing.T) {
68+
trusted, err := ParseTrustedProxies([]string{"10.0.0.0/8", "172.16.0.0/12"})
69+
if err != nil {
70+
t.Fatal(err)
71+
}
72+
73+
captured := ""
74+
h := RealIP(trusted)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
75+
captured = r.RemoteAddr
76+
}))
77+
78+
r := httptest.NewRequest(http.MethodGet, "/", nil)
79+
r.RemoteAddr = "10.0.5.6:5555"
80+
// Chain: client → ext LB (untrusted) → internal LB (trusted) → peer (trusted)
81+
r.Header.Set("X-Forwarded-For", "203.0.113.7, 172.16.0.1, 10.0.5.1")
82+
h.ServeHTTP(httptest.NewRecorder(), r)
83+
84+
if captured != "203.0.113.7" {
85+
t.Fatalf("expected first untrusted IP from right, got %q", captured)
86+
}
87+
}
88+
89+
func TestRealIP_AllForwardersTrusted_NoMatch(t *testing.T) {
90+
trusted, err := ParseTrustedProxies([]string{"10.0.0.0/8"})
91+
if err != nil {
92+
t.Fatal(err)
93+
}
94+
95+
captured := ""
96+
h := RealIP(trusted)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
97+
captured = r.RemoteAddr
98+
}))
99+
100+
r := httptest.NewRequest(http.MethodGet, "/", nil)
101+
r.RemoteAddr = "10.0.5.6:5555"
102+
r.Header.Set("X-Forwarded-For", "10.0.5.1, 10.0.5.2")
103+
h.ServeHTTP(httptest.NewRecorder(), r)
104+
105+
if captured != "10.0.5.6:5555" {
106+
t.Fatalf("expected RemoteAddr untouched when no untrusted hop, got %q", captured)
107+
}
108+
}
109+
110+
func TestParseTrustedProxies(t *testing.T) {
111+
cases := []struct {
112+
in []string
113+
wantOK bool
114+
}{
115+
{[]string{"10.0.0.0/8"}, true},
116+
{[]string{"::1/128"}, true},
117+
{[]string{"192.168.1.1"}, true}, // bare IP → /32
118+
{[]string{"2001:db8::1"}, true}, // bare IPv6 → /128
119+
{[]string{""}, true}, // empty entries skipped
120+
{[]string{"not-an-ip"}, false},
121+
{[]string{"10.0.0.0/99"}, false},
122+
}
123+
124+
for _, c := range cases {
125+
_, err := ParseTrustedProxies(c.in)
126+
if (err == nil) != c.wantOK {
127+
t.Errorf("ParseTrustedProxies(%v): err=%v, wantOK=%v", c.in, err, c.wantOK)
128+
}
129+
}
130+
}

0 commit comments

Comments
 (0)