2026-03-15 07:07:59 +01:00
|
|
|
package api
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
// White-box tests for clientIP and the trusted-proxy CIDR matching
|
|
|
|
|
// (parseCIDRList + ipInNets — the W3-3a replacement for isTrustedProxy).
|
2026-03-15 07:07:59 +01:00
|
|
|
// These live in package api (not api_test) so they can reach unexported symbols.
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-19 08:56:34 +02:00
|
|
|
"net/http"
|
2026-03-15 07:07:59 +01:00
|
|
|
"net/http/httptest"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
// ─── parseCIDRList + ipInNets ────────────────────────────────────────────────
|
2026-03-15 07:07:59 +01:00
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
// inCIDRs is the test shorthand for the old isTrustedProxy semantics: does ip
|
|
|
|
|
// fall inside any of the (string) CIDRs?
|
|
|
|
|
func inCIDRs(ip string, cidrs []string) bool {
|
|
|
|
|
return ipInNets(ip, parseCIDRList(cidrs))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIPInNets_EmptyList_ReturnsFalse(t *testing.T) {
|
|
|
|
|
if inCIDRs("10.0.0.1", nil) {
|
|
|
|
|
t.Error("ipInNets(empty list) = true, want false")
|
2026-03-15 07:07:59 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
func TestIPInNets_ExactIPMatch(t *testing.T) {
|
|
|
|
|
if !inCIDRs("10.0.0.1", []string{"10.0.0.1/32"}) {
|
|
|
|
|
t.Error("ipInNets exact match = false, want true")
|
2026-03-15 07:07:59 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
func TestIPInNets_CIDRMatch(t *testing.T) {
|
|
|
|
|
if !inCIDRs("192.168.1.50", []string{"192.168.1.0/24"}) {
|
|
|
|
|
t.Error("ipInNets CIDR match = false, want true")
|
2026-03-15 07:07:59 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
func TestIPInNets_CIDRNoMatch(t *testing.T) {
|
|
|
|
|
if inCIDRs("10.9.9.9", []string{"192.168.1.0/24"}) {
|
|
|
|
|
t.Error("ipInNets CIDR non-match = true, want false")
|
2026-03-15 07:07:59 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
func TestIPInNets_MultipleCIDRs_FirstMatches(t *testing.T) {
|
|
|
|
|
if !inCIDRs("10.0.0.5", []string{"172.16.0.0/12", "10.0.0.0/8"}) {
|
|
|
|
|
t.Error("ipInNets multi-CIDR first match = false, want true")
|
2026-03-15 07:07:59 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
func TestIPInNets_MultipleCIDRs_NoneMatch(t *testing.T) {
|
|
|
|
|
if inCIDRs("8.8.8.8", []string{"10.0.0.0/8", "192.168.0.0/16"}) {
|
|
|
|
|
t.Error("ipInNets multi-CIDR no match = true, want false")
|
2026-03-15 07:07:59 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
func TestParseCIDRList_InvalidCIDR_Skipped(t *testing.T) {
|
|
|
|
|
// Invalid entries are skipped (with a startup warning) — they never match,
|
|
|
|
|
// so a fully invalid list grants nothing (fail closed at the call sites).
|
|
|
|
|
if nets := parseCIDRList([]string{"not-a-cidr"}); len(nets) != 0 {
|
|
|
|
|
t.Errorf("parseCIDRList(invalid) = %d nets, want 0", len(nets))
|
|
|
|
|
}
|
|
|
|
|
if inCIDRs("10.0.0.1", []string{"not-a-cidr"}) {
|
|
|
|
|
t.Error("invalid CIDR matched an IP, want no match")
|
2026-03-15 07:07:59 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
func TestParseCIDRList_BarePlainIP_SkippedNotPanic(t *testing.T) {
|
|
|
|
|
// Bare IP without mask is not valid CIDR notation — skipped, no panic.
|
|
|
|
|
if nets := parseCIDRList([]string{"10.0.0.1"}); len(nets) != 0 {
|
|
|
|
|
t.Errorf("parseCIDRList(bare IP) = %d nets, want 0", len(nets))
|
|
|
|
|
}
|
2026-03-15 07:07:59 +01:00
|
|
|
}
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
func TestParseCIDRList_MixedValidInvalid_KeepsValid(t *testing.T) {
|
|
|
|
|
nets := parseCIDRList([]string{"not-a-cidr", "10.0.0.0/8"})
|
|
|
|
|
if len(nets) != 1 {
|
|
|
|
|
t.Fatalf("parseCIDRList(mixed) = %d nets, want 1", len(nets))
|
2026-03-15 07:07:59 +01:00
|
|
|
}
|
2026-07-23 21:07:09 +02:00
|
|
|
if !ipInNets("10.1.2.3", nets) {
|
|
|
|
|
t.Error("valid entry from mixed list did not match")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIPInNets_IPv6Match(t *testing.T) {
|
|
|
|
|
if !inCIDRs("::1", []string{"::1/128"}) {
|
|
|
|
|
t.Error("ipInNets IPv6 exact match = false, want true")
|
2026-03-15 07:07:59 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── clientIP with trusted proxies ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestClientIP_NoTrustedProxies_UsesRemoteAddr(t *testing.T) {
|
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
|
|
|
req.RemoteAddr = "203.0.113.5:4321"
|
|
|
|
|
req.Header.Set("X-Real-IP", "1.2.3.4")
|
|
|
|
|
req.Header.Set("X-Forwarded-For", "1.2.3.4")
|
|
|
|
|
|
|
|
|
|
ip := clientIPWithProxies(req, nil)
|
|
|
|
|
if ip != "203.0.113.5" {
|
|
|
|
|
t.Errorf("clientIP no trusted proxies = %q, want %q", ip, "203.0.113.5")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestClientIP_TrustedProxy_UsesXRealIP(t *testing.T) {
|
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
|
|
|
req.RemoteAddr = "10.0.0.1:9999"
|
|
|
|
|
req.Header.Set("X-Real-IP", "203.0.113.42")
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
ip := clientIPWithProxies(req, parseCIDRList([]string{"10.0.0.0/8"}))
|
2026-03-15 07:07:59 +01:00
|
|
|
if ip != "203.0.113.42" {
|
|
|
|
|
t.Errorf("clientIP trusted proxy = %q, want %q", ip, "203.0.113.42")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestClientIP_TrustedProxy_NoXRealIP_FallsBackToRemoteAddr(t *testing.T) {
|
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
|
|
|
req.RemoteAddr = "10.0.0.1:9999"
|
|
|
|
|
// No X-Real-IP header set.
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
ip := clientIPWithProxies(req, parseCIDRList([]string{"10.0.0.0/8"}))
|
2026-03-15 07:07:59 +01:00
|
|
|
if ip != "10.0.0.1" {
|
|
|
|
|
t.Errorf("clientIP trusted proxy no header = %q, want %q", ip, "10.0.0.1")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestClientIP_UntrustedSource_IgnoresXRealIP(t *testing.T) {
|
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
|
|
|
req.RemoteAddr = "8.8.8.8:12345"
|
|
|
|
|
req.Header.Set("X-Real-IP", "192.168.1.1") // attacker-supplied
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
ip := clientIPWithProxies(req, parseCIDRList([]string{"10.0.0.0/8"}))
|
2026-03-15 07:07:59 +01:00
|
|
|
// Must use RemoteAddr, not the forged X-Real-IP.
|
|
|
|
|
if ip != "8.8.8.8" {
|
|
|
|
|
t.Errorf("clientIP untrusted source = %q, want %q", ip, "8.8.8.8")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestClientIP_XForwardedFor_UsedWhenNoXRealIP(t *testing.T) {
|
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
|
|
|
req.RemoteAddr = "10.0.0.1:9999"
|
|
|
|
|
req.Header.Set("X-Forwarded-For", "203.0.113.10, 10.0.0.1")
|
|
|
|
|
// No X-Real-IP; X-Forwarded-For first entry should be used.
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
ip := clientIPWithProxies(req, parseCIDRList([]string{"10.0.0.0/8"}))
|
2026-03-15 07:07:59 +01:00
|
|
|
if ip != "203.0.113.10" {
|
|
|
|
|
t.Errorf("clientIP X-Forwarded-For = %q, want %q", ip, "203.0.113.10")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 08:56:34 +02:00
|
|
|
// TestClientIP_BroadTrustedCIDRKeepsClientsDistinct locks the W2-5 fix: with
|
|
|
|
|
// a trusted_proxies range broad enough to cover the clients themselves, the
|
|
|
|
|
// right-to-left walk exhausts; falling back to RemoteAddr would collapse
|
|
|
|
|
// every client into the proxy's own bucket (one user's failed logins would
|
|
|
|
|
// lock out everyone). The leftmost valid XFF entry keeps clients distinct.
|
|
|
|
|
func TestClientIP_BroadTrustedCIDRKeepsClientsDistinct(t *testing.T) {
|
2026-07-23 21:07:09 +02:00
|
|
|
trusted := parseCIDRList([]string{"10.0.0.0/8"}) // covers proxy AND LAN clients
|
2026-07-19 08:56:34 +02:00
|
|
|
|
|
|
|
|
newReq := func(xff string) *http.Request {
|
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
|
|
|
req.RemoteAddr = "10.0.0.2:9999" // the proxy
|
|
|
|
|
req.Header.Set("X-Forwarded-For", xff)
|
|
|
|
|
return req
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ip1 := clientIPWithProxies(newReq("10.5.1.7"), trusted)
|
|
|
|
|
ip2 := clientIPWithProxies(newReq("10.5.1.8"), trusted)
|
|
|
|
|
if ip1 != "10.5.1.7" || ip2 != "10.5.1.8" {
|
|
|
|
|
t.Fatalf("clients behind broad trusted CIDR collapsed: ip1=%q ip2=%q", ip1, ip2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Multi-hop: leftmost valid entry (furthest upstream) wins on exhaustion.
|
|
|
|
|
ip3 := clientIPWithProxies(newReq("10.5.1.9, 10.0.0.3"), trusted)
|
|
|
|
|
if ip3 != "10.5.1.9" {
|
|
|
|
|
t.Fatalf("expected furthest-upstream entry, got %q", ip3)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestClientIP_SpoofedXFFFromUntrustedRemoteIgnored: an untrusted connecting
|
|
|
|
|
// address never gets its forwarded headers honoured, exhaustion fallback or
|
|
|
|
|
// not.
|
|
|
|
|
func TestClientIP_SpoofedXFFFromUntrustedRemoteIgnored(t *testing.T) {
|
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
|
|
|
req.RemoteAddr = "203.0.113.9:1234"
|
|
|
|
|
req.Header.Set("X-Forwarded-For", "10.5.1.7")
|
|
|
|
|
|
2026-07-23 21:07:09 +02:00
|
|
|
ip := clientIPWithProxies(req, parseCIDRList([]string{"10.0.0.0/8"}))
|
2026-07-19 08:56:34 +02:00
|
|
|
if ip != "203.0.113.9" {
|
|
|
|
|
t.Fatalf("spoofed XFF from untrusted remote honoured: got %q", ip)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 19:29:31 +02:00
|
|
|
// TestClientIP_XForwardedFor_SkipsMalformedEntries pins the skip guard in the
|
|
|
|
|
// right-to-left walk: a garbage or empty XFF entry must be stepped over, never
|
|
|
|
|
// used as a rate-limit/lockout key. The garbage sits to the RIGHT of the real
|
|
|
|
|
// client so the walk actually reaches it. Dropping the guard would return
|
|
|
|
|
// "not-an-ip" / "garbage" as the key.
|
|
|
|
|
func TestClientIP_XForwardedFor_SkipsMalformedEntries(t *testing.T) {
|
|
|
|
|
trusted := parseCIDRList([]string{"10.0.0.0/8"})
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
xff string
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{"garbage rightmost", "203.0.113.10, not-an-ip", "203.0.113.10"},
|
|
|
|
|
{"empty entry", "203.0.113.10, , 10.0.0.1", "203.0.113.10"},
|
|
|
|
|
{"garbage between hops", "203.0.113.10, ::gg::, 10.0.0.1", "203.0.113.10"},
|
|
|
|
|
// Nothing parseable at all: fall back to RemoteAddr, never a garbage key.
|
|
|
|
|
{"all malformed falls back to RemoteAddr", "garbage, , not-an-ip", "10.0.0.1"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
|
|
|
req.RemoteAddr = "10.0.0.1:9999" // the trusted proxy
|
|
|
|
|
req.Header.Set("X-Forwarded-For", tt.xff)
|
|
|
|
|
|
|
|
|
|
if ip := clientIPWithProxies(req, trusted); ip != tt.want {
|
|
|
|
|
t.Errorf("clientIP XFF %q = %q, want %q", tt.xff, ip, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 07:07:59 +01:00
|
|
|
func TestClientIP_RemoteAddrWithoutPort(t *testing.T) {
|
|
|
|
|
// RemoteAddr sometimes has no port (e.g. Unix sockets in tests).
|
|
|
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
|
|
|
req.RemoteAddr = "10.0.0.1"
|
|
|
|
|
|
|
|
|
|
ip := clientIPWithProxies(req, nil)
|
|
|
|
|
if ip != "10.0.0.1" {
|
|
|
|
|
t.Errorf("clientIP no port = %q, want %q", ip, "10.0.0.1")
|
|
|
|
|
}
|
|
|
|
|
}
|