Files
Bose-SoundTouch/pkg/service/handlers/middleware_clientip_test.go
Tobias GesellchenandClaude Opus 4.8 67c30850cd fix(handlers): resolve client IP via chi ClientIP, drop deprecated RealIP
chi v5.3.0 deprecates middleware.RealIP (IP-spoofing advisories), which
failed the Lint and Static Security Analysis CI jobs (SA1019). Replace the
RealIP wrapper with chi's middleware.ClientIP: ClientIPFromRemoteAddr is
always applied so middleware.GetClientIP is populated, and when
trust_forwarded_headers is set and the immediate peer is a trusted-proxy
CIDR, ClientIPFromXFF resolves the real client from X-Forwarded-For
(rightmost entry outside the trusted CIDRs). The immediate-peer trust gate
is preserved, so a non-trusted peer's XFF is ignored. CIDR strings are
validated with netip.ParsePrefix first to avoid ClientIPFromXFF's panic.

Behavior change: only X-Forwarded-For is honored now (RealIP also read
X-Real-IP / True-Client-IP). Docs and a release note follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:15:49 +02:00

188 lines
5.3 KiB
Go

package handlers
import (
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5/middleware"
)
// captureClientIP is a tiny handler that records the resolved client IP from
// the request context (as set by clientIPMiddleware).
func captureClientIP(got *string) http.Handler {
return http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
*got = middleware.GetClientIP(r.Context())
})
}
func TestClientIPMiddleware(t *testing.T) {
defaultCIDRs := []string{"127.0.0.0/8", "::1/128"}
defaultPeers, err := ParseTrustedProxyCIDRs(defaultCIDRs)
if err != nil {
t.Fatalf("ParseTrustedProxyCIDRs: %v", err)
}
cases := []struct {
name string
trustForwardedHeaders bool
trustedPeers []*net.IPNet
trustedCIDRStrings []string
remoteAddr string
xForwardedFor string
wantClientIP string
}{
{
name: "trust disabled: peer recorded, XFF ignored",
trustForwardedHeaders: false,
remoteAddr: "1.2.3.4:5555",
xForwardedFor: "9.9.9.9",
wantClientIP: "1.2.3.4",
},
{
name: "trust enabled, trusted loopback peer, XFF honoured",
trustForwardedHeaders: true,
trustedPeers: defaultPeers,
trustedCIDRStrings: defaultCIDRs,
remoteAddr: "127.0.0.1:5555",
xForwardedFor: "9.9.9.9",
wantClientIP: "9.9.9.9",
},
{
name: "trust enabled, untrusted peer, XFF ignored (peer gate)",
trustForwardedHeaders: true,
trustedPeers: defaultPeers,
trustedCIDRStrings: defaultCIDRs,
remoteAddr: "8.8.8.8:5555",
xForwardedFor: "9.9.9.9",
wantClientIP: "8.8.8.8",
},
{
name: "trust enabled, trusted peer, XFF chain rightmost-untrusted wins",
trustForwardedHeaders: true,
trustedPeers: defaultPeers,
trustedCIDRStrings: defaultCIDRs,
remoteAddr: "127.0.0.1:5555",
xForwardedFor: "9.9.9.9, 127.0.0.2",
wantClientIP: "9.9.9.9",
},
{
name: "trust enabled, trusted peer, garbage XFF falls back to peer",
trustForwardedHeaders: true,
trustedPeers: defaultPeers,
trustedCIDRStrings: defaultCIDRs,
remoteAddr: "127.0.0.1:5555",
xForwardedFor: "not-an-ip",
wantClientIP: "127.0.0.1",
},
{
name: "trust enabled, trusted IPv6 loopback peer, XFF honoured",
trustForwardedHeaders: true,
trustedPeers: defaultPeers,
trustedCIDRStrings: defaultCIDRs,
remoteAddr: "[::1]:5555",
xForwardedFor: "9.9.9.9",
wantClientIP: "9.9.9.9",
},
{
name: "trust enabled, trusted peer, no XFF header: peer recorded",
trustForwardedHeaders: true,
trustedPeers: defaultPeers,
trustedCIDRStrings: defaultCIDRs,
remoteAddr: "127.0.0.1:5555",
xForwardedFor: "",
wantClientIP: "127.0.0.1",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got string
mw := clientIPMiddleware(tc.trustForwardedHeaders, tc.trustedPeers, tc.trustedCIDRStrings)
h := mw(captureClientIP(&got))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = tc.remoteAddr
if tc.xForwardedFor != "" {
req.Header.Set("X-Forwarded-For", tc.xForwardedFor)
}
h.ServeHTTP(httptest.NewRecorder(), req)
if got != tc.wantClientIP {
t.Errorf("GetClientIP = %q, want %q", got, tc.wantClientIP)
}
})
}
}
func TestClientIPMiddleware_AlwaysPopulated(t *testing.T) {
// Regardless of trust settings, GetClientIP must always return a non-empty
// string after clientIPMiddleware has run (the socket peer is the fallback).
mw := clientIPMiddleware(false, nil, nil)
var got string
h := mw(captureClientIP(&got))
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:1234"
h.ServeHTTP(httptest.NewRecorder(), req)
if got == "" {
t.Error("GetClientIP must not be empty after clientIPMiddleware runs")
}
}
func TestParseTrustedProxyCIDRs(t *testing.T) {
t.Run("empty input yields loopback default", func(t *testing.T) {
got, err := ParseTrustedProxyCIDRs(nil)
if err != nil {
t.Fatalf("ParseTrustedProxyCIDRs: %v", err)
}
if len(got) != 2 {
t.Fatalf("default CIDR count = %d, want 2 (127/8 + ::1/128)", len(got))
}
// Should contain 127.0.0.1 and ::1.
if !isFromTrustedPeer("127.0.0.1:1", got) {
t.Error("default CIDRs should include 127.0.0.1")
}
if !isFromTrustedPeer("[::1]:1", got) {
t.Error("default CIDRs should include ::1")
}
})
t.Run("custom CIDRs override defaults", func(t *testing.T) {
got, err := ParseTrustedProxyCIDRs([]string{"10.0.0.0/8"})
if err != nil {
t.Fatalf("ParseTrustedProxyCIDRs: %v", err)
}
if len(got) != 1 {
t.Errorf("custom CIDR count = %d, want 1", len(got))
}
if !isFromTrustedPeer("10.1.2.3:1", got) {
t.Error("10.1.2.3 should be in 10.0.0.0/8")
}
if isFromTrustedPeer("127.0.0.1:1", got) {
t.Error("127.0.0.1 should NOT match when default is overridden")
}
})
t.Run("invalid CIDR returns error", func(t *testing.T) {
_, err := ParseTrustedProxyCIDRs([]string{"not-a-cidr"})
if err == nil {
t.Fatal("expected error on invalid CIDR")
}
})
}