Files
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

218 lines
8.3 KiB
Go

package httpserver
import (
"net"
"net/http"
"net/netip"
"strings"
)
// Resolving the caller's network address behind a reverse proxy.
//
// WHAT THIS IS FOR
//
// Three limits on this API are keyed by the caller's address: failed logins
// (auth.go), OAuth client registration, and OAuth authorization before the
// caller has signed in. None of them has a better identity available —
// registration is anonymous by definition, and a login attempt is anonymous
// until the password has been judged.
//
// Behind a proxy, net/http reports the PROXY's address on every request. Those
// three budgets then describe the proxy rather than the caller, which means one
// bucket for the whole deployment: one person retrying a connector exhausts
// everybody's registration allowance, and twenty failed passwords anywhere lock
// out every user's sign-in. That is the fault this file exists to fix.
//
// WHY IT IS NOT JUST X-Forwarded-For
//
// The header is written by clients as readily as by proxies. Believing it
// unconditionally is worse than the shared bucket rather than better: a caller
// who reaches the API directly can put a different value in every request and
// get a fresh budget each time, which is not a weakened limit but no limit at
// all. The header carries information only about the hop that appended it, so
// it is worth exactly as much as the peer that handed it over.
//
// Hence: believe it only when the immediate peer is a configured proxy, and
// walk the chain from the right, where the entries were written by the hops
// closest to us, discarding those that are themselves trusted proxies. The
// first address that is not one of ours is the nearest thing to the real client
// that the topology can actually vouch for. Everything to its left was supplied
// by something we do not control and is never read.
//
// FAILING SAFE
//
// Every fallback in here returns the PEER address. That is deliberate and it is
// the property worth preserving if this code is ever changed: a bad or missing
// chain can only ever make a bucket coarser — more callers sharing one budget,
// which is the old behaviour — and can never hand a caller a bucket of their
// own. Spoofing gains nothing because no path exists from an untrusted input to
// a distinct key.
// proxyTrust turns a request into the address key used for rate limiting.
//
// A value rather than a package-level variable so that the trusted set is
// wired once at construction and cannot be changed by anything holding a
// request. An empty proxyTrust is valid and trusts nothing.
type proxyTrust struct {
// trusted networks, already masked by config parsing.
trusted []netip.Prefix
}
// newProxyTrust builds the resolver from configuration.
func newProxyTrust(trusted []netip.Prefix) proxyTrust {
return proxyTrust{trusted: trusted}
}
// forwardedHeader is the de facto standard, and what Traefik, nginx, Envoy and
// the cloud load balancers all append to.
//
// RFC 7239's `Forwarded:` header is deliberately NOT read. Supporting both
// would mean deciding which wins when they disagree, and an attacker choosing
// the one this code happens to prefer. One header, one meaning.
const forwardedHeader = "X-Forwarded-For"
// clientAddr returns the rate-limiting key for the caller's address.
//
// The port is stripped: a browser opens a new source port per connection, so
// keying on host:port would give every attempt its own budget and limit nothing
// at all. IPv6 is keyed by /64 — see bucketKey.
func (t proxyTrust) clientAddr(r *http.Request) string {
peer, ok := parseHost(r.RemoteAddr)
if !ok {
// RemoteAddr is not something this code recognises — a test server with
// a synthetic value, or a unix socket. Key by it verbatim, which is
// what this function did before proxies were considered at all.
return strings.TrimSpace(r.RemoteAddr)
}
peerKey := bucketKey(peer)
// Nothing is trusted, so nothing is read. The common case, and the default.
if len(t.trusted) == 0 || !t.contains(peer) {
return peerKey
}
if client, ok := t.forwardedClient(r); ok {
return bucketKey(client)
}
return peerKey
}
// forwardedClient walks the forwarded chain from the right and returns the
// first address that is not one of our own proxies.
//
// It reports false — meaning "fall back to the peer" — for an absent header, a
// chain that is entirely trusted proxies, and a malformed entry. The last of
// those is the interesting one: a chain that cannot be parsed cannot be
// reasoned about, and the safe reading of "10.0.0.1, ???, 10.0.0.2" is that
// everything to the left of the damage is unusable. Skipping the bad entry and
// carrying on would let a caller put anything it likes in the header and have
// this code step over it to reach the value the caller wanted read.
func (t proxyTrust) forwardedClient(r *http.Request) (netip.Addr, bool) {
// Values(), not Get(), because a chain may arrive as several headers as
// well as one comma-separated list; they are the same list in HTTP's terms
// and the rightmost entry of the last header is the most recent hop.
var chain []string
for _, header := range r.Header.Values(forwardedHeader) {
for _, entry := range strings.Split(header, ",") {
chain = append(chain, strings.TrimSpace(entry))
}
}
for i := len(chain) - 1; i >= 0; i-- {
entry := chain[i]
if entry == "" {
// A stray comma. Treated as damage rather than skipped, for the
// reason in the doc comment above.
return netip.Addr{}, false
}
addr, ok := parseForwardedAddr(entry)
if !ok {
return netip.Addr{}, false
}
if t.contains(addr) {
// One of ours. Keep walking left, towards the client.
continue
}
return addr, true
}
// Either there was no header, or every hop in it was a trusted proxy and
// none of them recorded a client. Neither tells us who called.
return netip.Addr{}, false
}
// contains reports whether an address is one of the configured proxies.
func (t proxyTrust) contains(addr netip.Addr) bool {
addr = addr.Unmap()
for _, prefix := range t.trusted {
if prefix.Contains(addr) {
return true
}
}
return false
}
// bucketKey is the string a rate-limit bucket is keyed by.
//
// IPv4 keys by the exact address, which is what this service has always done
// and what the existing buckets contain.
//
// IPv6 keys by the /64 PREFIX instead. A single customer is routinely delegated
// a whole /64 — often a /56 or shorter — and every address in it is one
// machine's to choose. Keying by the full address would hand one caller
// 18 quintillion budgets, which is a limit in form only. /64 is the smallest
// unit that is reliably one subscriber rather than one interface, so it is the
// narrowest honest key.
func bucketKey(addr netip.Addr) string {
addr = addr.Unmap().WithZone("") // a scope id is local to the host, never a caller identity
if addr.Is4() {
return addr.String()
}
prefix, err := addr.Prefix(64)
if err != nil {
return addr.String()
}
return prefix.String()
}
// parseHost splits "host:port" and parses the host.
//
// RemoteAddr always carries a port for TCP, but a test server, a unix socket or
// a middleware that rewrote it may not, so a bare address is accepted too.
func parseHost(remoteAddr string) (netip.Addr, bool) {
raw := strings.TrimSpace(remoteAddr)
if raw == "" {
return netip.Addr{}, false
}
if host, _, err := net.SplitHostPort(raw); err == nil {
raw = host
}
addr, err := netip.ParseAddr(strings.Trim(raw, "[]"))
if err != nil {
return netip.Addr{}, false
}
return addr, true
}
// parseForwardedAddr parses one entry of an X-Forwarded-For chain.
//
// Entries are bare addresses by the header's convention, but a port turns up in
// practice — some proxies append one, and IPv6 is then bracketed. Both forms
// are accepted; anything else is malformed and refused.
//
// "unknown", the obfuscated identifiers RFC 7239 permits, and empty entries are
// all refused rather than skipped: they say the chain is not a list of
// addresses, and this code declines to guess which of the remaining entries the
// proxy meant.
func parseForwardedAddr(entry string) (netip.Addr, bool) {
if addr, err := netip.ParseAddr(entry); err == nil {
return addr, true
}
// "[2001:db8::1]:443" or "203.0.113.7:443".
if host, _, err := net.SplitHostPort(entry); err == nil {
if addr, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil {
return addr, true
}
}
return netip.Addr{}, false
}