Files
pad/internal/server/middleware_realip_test.go
T
xarmian 157ca4e88f chore: bump Go toolchain to 1.26 (TASK-763) (#247)
* chore: bump Go toolchain to 1.26 (TASK-763)

Bump Go from 1.25 to 1.26 across all toolchain pins:

- go.mod — go 1.25.0 → go 1.26.0
- Dockerfile — golang:1.25-alpine → golang:1.26-alpine
- .github/workflows/ci.yml — three setup-go steps (Go, Go-Postgres, E2E jobs)
- .github/workflows/release.yml — release pipeline

No `toolchain` directive: the repo is pre-launch with no external
contributors yet, so we set the floor where we want it (hard requirement).

Verified locally before commit:
- golangci-lint v2.11.4 builds and runs under Go 1.26.2 (pinned in CI)
- golang:1.26-alpine and 1.26.2-alpine images present on Docker Hub
- go build ./... clean
- go vet ./... clean
- go test ./... all pass

Parent: PLAN-644 (OSS Repo Hygiene and Launch Polish).

* chore: gofmt -w under Go 1.26 (TASK-763)

Apply Go 1.26's gofmt to the codebase. ~41 files reformatted, all
struct-tag whitespace realignment — no semantic changes. Verified:

- gofmt -l ./cmd ./internal returns empty after
- go build ./... still clean
- go test ./... still passes (run before commit)

Bundling the gofmt diff with the toolchain bump in the same PR because
the formatting drift is a direct consequence of moving from 1.25 to
1.26; splitting them creates a mandatory two-PR ordering for no value.

Parent: PLAN-644.

* docs: bump documented Go floor to 1.26 (TASK-763)

Match go.mod's hard 1.26.0 requirement in the source-build instructions.
Caught by Codex review round 1 on PR #247.

- README.md:158 — "Go 1.25+" → "Go 1.26+"
- CONTRIBUTING.md:9 — "Go 1.25+" → "Go 1.26+"
2026-04-25 11:35:19 -04:00

205 lines
6.0 KiB
Go

package server
import (
"net"
"net/http"
"net/http/httptest"
"testing"
)
func TestParseTrustedProxyCIDRs(t *testing.T) {
tests := []struct {
name string
spec string
wantLen int
contains []string // IPs that should match
rejects []string // IPs that should not match
}{
{
name: "empty spec disables proxy trust",
spec: "",
wantLen: 0,
},
{
name: "single CIDR",
spec: "10.0.0.0/8",
wantLen: 1,
contains: []string{"10.0.0.1", "10.255.255.255"},
rejects: []string{"11.0.0.1", "192.168.1.1"},
},
{
name: "bare IPv4 becomes /32",
spec: "192.168.1.50",
wantLen: 1,
contains: []string{"192.168.1.50"},
rejects: []string{"192.168.1.51"},
},
{
name: "bare IPv6 becomes /128",
spec: "::1",
wantLen: 1,
contains: []string{"::1"},
rejects: []string{"::2"},
},
{
name: "mixed CIDRs and IPs",
spec: "10.0.0.0/8, 172.16.0.0/12, 192.168.1.1",
wantLen: 3,
contains: []string{"10.1.1.1", "172.16.0.5", "192.168.1.1"},
rejects: []string{"8.8.8.8", "192.168.1.2"},
},
{
name: "invalid entries are skipped",
spec: "10.0.0.0/8, not-an-ip, 999.999.999.999",
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ParseTrustedProxyCIDRs(tt.spec)
if len(got) != tt.wantLen {
t.Fatalf("len(cidrs) = %d, want %d", len(got), tt.wantLen)
}
for _, ip := range tt.contains {
if !ipInCIDRs(net.ParseIP(ip), got) {
t.Errorf("%s should match", ip)
}
}
for _, ip := range tt.rejects {
if ipInCIDRs(net.ParseIP(ip), got) {
t.Errorf("%s should NOT match", ip)
}
}
})
}
}
func TestTrustedProxyRealIP_NoProxies_HeadersIgnored(t *testing.T) {
// When no trusted proxies are configured, proxy headers are completely
// ignored and the real TCP peer address wins.
var seen string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.RemoteAddr
})
mw := TrustedProxyRealIP(nil)(next)
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "203.0.113.5:12345"
req.Header.Set("X-Forwarded-For", "198.51.100.7") // attacker-spoofed
mw.ServeHTTP(httptest.NewRecorder(), req)
if seen != "203.0.113.5:12345" {
t.Fatalf("RemoteAddr was rewritten despite empty trust list: %s", seen)
}
}
func TestTrustedProxyRealIP_UntrustedPeer_HeadersIgnored(t *testing.T) {
// Proxy headers from an untrusted peer must be ignored even when the
// trust list is non-empty.
var seen string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.RemoteAddr
})
cidrs := ParseTrustedProxyCIDRs("10.0.0.0/8")
mw := TrustedProxyRealIP(cidrs)(next)
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "203.0.113.5:12345" // NOT in 10/8
req.Header.Set("X-Forwarded-For", "198.51.100.7")
mw.ServeHTTP(httptest.NewRecorder(), req)
if seen != "203.0.113.5:12345" {
t.Fatalf("RemoteAddr was rewritten for untrusted peer: %s", seen)
}
}
func TestTrustedProxyRealIP_TrustedPeer_XRealIPUsed(t *testing.T) {
var seen string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.RemoteAddr
})
cidrs := ParseTrustedProxyCIDRs("10.0.0.0/8")
mw := TrustedProxyRealIP(cidrs)(next)
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "10.0.0.5:12345" // in trusted CIDR
req.Header.Set("X-Real-IP", "198.51.100.7")
mw.ServeHTTP(httptest.NewRecorder(), req)
if seen != "198.51.100.7" {
t.Fatalf("X-Real-IP from trusted peer was ignored: %s", seen)
}
}
func TestTrustedProxyRealIP_TrustedPeer_XFFFirstEntryUsed(t *testing.T) {
var seen string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.RemoteAddr
})
cidrs := ParseTrustedProxyCIDRs("10.0.0.0/8")
mw := TrustedProxyRealIP(cidrs)(next)
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "10.0.0.5:12345"
req.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.5")
mw.ServeHTTP(httptest.NewRecorder(), req)
if seen != "198.51.100.7" {
t.Fatalf("XFF first entry not honored: %s", seen)
}
}
func TestCapturePeerAddr_PreservesOriginalRemoteAddr(t *testing.T) {
// CapturePeerAddr installs the raw peer into context BEFORE TrustedProxyRealIP
// has a chance to rewrite it — so downstream handlers can always fetch the
// real TCP peer, even on deployments with trusted proxies.
const realPeer = "10.0.0.5:12345"
var seenCtx, seenRemoteAddr string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenCtx = rawPeerAddr(r)
seenRemoteAddr = r.RemoteAddr
})
cidrs := ParseTrustedProxyCIDRs("10.0.0.0/8")
chain := CapturePeerAddr(TrustedProxyRealIP(cidrs)(next))
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = realPeer
req.Header.Set("X-Forwarded-For", "198.51.100.7")
chain.ServeHTTP(httptest.NewRecorder(), req)
if seenRemoteAddr != "198.51.100.7" {
t.Fatalf("expected RemoteAddr rewritten to XFF value, got %q", seenRemoteAddr)
}
if seenCtx != realPeer {
t.Fatalf("expected context to preserve raw peer %q, got %q", realPeer, seenCtx)
}
}
func TestRawPeerAddr_FallsBackToRemoteAddrWithoutMiddleware(t *testing.T) {
// In tests (or any call path that skips CapturePeerAddr), rawPeerAddr
// falls back to r.RemoteAddr rather than returning empty.
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "192.0.2.1:9999"
if got := rawPeerAddr(req); got != "192.0.2.1:9999" {
t.Fatalf("rawPeerAddr fallback = %q, want %q", got, "192.0.2.1:9999")
}
}
func TestTrustedProxyRealIP_TrustedPeer_InvalidHeaderIgnored(t *testing.T) {
var seen string
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.RemoteAddr
})
cidrs := ParseTrustedProxyCIDRs("10.0.0.0/8")
mw := TrustedProxyRealIP(cidrs)(next)
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "10.0.0.5:12345"
req.Header.Set("X-Real-IP", "not-an-ip")
mw.ServeHTTP(httptest.NewRecorder(), req)
if seen != "10.0.0.5:12345" {
t.Fatalf("invalid header was trusted: %s", seen)
}
}