Watch
1
0
Fork
You've already forked RedFlag
0

feat: rate limiter startup grace penalty (SEC-004)

The limiter is in-memory; a restart clears all counters, so an attacker
who can force one gets a fresh budget. For 60s after boot every limit
runs at half its configured budget (min 1), making a restart strictly
worse for the attacker while per-key limits keep reconnecting agents
comfortable. Restart semantics documented in OPERATIONS.md §6.
This commit is contained in:
Fimeg 2026-06-11 04:29:50 -04:00
commit 6c3461cdf9
3 changed files with 105 additions and 4 deletions

View file

@ -24,11 +24,19 @@ type RateLimitEntry struct {
// RateLimiter implements in-memory rate limiting with user-configurable settings
type RateLimiter struct {
entries sync.Map // map[string]*RateLimitEntry
configs map[string]RateLimitConfig
mutex sync.RWMutex
entries sync.Map // map[string]*RateLimitEntry
configs map[string]RateLimitConfig
mutex sync.RWMutex
startedAt time.Time
}
// startupGracePeriod is the post-boot window during which limits are halved
// (SEC-004). A restart clears the in-memory counters, so an attacker who can
// force one would otherwise get a fresh budget; the penalty makes a restart
// strictly worse for them. Per-key limits (agent ID, IP) mean legitimate
// agents reconnecting after a restart still fit comfortably in half budget.
const startupGracePeriod = 60 * time.Second
// RateLimitSettings holds all user-configurable rate limit settings
type RateLimitSettings struct {
AgentRegistration RateLimitConfig `json:"agent_registration"`
@ -78,7 +86,8 @@ func DefaultRateLimitSettings() RateLimitSettings {
// NewRateLimiter creates a new rate limiter with default settings
func NewRateLimiter() *RateLimiter {
rl := &RateLimiter{
entries: sync.Map{},
entries: sync.Map{},
startedAt: time.Now(),
}
// Load default settings
@ -130,6 +139,15 @@ func (rl *RateLimiter) RateLimit(limitType string, keyFunc func(*gin.Context) st
return
}
// SEC-004: during the post-boot grace window, run at half budget so a
// forced restart cannot be used to reset counters profitably.
if time.Since(rl.startedAt) < startupGracePeriod {
config.Requests = config.Requests / 2
if config.Requests < 1 {
config.Requests = 1
}
}
key := keyFunc(c)
if key == "" {
c.Next()

View file

@ -0,0 +1,59 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
// fireRequests sends n requests through the limiter and returns how many were
// allowed (non-429).
func fireRequests(t *testing.T, rl *RateLimiter, n int) int {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/x", rl.RateLimit("public_access", KeyByIP), func(c *gin.Context) {
c.Status(http.StatusOK)
})
allowed := 0
for i := 0; i < n; i++ {
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.RemoteAddr = "203.0.113.7:1234"
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusTooManyRequests {
allowed++
}
}
return allowed
}
// TestStartupGraceHalvesBudget locks in SEC-004: within the post-boot grace
// window the limiter runs at half the configured budget, so a forced restart
// cannot profitably reset counters.
func TestStartupGraceHalvesBudget(t *testing.T) {
rl := NewRateLimiter() // startedAt = now → inside grace window
full := DefaultRateLimitSettings().PublicAccess.Requests
allowed := fireRequests(t, rl, full)
if want := full / 2; allowed != want {
t.Fatalf("during grace window: allowed %d of %d requests, want %d (half budget)", allowed, full, want)
}
}
// TestFullBudgetAfterGrace verifies the limiter returns to the configured
// budget once the grace window has passed.
func TestFullBudgetAfterGrace(t *testing.T) {
rl := NewRateLimiter()
rl.startedAt = time.Now().Add(-2 * startupGracePeriod) // grace window elapsed
full := DefaultRateLimitSettings().PublicAccess.Requests
allowed := fireRequests(t, rl, full+5)
if allowed != full {
t.Fatalf("after grace window: allowed %d requests, want full budget %d", allowed, full)
}
}