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) } }