mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
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]>
105 lines
4.1 KiB
Go
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))
|
|
}
|