mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
157ca4e88f
* 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+"
205 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|