Files
mpc/pkg/api/ratelimit.go
T
zeekay 21da3d64e0 security: harden internal MPC API, enforce TLS-only transport, restrict egress
- Add bearer token auth to internal MPC API (port 9800): /keygen, /keys,
  /backup now require Authorization header. Token from MPC_INTERNAL_API_KEY
  env (KMS-synced) or derived from Ed25519 node identity.
- Replace DualModeListener with TLSOnlyListener: plaintext peer connections
  are rejected. TLS 1.3 with PQ key exchange is mandatory.
- Add dedicated 20 RPM rate limiter on bridge signing and intent signing
  endpoints (separate from global 100 RPM).
- Add body size limit (1MB), read/write timeouts, and 10 RPM rate limiter
  to internal MPC API server.
- Restrict K8s NetworkPolicy egress for MPC nodes to peers, postgres,
  valkey, DNS, and HTTPS only (was unrestricted).
- Wire cluster-api-key in api-deployment for authenticated proxy to
  internal MPC API. Forward as Authorization: Bearer header.
- Remove hardcoded dev JWT_SECRET default from compose.yml (now required).
- Add internal-api-key to KMSSecret CRD and StatefulSet env.
2026-04-07 14:00:47 -07:00

113 lines
2.8 KiB
Go

package api
import (
"net"
"net/http"
"sync"
"sync/atomic"
"time"
)
// visitor tracks request count for a single IP within a time window.
type visitor struct {
count atomic.Int64
lastSeen atomic.Int64 // unix seconds
}
// RateLimiter implements per-IP rate limiting using a sync.Map.
type RateLimiter struct {
visitors sync.Map // map[string]*visitor
limit int64
windowSecs int64
}
// NewRateLimiter creates a rate limiter with the given requests-per-minute limit.
// It starts a background goroutine that evicts stale entries every 60 seconds.
func NewRateLimiter(requestsPerMinute int) *RateLimiter {
rl := &RateLimiter{
limit: int64(requestsPerMinute),
windowSecs: 60,
}
go rl.cleanup()
return rl
}
// Allow returns true if the IP has not exceeded the rate limit.
// Exported for use by other packages (e.g., internal MPC API auth).
func (rl *RateLimiter) Allow(ip string) bool {
return rl.allow(ip)
}
// allow returns true if the IP has not exceeded the rate limit.
func (rl *RateLimiter) allow(ip string) bool {
now := time.Now().Unix()
val, loaded := rl.visitors.LoadOrStore(ip, &visitor{})
v := val.(*visitor)
if !loaded {
v.count.Store(1)
v.lastSeen.Store(now)
return true
}
last := v.lastSeen.Load()
if now-last >= rl.windowSecs {
// Window expired; reset.
v.count.Store(1)
v.lastSeen.Store(now)
return true
}
c := v.count.Add(1)
v.lastSeen.Store(now)
return c <= rl.limit
}
// cleanup removes entries not seen in the last 2 windows.
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for range ticker.C {
cutoff := time.Now().Unix() - 2*rl.windowSecs
rl.visitors.Range(func(key, val any) bool {
v := val.(*visitor)
if v.lastSeen.Load() < cutoff {
rl.visitors.Delete(key)
}
return true
})
}
}
// clientIP extracts the client IP from RemoteAddr.
// chimw.RealIP middleware already processes X-Forwarded-For and sets RemoteAddr.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// RateLimitMiddleware returns chi-compatible middleware that limits requests per
// IP to requestsPerMinute within a rolling 60-second window.
func RateLimitMiddleware(requestsPerMinute int) func(http.Handler) http.Handler {
rl := NewRateLimiter(requestsPerMinute)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Exempt health probes from rate limiting (K8s liveness/readiness).
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
ip := clientIP(r)
if !rl.allow(ip) {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
next.ServeHTTP(w, r)
})
}
}