Files
gateway/clientip.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

147 lines
5.5 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
// clientip.go answers "which client is this" — ONCE, for both edges.
//
// A rate limit is only as good as the key it buckets on, and until now each
// transport supplied that key from its own framework:
//
// gin's ClientIP() trusts X-Forwarded-For from EVERY peer — gin's
// defaultTrustedCIDRs is 0.0.0.0/0 + ::/0 and this repo never
// calls SetTrustedProxies — and then returns the LEFTMOST
// entry, which is the one the client wrote.
//
// fiber's IP() ignores X-Forwarded-For entirely (zip sets no ProxyHeader,
// so it returns fasthttp's RemoteIP), so behind hanzoai/ingress
// every request in the cluster carries the SAME peer — the
// ingress pod.
//
// One is forgeable and the other is a self-DoS: a 10/min per-IP cap that either
// never trips or trips once for the whole internet. Both were measured on the
// live lux-ns edge — 20 requests with a rotating X-Forwarded-For never hit the
// cap, and the gin access log printed the forged address as the client.
//
// transport_parity_test.go could not see either, because its two harnesses never
// set a forwarded header and never vary the peer: both edges answered about the
// same nothing, and agreed. So the peer becomes a value, like every other
// decision in this package — a trusted set and a walk over it, stated here and
// asked by both transports.
package gateway
import (
"net/netip"
"os"
"strings"
)
// defaultTrustedProxies is the set a gateway pod's peers actually occupy: the
// loopback it is probed on, and the private ranges an in-cluster ingress lives
// in. A peer OUTSIDE this set reached the gateway directly, and a direct client's
// own X-Forwarded-For is worth exactly nothing.
var defaultTrustedProxies = []string{
"127.0.0.0/8", "::1/128", // loopback — probes, and the pod's own callers
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", // RFC1918 — pod and service CIDRs
"100.64.0.0/10", // CGNAT — DOKS node pools
"169.254.0.0/16", // link-local
"fe80::/10", // link-local v6
"fc00::/7", // unique-local v6
}
// peerPolicy is the compiled trusted-proxy set. Build it with [newPeerPolicy]
// and ask it per request.
type peerPolicy struct{ trusted []netip.Prefix }
// newPeerPolicy compiles the trusted set once, from the environment.
//
// GATEWAY_TRUSTED_PROXIES REPLACES the default with a comma-separated CIDR list.
// An operator fronting this edge with a CDN adds the CDN's ranges there; until
// they do, the CDN's own address is what gets bucketed — coarser than the true
// client, never forgeable by one.
//
// An unparsable entry is DROPPED rather than defaulted. That narrows the trusted
// set, so a typo makes the gate trust less and limit harder — the direction a
// config error should fail in.
func newPeerPolicy() peerPolicy {
spec := defaultTrustedProxies
if env := os.Getenv("GATEWAY_TRUSTED_PROXIES"); env != "" {
spec = strings.Split(env, ",")
}
var p peerPolicy
for _, c := range spec {
if pfx, err := netip.ParsePrefix(strings.TrimSpace(c)); err == nil {
p.trusted = append(p.trusted, pfx.Masked())
}
}
return p
}
func (p peerPolicy) isTrusted(a netip.Addr) bool {
for _, pfx := range p.trusted {
if pfx.Contains(a) {
return true
}
}
return false
}
// clientIP is the address a rate limit should bucket on.
//
// peer is the transport's own view of the SOCKET peer — gin's RemoteIP(), zip's
// Fiber().IP() — never its ClientIP()-style helper, which is the forgeable thing
// this replaces. forwarded is the raw X-Forwarded-For.
//
// The walk is right-to-left, returning the first hop that is not itself a trusted
// proxy. X-Forwarded-For is APPENDED by each hop, so everything to the right of
// the last trusted proxy was written by infrastructure we run, and everything
// left of the first untrusted entry was written by someone we do not. A client
// that pads the header only pads the part already discarded.
func (p peerPolicy) clientIP(peer, forwarded string) string {
addr, ok := parseAddr(peer)
if !ok {
// An unparsable peer is one bucket for all of it. That limits harder
// than guessing, and it cannot be steered by a client.
return peer
}
if !p.isTrusted(addr) {
// Direct client: its own header is not evidence about itself.
return addr.String()
}
var leftmost string
hops := strings.Split(forwarded, ",")
for i := len(hops) - 1; i >= 0; i-- {
hop, ok := parseAddr(hops[i])
if !ok {
// A malformed hop ends the chain: nothing further left can be
// attributed, so stop rather than skip past it.
break
}
if !p.isTrusted(hop) {
return hop.String()
}
leftmost = hop.String()
}
if leftmost != "" {
// Every hop is ours, so the caller is in-cluster and the leftmost entry
// is the one that started it.
return leftmost
}
return addr.String()
}
// parseAddr accepts what a transport or a forwarded header actually carries: a
// bare address, a host:port (bracketed or not), and surrounding space. The
// result is Unmap'd so an IPv4-mapped IPv6 peer — which is what fasthttp reports
// on a dual-stack listener — matches the IPv4 prefixes above.
func parseAddr(s string) (netip.Addr, bool) {
s = strings.TrimSpace(s)
if s == "" {
return netip.Addr{}, false
}
if a, err := netip.ParseAddr(s); err == nil {
return a.Unmap(), true
}
if ap, err := netip.ParseAddrPort(s); err == nil {
return ap.Addr().Unmap(), true
}
return netip.Addr{}, false
}