mcp connection
This commit is contained in:
358
go-api/internal/httpserver/clientip_test.go
Normal file
358
go-api/internal/httpserver/clientip_test.go
Normal file
@@ -0,0 +1,358 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user