fix(multiplayer): resolve client IP behind proxies

This commit is contained in:
Josh Creek
2026-09-02 19:08:34 +01:00
parent d59e0017f7
commit 6ebd6e59c1
7 changed files with 166 additions and 8 deletions
@@ -52,6 +52,10 @@ spec:
- --rate-limit=120
- --rate-limit-window=1m
- --rate-limit-max-keys=10000
# Ingress NetworkPolicy admits only the labelled edge gateway.
# Cover common private/CGNAT/ULA pod networks; overlays should
# narrow this to their actual gateway CIDR where available.
- --trusted-proxy-cidrs=10.0.0.0/8,100.64.0.0/10,172.16.0.0/12,192.168.0.0/16,fc00::/7
ports:
- name: http
containerPort: 8080
+2
View File
@@ -1640,3 +1640,5 @@ The production allocator now uses the API it actually implements: Kubernetes cus
Drain admission now fails at the handshake boundary: a new `_hello` is rejected with the actual RPC peer ID after `admissions_open` closes. Disconnects no longer perform the admission check (or try to reject an already-gone sender); they always invalidate transport state, release the signed join token, record the reconnect boundary, and remove the roster entry. Godot regressions cover the admission decision and cleanup while draining. Task 8.36's live lifecycle/PDB gates remain open.
Allocated team and slot assignments are now immutable after signed admission. MatchNet rejects client `_set_team` requests whenever join authorisation is required, preserving the signed global-slot/team pairing and its derived spawn index; direct/community lobbies retain team switching and its existing unready behavior. The Godot regression asserts both sides of that compatibility boundary.
Per-IP API limiting now resolves the client behind the edge gateway instead of charging every player to the gateway's socket address. `X-Forwarded-For` is ignored unless the immediate peer belongs to an explicitly configured `--trusted-proxy-cidrs` range; trusted chains are walked from right to left past known proxies, while malformed/oversized chains fail closed to the immediate peer. The base deployment supplies private/CGNAT/ULA pod ranges under its edge-only ingress NetworkPolicy and calls out that production overlays should narrow them to the actual gateway CIDR. Tests cover spoofing from an untrusted peer, chained proxies, malformed input, invalid configuration, and independent clients behind one gateway.
+81 -7
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"sync"
"time"
@@ -27,6 +28,30 @@ type rateWindow struct {
count int
}
// ClientIPResolver accepts X-Forwarded-For only from explicitly trusted
// immediate peers. It walks the chain from the application backwards so an
// untrusted client cannot select its own rate-limit identity by prepending a
// forged address.
type ClientIPResolver struct {
trustedProxies []netip.Prefix
}
func NewClientIPResolver(cidrs string) (*ClientIPResolver, error) {
resolver := &ClientIPResolver{}
for _, raw := range strings.Split(cidrs, ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
prefix, err := netip.ParsePrefix(raw)
if err != nil {
return nil, fmt.Errorf("invalid trusted proxy CIDR %q", raw)
}
resolver.trustedProxies = append(resolver.trustedProxies, prefix.Masked())
}
return resolver, nil
}
func NewRateLimiter(limit int, window time.Duration, maxKeys int) (*RateLimiter, error) {
if limit < 1 || window <= 0 || maxKeys < 1 {
return nil, fmt.Errorf("invalid rate limiter configuration")
@@ -93,26 +118,75 @@ func (l *RateLimiter) AllowKeys(keys []string, now time.Time) bool {
return true
}
func requestRateKey(r *http.Request) string {
keys := requestRateKeys(r)
func requestRateKey(r *http.Request, resolver *ClientIPResolver) string {
keys := requestRateKeys(r, resolver)
if len(keys) == 0 {
return ""
}
return keys[0]
}
func requestRateKeys(r *http.Request) []string {
func requestRateKeys(r *http.Request, resolver *ClientIPResolver) []string {
keys := make([]string, 0, 2)
if authorization := strings.TrimSpace(r.Header.Get("Authorization")); authorization != "" {
digest := sha256.Sum256([]byte(authorization))
keys = append(keys, "auth:"+hex.EncodeToString(digest[:]))
}
host := r.RemoteAddr
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
host = parsedHost
}
host := requestClientIP(r, resolver)
if host == "" {
return keys
}
return append(keys, "ip:"+host)
}
func requestClientIP(r *http.Request, resolver *ClientIPResolver) string {
host := strings.TrimSpace(r.RemoteAddr)
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
host = parsedHost
}
remote, err := netip.ParseAddr(strings.Trim(host, "[]"))
if err != nil {
return host
}
remote = remote.Unmap()
if resolver == nil || !resolver.trusts(remote) {
return remote.String()
}
forwarded := strings.Join(r.Header.Values("X-Forwarded-For"), ",")
if forwarded == "" || len(forwarded) > 2048 {
return remote.String()
}
parts := strings.Split(forwarded, ",")
if len(parts) > 16 {
return remote.String()
}
chain := make([]netip.Addr, 0, len(parts))
for _, part := range parts {
address, parseErr := netip.ParseAddr(strings.TrimSpace(part))
if parseErr != nil {
return remote.String()
}
chain = append(chain, address.Unmap())
}
for index := len(chain) - 1; index >= 0; index-- {
if !resolver.trusts(chain[index]) {
return chain[index].String()
}
}
if len(chain) > 0 {
return chain[0].String()
}
return remote.String()
}
func (r *ClientIPResolver) trusts(address netip.Addr) bool {
if r == nil || !address.IsValid() {
return false
}
for _, prefix := range r.trustedProxies {
if prefix.Contains(address) {
return true
}
}
return false
}
+70
View File
@@ -80,3 +80,73 @@ func TestRateLimitedHTTPBoundaryReturnsGeneric429(t *testing.T) {
t.Fatalf("limited request status = %d", response.StatusCode)
}
}
func TestClientIPResolverTrustsForwardingOnlyFromConfiguredProxy(t *testing.T) {
resolver, err := NewClientIPResolver("10.0.0.0/8, 2001:db8::/32")
if err != nil {
t.Fatal(err)
}
untrusted := httptest.NewRequest(http.MethodGet, "/", nil)
untrusted.RemoteAddr = "203.0.113.10:1234"
untrusted.Header.Set("X-Forwarded-For", "198.51.100.7")
if got := requestClientIP(untrusted, resolver); got != "203.0.113.10" {
t.Fatalf("untrusted peer selected forwarded IP %q", got)
}
trusted := httptest.NewRequest(http.MethodGet, "/", nil)
trusted.RemoteAddr = "10.2.3.4:443"
trusted.Header.Set("X-Forwarded-For", "198.51.100.7, 10.9.8.7")
if got := requestClientIP(trusted, resolver); got != "198.51.100.7" {
t.Fatalf("trusted proxy chain resolved to %q", got)
}
trusted.Header["X-Forwarded-For"] = []string{"192.0.2.99", "198.51.100.7, 10.9.8.7"}
if got := requestClientIP(trusted, resolver); got != "198.51.100.7" {
t.Fatalf("repeated forwarded headers bypassed the nearest untrusted address: %q", got)
}
trusted.Header.Set("X-Forwarded-For", "forged, 198.51.100.7")
if got := requestClientIP(trusted, resolver); got != "10.2.3.4" {
t.Fatalf("malformed forwarding did not fail closed to immediate peer: %q", got)
}
}
func TestClientIPResolverRejectsInvalidCIDRs(t *testing.T) {
if _, err := NewClientIPResolver("10.0.0.0/8,not-a-network"); err == nil {
t.Fatal("invalid trusted proxy CIDR accepted")
}
}
func TestRateLimiterSeparatesClientsBehindTrustedGateway(t *testing.T) {
limiter, err := NewRateLimiter(1, time.Minute, 8)
if err != nil {
t.Fatal(err)
}
resolver, err := NewClientIPResolver("127.0.0.0/8")
if err != nil {
t.Fatal(err)
}
service := &Service{RateLimiter: limiter, ClientIPs: resolver, Now: func() time.Time { return time.Unix(1000, 0) }}
server := httptest.NewServer(service.Handler())
defer server.Close()
request := func(forwarded string) int {
req, requestErr := http.NewRequest(http.MethodGet, server.URL+"/healthz", nil)
if requestErr != nil {
t.Fatal(requestErr)
}
req.Header.Set("X-Forwarded-For", forwarded)
response, requestErr := server.Client().Do(req)
if requestErr != nil {
t.Fatal(requestErr)
}
response.Body.Close()
return response.StatusCode
}
if got := request("198.51.100.1"); got != http.StatusOK {
t.Fatalf("first client status = %d", got)
}
if got := request("198.51.100.2"); got != http.StatusOK {
t.Fatalf("second client behind gateway status = %d", got)
}
if got := request("198.51.100.1"); got != http.StatusTooManyRequests {
t.Fatalf("repeated first client status = %d", got)
}
}
+2 -1
View File
@@ -127,6 +127,7 @@ type Service struct {
RankedProfileProvider RankedProfileProvider
TierPolicy domain.TierPolicy
RateLimiter *RateLimiter
ClientIPs *ClientIPResolver
Admission AdmissionController
// Log receives a credential-safe structured event for lifecycle-relevant
// reads and mutations. Nil
@@ -207,7 +208,7 @@ func (s *Service) Handler() http.Handler {
var handler http.Handler = mux
if s.RateLimiter != nil {
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.RateLimiter.AllowKeys(requestRateKeys(r), s.now()) {
if !s.RateLimiter.AllowKeys(requestRateKeys(r, s.ClientIPs), s.now()) {
writeError(w, http.StatusTooManyRequests, "rate_limited")
return
}
+6
View File
@@ -33,6 +33,7 @@ func main() {
rateLimit := flag.Int("rate-limit", 120, "maximum requests per per-credential/IP fixed window")
rateWindow := flag.Duration("rate-limit-window", time.Minute, "fixed window for the per-replica request limiter")
rateMaxKeys := flag.Int("rate-limit-max-keys", 10000, "maximum credential/IP keys retained by the per-replica request limiter")
trustedProxyCIDRs := flag.String("trusted-proxy-cidrs", os.Getenv("COSMIC_CLASH_TRUSTED_PROXY_CIDRS"), "comma-separated immediate proxy CIDRs allowed to supply X-Forwarded-For")
flag.Parse()
if *role != "api" {
fatalf("unsupported role %q (only api is implemented)", *role)
@@ -47,6 +48,10 @@ func main() {
if err != nil {
fatalf("invalid request limiter configuration: %v", err)
}
clientIPs, err := api.NewClientIPResolver(*trustedProxyCIDRs)
if err != nil {
fatalf("invalid trusted proxy configuration: %v", err)
}
db, err := sql.Open("pgx", *dsn)
if err != nil {
fatalf("open PostgreSQL: %v", err)
@@ -72,6 +77,7 @@ func main() {
}
service := newAPIService(db, *workloadSecret, candidateIndex)
service.RateLimiter = rateLimiter
service.ClientIPs = clientIPs
admission := api.NewAdmissionGate(*degraded)
service.Admission = admission
server := &http.Server{Addr: *listen, Handler: service.Handler(), ReadHeaderTimeout: 5 * time.Second}
@@ -22,6 +22,7 @@ class KubernetesPolicyTest(unittest.TestCase):
"readOnlyRootFilesystem: true", "drop: [ALL]", "resources:",
"image: ghcr.io/cosmic-clash/control-plane@sha256:",
"--rate-limit=120", "--rate-limit-window=1m", "--rate-limit-max-keys=10000",
"--trusted-proxy-cidrs=10.0.0.0/8,100.64.0.0/10,172.16.0.0/12,192.168.0.0/16,fc00::/7",
"name: COSMIC_CLASH_POSTGRES_DSN", "key: dsn",
"name: COSMIC_CLASH_WORKLOAD_SECRET", "key: secret",
):