Files
gateway/clientip_test.go
zeekayandHanzo Dev 06210cb7de gateway: a client does not get to choose its own rate-limit bucket
Shipping the two dark gates meant checking that they hold, and the widget one
did not. Both halves were measured on the live lux-ns edge, against the running
image, by body — not by reading the source.

WHAT EACH CONTROL IS WORTH. Origin is written by whoever makes the request.
Against a browser that is trustworthy — the browser sets it and a page cannot lie
about its own origin — and browser-driven abuse is what the allowlist is for.
Against a script it is not a control at all: `Origin: https://hanzo.ai` was
admitted from a shell. So the RATE LIMIT is the only thing that binds a scripted
attacker holding an hz_ key lifted from a page's source, and its key was
forgeable:

  20 requests, one peer, rotating X-Forwarded-For, cap of 10 -> 20 admitted.
  The gin access log printed the forged address as the client.

gin's ClientIP() applies gin's forwarded-header policy, and gin's default trusted
set is 0.0.0.0/0 + ::/0 (defaultTrustedCIDRs); this repo never calls
SetTrustedProxies. So it walks X-Forwarded-For to the LEFTMOST entry — the one
the client wrote. fiber's IP() has the opposite flaw: zip sets no ProxyHeader, so
it reads no forwarded header at all, and behind hanzoai/ingress every caller in
the cluster would share the ingress pod's bucket — a 10/min cap for the whole
internet.

One forgeable, one a self-DoS, and transport_parity_test.go could not see either:
its harnesses never set a forwarded header and never vary the peer, so both edges
answered about the same nothing and agreed. The new case fails on the old code
with exactly the message the harness exists to print —
`TRANSPORTS DISAGREE: gin=200 zip=429`.

