From 7f7516d9a03d50a80431786392786d805c85fc4c Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:17:02 +0100 Subject: [PATCH] feat: add bounded control plane rate limiting --- multiplayer-todo.md | 2 +- server/api/rate_limit.go | 81 +++++++++++++++++++++++++++++++++++ server/api/rate_limit_test.go | 62 +++++++++++++++++++++++++++ server/api/service.go | 12 +++++- 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 server/api/rate_limit.go create mode 100644 server/api/rate_limit_test.go diff --git a/multiplayer-todo.md b/multiplayer-todo.md index b0a9824c..0b30099e 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1184,7 +1184,7 @@ the local/CI/community transport, not a silent production fallback. | 8.9 `[D:8.4,8.7]` | **IN PROGRESS.** Pure Go join policy binds SteamID/player/match/server/team/slot/protocol/expiry, rejects duplicate roster slots, permits same-identity reclaim through backend loss and fences prior server-owned generations | `server/domain/reconnect.go` covers SteamID/server/slot binding, expiry, repeated reclaim, grace boundary and old-generation fencing; signed token issuance/verification, persistent leases, Godot `hello` transport and production integration remain | | 8.10 `[D:8.5,8.31]` | **IN PROGRESS.** Dependency-free workload credential policy validates an adapter-verified signature, issuer/audience/time bounds, namespace/service account, pod UID, GameServer UID, allocation ID, match ID and server ID before result submission | `server/domain/workload.go` and adversarial tests reject every binding mutation, missing/unverified signature and time boundary; `server/testkit/pipeline_test.go` carries allocation identity through the offline result path; projected-token/JWT adapter, trusted-cluster verification and live duplicate/conflict alerting remain | | 8.11 `[D:8.1]` | **DONE.** Write the threat model: forged clients/replay/queues/results, floods/bots, pod/insider compromise, gameplay and API DDoS, SDR signing-key theft, dependencies and denial-of-wallet | [`docs/THREAT-MODEL.md`](docs/THREAT-MODEL.md) records prevention, detection/response, owner and residual risk for every threat; it separates offline CA/online signer, client/game-server/PostgreSQL/Redis trust boundaries and recovery behavior | -| 8.12 `[D:8.11]` | **IN PROGRESS.** Provider-neutral Kubernetes baseline enforces restricted namespace admission, non-root/read-only/no-capability workloads, separate service accounts, allocator-only RBAC, default-deny networking and explicit edge/data/DNS/Agones flows; application manifests consume externally populated Secret objects | `deploy/k8s/base/` plus `server/security/test_kubernetes_policies.py` cover the static hardening and secret-reference invariants; private-store provisioning, edge DDoS/WAF/origin shielding, WebSocket limits, overload shedding, encrypted backups and live policy/load tests remain | +| 8.12 `[D:8.11]` | **IN PROGRESS.** Provider-neutral Kubernetes baseline enforces restricted namespace admission, non-root/read-only/no-capability workloads, separate service accounts, allocator-only RBAC, default-deny networking and explicit edge/data/DNS/Agones flows; application manifests consume externally populated Secret objects; the Go API now has an optional bounded per-replica rate-limit/429 boundary | `deploy/k8s/base/` plus `server/security/test_kubernetes_policies.py`, `server/api/rate_limit.go` and adversarial tests cover static hardening, secret-reference invariants, fixed-window limits and bounded key memory; private-store provisioning, distributed/global quotas, edge DDoS/WAF/origin shielding, WebSocket limits, overload shedding, encrypted backups and live policy/load tests remain | | 8.13 `[D:8.12]` | **IN PROGRESS.** Docker/Kubernetes references are digest-pinned, a dependency-free checker rejects mutable tags/plaintext credentials and concrete release overlays can reject template digests; release documentation defines SBOM, dependency/image scanning, signing, admission verification and a 24-hour critical-fix SLA | `scripts/verify_supply_chain.py`, `server/security/test_supply_chain.py`, `docs/SUPPLY-CHAIN.md` and `.github/workflows/supply-chain.yml` cover repository policy and provenance requirements; registry SBOM/scan/sign/admission execution and a concrete production overlay remain | #### 8C — Queueing, matchmaking, playlists and rating diff --git a/server/api/rate_limit.go b/server/api/rate_limit.go new file mode 100644 index 00000000..9a83a594 --- /dev/null +++ b/server/api/rate_limit.go @@ -0,0 +1,81 @@ +package api + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "net/http" + "strings" + "sync" + "time" +) + +// RateLimiter is an optional fixed-window limiter for the control-plane edge. +// It is intentionally process-local: a deployment must use a shared edge +// limiter for global quotas, while this boundary still protects each replica. +type RateLimiter struct { + mu sync.Mutex + limit int + window time.Duration + maxKeys int + entries map[string]rateWindow +} + +type rateWindow struct { + started time.Time + count int +} + +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") + } + return &RateLimiter{limit: limit, window: window, maxKeys: maxKeys, entries: make(map[string]rateWindow)}, nil +} + +func (l *RateLimiter) Allow(key string, now time.Time) bool { + if l == nil || key == "" || now.IsZero() { + return false + } + l.mu.Lock() + defer l.mu.Unlock() + for storedKey, entry := range l.entries { + if !now.Before(entry.started.Add(l.window)) { + delete(l.entries, storedKey) + } + } + entry, exists := l.entries[key] + if !exists { + if len(l.entries) >= l.maxKeys { + return false + } + l.entries[key] = rateWindow{started: now, count: 1} + return true + } + if !now.Before(entry.started.Add(l.window)) { + l.entries[key] = rateWindow{started: now, count: 1} + return true + } + if entry.count >= l.limit { + return false + } + entry.count++ + l.entries[key] = entry + return true +} + +func requestRateKey(r *http.Request) string { + if authorization := strings.TrimSpace(r.Header.Get("Authorization")); authorization != "" { + digest := sha256.Sum256([]byte(authorization)) + return "auth:" + hex.EncodeToString(digest[:]) + } + host := r.RemoteAddr + if parsedHost, _, err := net.SplitHostPort(host); err == nil { + host = parsedHost + } + if host == "" { + return "" + } + return "ip:" + host +} diff --git a/server/api/rate_limit_test.go b/server/api/rate_limit_test.go new file mode 100644 index 00000000..2d15a3c1 --- /dev/null +++ b/server/api/rate_limit_test.go @@ -0,0 +1,62 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestRateLimiterEnforcesWindowAndBoundsKeyMemory(t *testing.T) { + limiter, err := NewRateLimiter(2, time.Second, 1) + if err != nil { + t.Fatal(err) + } + start := time.Unix(1000, 0) + if !limiter.Allow("player-1", start) || !limiter.Allow("player-1", start.Add(100*time.Millisecond)) { + t.Fatal("allowed requests were rejected") + } + if limiter.Allow("player-1", start.Add(200*time.Millisecond)) { + t.Fatal("request over the window limit was accepted") + } + if limiter.Allow("player-2", start.Add(300*time.Millisecond)) { + t.Fatal("unbounded new key bypassed the memory bound") + } + if !limiter.Allow("player-1", start.Add(time.Second)) { + t.Fatal("window did not reset at the boundary") + } +} + +func TestRateLimitedHTTPBoundaryReturnsGeneric429(t *testing.T) { + limiter, err := NewRateLimiter(1, time.Minute, 8) + if err != nil { + t.Fatal(err) + } + service := &Service{RateLimiter: limiter, Now: func() time.Time { return time.Unix(1000, 0) }} + server := httptest.NewServer(service.Handler()) + defer server.Close() + request, err := http.NewRequest(http.MethodGet, server.URL+"/healthz", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer secret-session:secret-token") + response, err := server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("first request status = %d", response.StatusCode) + } + request, _ = http.NewRequest(http.MethodGet, server.URL+"/healthz", strings.NewReader("")) + request.Header.Set("Authorization", "Bearer secret-session:secret-token") + response, err = server.Client().Do(request) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusTooManyRequests { + t.Fatalf("limited request status = %d", response.StatusCode) + } +} diff --git a/server/api/service.go b/server/api/service.go index e2d1732f..ef97d68a 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -71,6 +71,7 @@ type Service struct { Proposals map[string]*domain.Proposal RankedProfiles map[string]domain.RankedProfile TierPolicy domain.TierPolicy + RateLimiter *RateLimiter proposalMu sync.Mutex eventsMu sync.Mutex events *eventHub @@ -96,7 +97,16 @@ func (s *Service) Handler() http.Handler { mux.HandleFunc("/api/v1/proposals/", s.contractProposalMutation) mux.HandleFunc("/api/v1/assignments/", s.contractAssignment) mux.HandleFunc("/api/v1/events", s.controlPlaneEvent) - return mux + if s.RateLimiter == nil { + return mux + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.RateLimiter.Allow(requestRateKey(r), s.now()) { + writeError(w, http.StatusTooManyRequests, "rate_limited") + return + } + mux.ServeHTTP(w, r) + }) } type steamSessionRequest struct {