feat: SOC ghost sinkhole, rate limiter, RBAC, demo seed

This commit is contained in:
DmitrL-dev 2026-03-27 12:45:11 +10:00
parent cc7956d835
commit b8097d3f1b
19 changed files with 1169 additions and 63 deletions

View file

@ -2,6 +2,7 @@ package httpserver
import (
"context"
"fmt"
"net"
"net/http"
"sync"
@ -9,20 +10,28 @@ import (
)
// RateLimiter provides per-IP sliding window rate limiting (§17.3).
// Supports burst tolerance (soft/hard limits) and standard X-RateLimit headers.
type RateLimiter struct {
mu sync.RWMutex
windows map[string][]time.Time
limit int // max requests per window
limit int // max requests per window (soft limit)
burst int // burst tolerance (hard limit = limit + burst)
window time.Duration // window size
enabled bool
}
// NewRateLimiter creates a rate limiter. Set limit=0 to disable.
// Burst is set to 20% of limit (allows short spikes before hard-dropping).
// The cleanup goroutine stops when ctx is cancelled (T4-6).
func NewRateLimiter(ctx context.Context, limit int, window time.Duration) *RateLimiter {
burst := limit / 5 // 20% burst tolerance
if burst < 5 {
burst = 5
}
rl := &RateLimiter{
windows: make(map[string][]time.Time),
limit: limit,
burst: burst,
window: window,
enabled: limit > 0,
}
@ -31,7 +40,8 @@ func NewRateLimiter(ctx context.Context, limit int, window time.Duration) *RateL
return rl
}
// Allow checks if the IP is within limits. Returns true if allowed.
// Allow checks if the IP is within the hard limit (limit + burst).
// Returns true if allowed.
func (rl *RateLimiter) Allow(ip string) bool {
if !rl.enabled {
return true
@ -52,7 +62,8 @@ func (rl *RateLimiter) Allow(ip string) bool {
}
}
if len(valid) >= rl.limit {
hardLimit := rl.limit + rl.burst
if len(valid) >= hardLimit {
rl.windows[ip] = valid
return false
}
@ -61,9 +72,38 @@ func (rl *RateLimiter) Allow(ip string) bool {
return true
}
// RemainingAndReset returns the remaining requests within the soft limit
// and the time when the window resets for this IP.
func (rl *RateLimiter) RemainingAndReset(ip string) (remaining int, resetAt time.Time) {
rl.mu.RLock()
defer rl.mu.RUnlock()
now := time.Now()
cutoff := now.Add(-rl.window)
count := 0
earliestInWindow := now
for _, ts := range rl.windows[ip] {
if ts.After(cutoff) {
count++
if ts.Before(earliestInWindow) {
earliestInWindow = ts
}
}
}
remaining = rl.limit - count
if remaining < 0 {
remaining = 0
}
resetAt = earliestInWindow.Add(rl.window)
return
}
// Middleware wraps an HTTP handler with rate limiting.
// Certain paths are excluded to prevent battle/scan traffic from blocking
// dashboard access (auth, SSE stream, event ingestion).
// Emits standard X-RateLimit-* headers on every response for client visibility.
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !rl.enabled {
@ -78,26 +118,39 @@ func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
p == "/api/auth/refresh",
p == "/api/soc/stream",
p == "/api/v1/soc/events",
p == "/api/soc/events":
p == "/api/soc/events",
p == "/api/v1/scan",
p == "/api/scan":
next.ServeHTTP(w, r)
return
}
// T4-3 FIX: Use RemoteAddr directly to prevent X-Forwarded-For spoofing.
// When behind a trusted reverse proxy, configure the proxy to set
// X-Real-IP and strip external X-Forwarded-For headers.
ip := r.RemoteAddr
// Strip port from RemoteAddr (e.g. "192.168.1.1:12345" → "192.168.1.1")
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
if !rl.Allow(ip) {
w.Header().Set("Retry-After", "60")
_, resetAt := rl.RemainingAndReset(ip)
retryAfter := int(time.Until(resetAt).Seconds())
if retryAfter < 1 {
retryAfter = 1
}
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", rl.limit))
w.Header().Set("X-RateLimit-Remaining", "0")
w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", resetAt.Unix()))
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
// Emit X-RateLimit headers on successful requests
remaining, resetAt := rl.RemainingAndReset(ip)
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", rl.limit))
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining))
w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", resetAt.Unix()))
next.ServeHTTP(w, r)
})
}
@ -109,6 +162,8 @@ func (rl *RateLimiter) Stats() map[string]any {
return map[string]any{
"enabled": rl.enabled,
"limit": rl.limit,
"burst": rl.burst,
"hard_limit": rl.limit + rl.burst,
"window_sec": rl.window.Seconds(),
"tracked_ips": len(rl.windows),
}