So the peer becomes a value, like every other decision here: clientip.go states
one trusted-proxy set and one right-to-left walk over the forwarded chain, and
both transports hand it the SOCKET peer (gin's RemoteIP, not ClientIP) and ask.
GATEWAY_TRUSTED_PROXIES widens it for an operator fronting the edge with a CDN;
an unparsable entry is dropped, which narrows trust — the direction a config
error should fail in.

The second half: the origin allowlist had a User-Agent escape hatch. Go's
precedence made it

    kube-probe  OR  Wget  OR  (curl AND 127.0.0.1)

so only the curl arm was pinned to loopback, and any request claiming Wget or
kube-probe skipped the allowlist from anywhere. Live, by body: a widget key with
no Origin and `User-Agent: Wget/1.21` was ADMITTED (reached the upstream proxy,
came back 502) where the same request with a browser User-Agent got the 403.
Nothing legitimate needed it — a kubelet probe sends no Authorization header, so
it never reaches that branch; every httpGet probe in k8s/ is a bare path with no
httpHeaders. It is deleted, and admit() now does what its own doc comment always
said.

A test ENCODED the hatch: `widget_key_kube_probe_allowed` asserted 200 and its
name read like a feature. It now asserts 403, with a sibling for Wget.

make test green on both tag sets (the legacy edge the image ships and the default
zip build); go vet clean on both.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 10:08:04 -07:00

105 lines
4.1 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
package gateway
import "testing"
// The whole point of the policy: a client cannot choose its own rate-limit
// bucket. Every case below is a request a client could construct.
func TestPeerPolicy_ClientIP(t *testing.T) {
p := newPeerPolicy()
cases := []struct {
name string
peer string
forwarded string
want string
}{
// A peer we do not run is the client, and its own claim about itself is
// discarded — this is the case gin got wrong, returning 198.51.100.7.
{"direct client cannot forge", "203.0.113.9", "198.51.100.7", "203.0.113.9"},
{"direct client cannot forge a chain", "203.0.113.9", "10.0.0.1, 127.0.0.1", "203.0.113.9"},
{"direct client, no header", "203.0.113.9", "", "203.0.113.9"},
// Behind our own ingress the chain IS evidence, as far as it is ours.
{"ingress forwards the client", "10.150.104.186", "203.0.113.9", "203.0.113.9"},
{"rightmost untrusted hop wins", "10.150.104.186", "203.0.113.9, 198.51.100.4", "198.51.100.4"},
{"padding left of the real hop is ignored", "10.150.104.186", "1.1.1.1, 2.2.2.2, 203.0.113.9", "203.0.113.9"},
{"our own hops are skipped", "10.150.104.186", "203.0.113.9, 10.4.5.6, 10.7.8.9", "203.0.113.9"},
// Nothing to attribute: bucket on something a client cannot steer.
{"trusted peer, no header", "10.150.104.186", "", "10.150.104.186"},
{"whole chain is in-cluster", "10.150.104.186", "10.1.2.3, 10.4.5.6", "10.1.2.3"},
{"malformed hop ends the chain", "10.150.104.186", "203.0.113.9, garbage", "10.150.104.186"},
// Shapes a transport actually hands us.
{"peer with port", "203.0.113.9:53314", "", "203.0.113.9"},
{"v6 peer with port", "[2001:db8::1]:443", "", "2001:db8::1"},
{"v4-mapped v6 loopback is loopback", "::ffff:127.0.0.1", "203.0.113.9", "203.0.113.9"},
{"forwarded entry with spaces", "10.150.104.186", " 203.0.113.9 ", "203.0.113.9"},
{"unparsable peer is its own bucket", "", "203.0.113.9", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := p.clientIP(tc.peer, tc.forwarded); got != tc.want {
t.Errorf("clientIP(%q, %q) = %q, want %q", tc.peer, tc.forwarded, got, tc.want)
}
})
}
}
// The measured live defect, as a test: 20 requests from one peer with a rotating
// X-Forwarded-For must be twenty requests from ONE bucket. On the live lux-ns
// edge they were twenty buckets and the 10/min cap never tripped.
func TestPeerPolicy_RotatingForwardedForIsOneBucket(t *testing.T) {
p := newPeerPolicy()
const peer = "203.0.113.9" // the attacker's real socket peer
first := p.clientIP(peer, "198.51.100.1")
for i := 2; i <= 20; i++ {
got := p.clientIP(peer, "198.51.100."+itoa(i))
if got != first {
t.Fatalf("rotating X-Forwarded-For moved the bucket: %q then %q", first, got)
}
}
if first != peer {
t.Errorf("bucketed on %q, want the real peer %q", first, peer)
}
}
// An operator fronting the edge with a CDN widens the trusted set; nothing else
// about the walk changes.
func TestPeerPolicy_TrustedSetIsConfigurable(t *testing.T) {
t.Setenv("GATEWAY_TRUSTED_PROXIES", "198.51.100.0/24")
p := newPeerPolicy()
if got := p.clientIP("198.51.100.4", "203.0.113.9"); got != "203.0.113.9" {
t.Errorf("configured proxy should be trusted to forward: got %q", got)
}
// The defaults are REPLACED, not merged: a private peer is no longer trusted.
if got := p.clientIP("10.150.104.186", "203.0.113.9"); got != "10.150.104.186" {
t.Errorf("default range should no longer be trusted: got %q", got)
}
}
// A typo must make the gate trust LESS, never more.
func TestPeerPolicy_UnparsableCIDRNarrows(t *testing.T) {
t.Setenv("GATEWAY_TRUSTED_PROXIES", "not-a-cidr, 10.0.0.0/8")
p := newPeerPolicy()
if got := p.clientIP("10.150.104.186", "203.0.113.9"); got != "203.0.113.9" {
t.Errorf("the valid half should still be trusted: got %q", got)
}
if got := p.clientIP("127.0.0.1", "203.0.113.9"); got != "127.0.0.1" {
t.Errorf("a dropped entry must not widen trust: got %q", got)
}
}
func itoa(i int) string {
if i < 10 {
return string(rune('0' + i))
}
return string(rune('0'+i/10)) + string(rune('0'+i%10))
}