Files
krow_backend/go-api/internal/httpserver/clientip_test.go
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

359 lines
13 KiB
Go

package httpserver
// Unit tests for client-address resolution.
//
// An INTERNAL test package (httpserver, not httpserver_test) because proxyTrust
// is unexported and deliberately so — the trusted set is wired once at server
// construction and there is no reason for anything outside this package to
// build one. The rest of the package's tests stay external; this file is the
// exception because what is under test is a decision procedure, and testing it
// through an HTTP server would obscure which input produced which key.
//
// THE PROPERTY THESE TESTS EXIST TO DEFEND
//
// No untrusted input may produce a distinct bucket key. Every failure path must
// collapse back to the peer address. A test that asserts a spoofed header is
// "ignored" by checking it does not appear is not enough — it must check the
// key equals the PEER's key, because two different wrong answers are still two
// different buckets, and two buckets is the whole exploit.
import (
"net/http"
"net/netip"
"testing"
)
func prefixes(t *testing.T, cidrs ...string) []netip.Prefix {
t.Helper()
out := make([]netip.Prefix, 0, len(cidrs))
for _, c := range cidrs {
p, err := netip.ParsePrefix(c)
if err != nil {
t.Fatalf("bad test CIDR %q: %v", c, err)
}
out = append(out, p.Masked())
}
return out
}
// request builds a request with a peer address and an optional forwarded chain.
// A chain entry of "" means the header is absent.
func request(remoteAddr string, forwarded ...string) *http.Request {
r := &http.Request{
RemoteAddr: remoteAddr,
Header: http.Header{},
}
for _, f := range forwarded {
r.Header.Add(forwardedHeader, f)
}
return r
}
/* ── A. A direct client's forwarded header is not read ──────────────────── */
func TestDirectClientForwardedHeaderIgnored(t *testing.T) {
// A proxy IS configured — just not this caller. The caller reaches the API
// directly and claims to be somebody else.
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
got := trust.clientAddr(request("203.0.113.9:51000", "198.51.100.7"))
if want := "203.0.113.9"; got != want {
t.Errorf("clientAddr = %q, want %q — a direct caller's X-Forwarded-For was believed", got, want)
}
}
func TestNoTrustedProxiesConfiguredIgnoresForwarded(t *testing.T) {
// The default posture. Nothing is trusted, so nothing is read, and the
// behaviour is exactly what it was before this setting existed.
trust := newProxyTrust(nil)
got := trust.clientAddr(request("10.0.0.1:4000", "198.51.100.7"))
if want := "10.0.0.1"; got != want {
t.Errorf("clientAddr = %q, want %q — an unconfigured deployment read a forwarded address", got, want)
}
}
/* ── B. A trusted proxy's forwarded client is used ──────────────────────── */
func TestTrustedProxyForwardedClientUsed(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
got := trust.clientAddr(request("10.0.0.1:4000", "203.0.113.9"))
if want := "203.0.113.9"; got != want {
t.Errorf("clientAddr = %q, want %q", got, want)
}
}
// The point of the whole change: two users behind the same proxy get two keys.
func TestTrustedProxySeparatesTwoClients(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
a := trust.clientAddr(request("10.0.0.1:4000", "203.0.113.9"))
b := trust.clientAddr(request("10.0.0.1:4001", "203.0.113.10"))
if a == b {
t.Fatalf("two clients behind one proxy shared the key %q", a)
}
}
/* ── C. Multiple hops, walked right to left ─────────────────────────────── */
func TestMultipleTrustedHopsSelectsFirstUntrusted(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8", "172.16.0.0/12"))
// client → edge(172.16.0.5) → internal(10.0.0.1) → us.
// Right to left: 10.0.0.1 ours, 172.16.0.5 ours, 203.0.113.9 the client.
got := trust.clientAddr(request("10.0.0.1:4000", "203.0.113.9, 172.16.0.5, 10.0.0.1"))
if want := "203.0.113.9"; got != want {
t.Errorf("clientAddr = %q, want %q", got, want)
}
}
// The chain split across several headers is the same chain.
func TestChainSplitAcrossHeaders(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
got := trust.clientAddr(request("10.0.0.1:4000", "203.0.113.9", "10.0.0.1"))
if want := "203.0.113.9"; got != want {
t.Errorf("clientAddr = %q, want %q", got, want)
}
}
// Entries to the LEFT of the first untrusted address are never read, whatever
// they say. This is what stops a client prepending a forged hop.
func TestEntriesLeftOfTheClientAreNotRead(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
// The caller put "1.2.3.4" at the head of the chain hoping to be keyed by
// it. The proxy appended the address it actually saw.
got := trust.clientAddr(request("10.0.0.1:4000", "1.2.3.4, 203.0.113.9, 10.0.0.1"))
if want := "203.0.113.9"; got != want {
t.Errorf("clientAddr = %q, want %q — a forged leading hop was selected", got, want)
}
}
/* ── D. Spoofing gains nothing ──────────────────────────────────────────── */
// The exploit this design exists to prevent: an untrusted caller varying the
// header to get a fresh budget per request. Every variation must land on the
// SAME key, and that key must be the peer's.
func TestUntrustedSpoofingCannotProduceDistinctBuckets(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
spoofs := []string{
"1.2.3.4",
"5.6.7.8",
"10.0.0.1", // claiming to BE the trusted proxy
"1.1.1.1, 2.2.2.2, 10.0.0.1", // a whole fabricated chain ending in ours
"::1",
"2001:db8::1",
}
const peerKey = "203.0.113.9"
for _, spoof := range spoofs {
got := trust.clientAddr(request("203.0.113.9:51000", spoof))
if got != peerKey {
t.Errorf("X-Forwarded-For %q produced key %q, want %q — spoofing bought a separate bucket",
spoof, got, peerKey)
}
}
}
// A trusted proxy that forwards a chain whose leading entries were forged still
// yields one key per real client, not one per forgery.
func TestSpoofedPrefixBehindTrustedProxyIsStable(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
first := trust.clientAddr(request("10.0.0.1:4000", "9.9.9.9, 203.0.113.9, 10.0.0.1"))
second := trust.clientAddr(request("10.0.0.1:4002", "8.8.8.8, 203.0.113.9, 10.0.0.1"))
if first != second {
t.Errorf("one client produced two keys (%q, %q) by varying a forged hop", first, second)
}
}
/* ── E. Malformed input falls back, and never panics ────────────────────── */
func TestMalformedForwardedEntriesFallBackToPeer(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
cases := map[string]string{
"not an address": "banana",
"unknown": "unknown",
"obfuscated (7239)": "_hidden",
"empty entry": "203.0.113.9, , 10.0.0.1",
"trailing comma": "203.0.113.9,",
"damage before ours": "203.0.113.9, banana, 10.0.0.1",
"whitespace only": " ",
"port but no host": ":443",
"cidr not address": "203.0.113.0/24",
}
const peerKey = "10.0.0.1"
for name, header := range cases {
t.Run(name, func(t *testing.T) {
got := trust.clientAddr(request("10.0.0.1:4000", header))
if got != peerKey {
t.Errorf("clientAddr = %q, want the peer %q", got, peerKey)
}
})
}
}
// An address WITH a port is not malformed — some proxies append one.
func TestForwardedEntryWithPortIsAccepted(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
if got, want := trust.clientAddr(request("10.0.0.1:4000", "203.0.113.9:51000")), "203.0.113.9"; got != want {
t.Errorf("IPv4 with port: clientAddr = %q, want %q", got, want)
}
if got, want := trust.clientAddr(request("10.0.0.1:4000", "[2001:db8::1]:443")), "2001:db8::/64"; got != want {
t.Errorf("IPv6 with port: clientAddr = %q, want %q", got, want)
}
}
func TestMalformedRemoteAddrDoesNotPanic(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
for _, remote := range []string{"", " ", "pipe", "not:an:addr", "@"} {
got := trust.clientAddr(request(remote, "203.0.113.9"))
// Whatever it returns, it must not be the forwarded address: an
// unparseable peer is not a trusted one.
if got == "203.0.113.9" {
t.Errorf("RemoteAddr %q was treated as a trusted peer", remote)
}
}
}
/* ── F. IPv6 is keyed by /64 ────────────────────────────────────────────── */
func TestIPv6SameSlash64SharesABucket(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
// Same /64, different hosts within it — one subscriber, one budget.
a := trust.clientAddr(request("10.0.0.1:4000", "2001:db8:abcd:1234::1"))
b := trust.clientAddr(request("10.0.0.1:4000", "2001:db8:abcd:1234:ffff:ffff:ffff:ffff"))
if a != b {
t.Errorf("two addresses in one /64 produced %q and %q; a caller could mint budgets at will", a, b)
}
if want := "2001:db8:abcd:1234::/64"; a != want {
t.Errorf("key = %q, want %q", a, want)
}
}
func TestIPv6DifferentSlash64DoesNotShareABucket(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
a := trust.clientAddr(request("10.0.0.1:4000", "2001:db8:abcd:1234::1"))
b := trust.clientAddr(request("10.0.0.1:4000", "2001:db8:abcd:9999::1"))
if a == b {
t.Errorf("two different /64s shared the key %q", a)
}
}
// An IPv4 peer reported in IPv4-mapped form is the same caller as the plain
// form, and must not become a second bucket.
func TestIPv4MappedIPv6NormalisesToIPv4(t *testing.T) {
trust := newProxyTrust(nil)
plain := trust.clientAddr(request("203.0.113.9:51000"))
mapped := trust.clientAddr(request("[::ffff:203.0.113.9]:51000"))
if plain != mapped {
t.Errorf("plain %q and mapped %q are the same host but keyed differently", plain, mapped)
}
if want := "203.0.113.9"; plain != want {
t.Errorf("key = %q, want %q", plain, want)
}
}
// A trusted IPv6 proxy works the same way as a trusted IPv4 one.
func TestTrustedIPv6Proxy(t *testing.T) {
trust := newProxyTrust(prefixes(t, "fd00::/8"))
got := trust.clientAddr(request("[fd00::1]:4000", "2001:db8:abcd:1234::5"))
if want := "2001:db8:abcd:1234::/64"; got != want {
t.Errorf("clientAddr = %q, want %q", got, want)
}
}
// A scope id is local to this host and says nothing about who called.
func TestIPv6ZoneIsNotPartOfTheKey(t *testing.T) {
trust := newProxyTrust(nil)
withZone := trust.clientAddr(request("[fe80::1%eth0]:4000"))
without := trust.clientAddr(request("[fe80::1]:4000"))
if withZone != without {
t.Errorf("zone changed the key: %q vs %q", withZone, without)
}
}
/* ── G. No header at all ────────────────────────────────────────────────── */
func TestMissingForwardedHeaderFallsBackToPeer(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
if got, want := trust.clientAddr(request("10.0.0.1:4000")), "10.0.0.1"; got != want {
t.Errorf("clientAddr = %q, want %q", got, want)
}
}
// A chain consisting only of our own proxies names no client.
func TestChainOfOnlyTrustedProxiesFallsBackToPeer(t *testing.T) {
trust := newProxyTrust(prefixes(t, "10.0.0.0/8"))
if got, want := trust.clientAddr(request("10.0.0.1:4000", "10.0.0.2, 10.0.0.1")), "10.0.0.1"; got != want {
t.Errorf("clientAddr = %q, want %q", got, want)
}
}
/* ── H. The port is not part of the key ─────────────────────────────────── */
// Pre-existing behaviour, asserted here because it is the reason this function
// strips the port at all: a browser opens a new source port per connection.
func TestSourcePortIsNotPartOfTheKey(t *testing.T) {
trust := newProxyTrust(nil)
a := trust.clientAddr(request("203.0.113.9:51000"))
b := trust.clientAddr(request("203.0.113.9:51001"))
if a != b {
t.Errorf("source port changed the key: %q vs %q", a, b)
}
}
// A bare address with no port — a test server, or a rewritten RemoteAddr.
func TestRemoteAddrWithoutAPortIsAccepted(t *testing.T) {
trust := newProxyTrust(nil)
if got, want := trust.clientAddr(request("203.0.113.9")), "203.0.113.9"; got != want {
t.Errorf("clientAddr = %q, want %q", got, want)
}
}
/* ── Trust-set edge cases ───────────────────────────────────────────────── */
// A single-host trusted proxy, which is what a bare address in configuration
// becomes.
func TestSingleHostTrustedProxy(t *testing.T) {
trust := newProxyTrust(prefixes(t, "172.17.0.1/32"))
if got, want := trust.clientAddr(request("172.17.0.1:4000", "203.0.113.9")), "203.0.113.9"; got != want {
t.Errorf("trusted host: clientAddr = %q, want %q", got, want)
}
// One address along is NOT trusted.
if got, want := trust.clientAddr(request("172.17.0.2:4000", "203.0.113.9")), "172.17.0.2"; got != want {
t.Errorf("neighbouring host: clientAddr = %q, want %q", got, want)
}
}