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:
parent
6ac937bfe5
commit
6c3461cdf9
3 changed files with 105 additions and 4 deletions
|
|
@ -148,3 +148,27 @@ The age gate in `block` enforcement is a separate full stop (still env-driven: `
|
||||||
- Package age gate: npm (registry.npmjs.org), PyPI (pypi.org)
|
- Package age gate: npm (registry.npmjs.org), PyPI (pypi.org)
|
||||||
|
|
||||||
**Monitoring:** Search server logs for `[SECURITY] [server] [supply_chain]` — `approval_blocked` (a gate stop), `bulk_approval_blocked`, and `gate_overridden` (an operator trusted the void). Overrides also surface in the events API as `supply_chain_override`. Upstream advisory failures stay at `[WARNING]`; grep `supply_chain` / `package_age` for chronic upstream unavailability.
|
**Monitoring:** Search server logs for `[SECURITY] [server] [supply_chain]` — `approval_blocked` (a gate stop), `bulk_approval_blocked`, and `gate_overridden` (an operator trusted the void). Overrides also surface in the events API as `supply_chain_override`. Upstream advisory failures stay at `[WARNING]`; grep `supply_chain` / `package_age` for chronic upstream unavailability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rate Limiter — In-Memory, Restart Semantics
|
||||||
|
|
||||||
|
The API rate limiter is **in-memory only** (per-key sliding windows in the server
|
||||||
|
process). A server restart clears all counters. This is a known, accepted
|
||||||
|
characteristic — there is no Redis/DB backing store.
|
||||||
|
|
||||||
|
**Restart penalty (SEC-004).** For the first 60 seconds after boot, every limit
|
||||||
|
runs at **half its configured budget** (minimum 1 request). This makes a forced
|
||||||
|
restart strictly worse for an attacker trying to reset their counters, while
|
||||||
|
legitimate agents — each rate-limited under its own key — reconnect comfortably
|
||||||
|
within half budget.
|
||||||
|
|
||||||
|
**Operational notes:**
|
||||||
|
- A burst of `429`s in the minute after a deploy/restart is the grace penalty
|
||||||
|
working, not a misconfiguration. It clears itself at T+60s.
|
||||||
|
- Limits are operator-tunable at runtime via `/api/v1/admin/rate-limits`; the
|
||||||
|
grace penalty halves whatever is configured at request time.
|
||||||
|
- If you see repeated unexplained server restarts combined with high request
|
||||||
|
volume from one source, treat it as a possible counter-reset attempt and
|
||||||
|
block at the firewall — the limiter alone cannot fully stop an attacker who
|
||||||
|
can crash the server.
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,19 @@ type RateLimitEntry struct {
|
||||||
|
|
||||||
// RateLimiter implements in-memory rate limiting with user-configurable settings
|
// RateLimiter implements in-memory rate limiting with user-configurable settings
|
||||||
type RateLimiter struct {
|
type RateLimiter struct {
|
||||||
entries sync.Map // map[string]*RateLimitEntry
|
entries sync.Map // map[string]*RateLimitEntry
|
||||||
configs map[string]RateLimitConfig
|
configs map[string]RateLimitConfig
|
||||||
mutex sync.RWMutex
|
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
|
// RateLimitSettings holds all user-configurable rate limit settings
|
||||||
type RateLimitSettings struct {
|
type RateLimitSettings struct {
|
||||||
AgentRegistration RateLimitConfig `json:"agent_registration"`
|
AgentRegistration RateLimitConfig `json:"agent_registration"`
|
||||||
|
|
@ -78,7 +86,8 @@ func DefaultRateLimitSettings() RateLimitSettings {
|
||||||
// NewRateLimiter creates a new rate limiter with default settings
|
// NewRateLimiter creates a new rate limiter with default settings
|
||||||
func NewRateLimiter() *RateLimiter {
|
func NewRateLimiter() *RateLimiter {
|
||||||
rl := &RateLimiter{
|
rl := &RateLimiter{
|
||||||
entries: sync.Map{},
|
entries: sync.Map{},
|
||||||
|
startedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load default settings
|
// Load default settings
|
||||||
|
|
@ -130,6 +139,15 @@ func (rl *RateLimiter) RateLimit(limitType string, keyFunc func(*gin.Context) st
|
||||||
return
|
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)
|
key := keyFunc(c)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
|
||||||
59
server/internal/api/middleware/rate_limiter_grace_test.go
Normal file
59
server/internal/api/middleware/rate_limiter_grace_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue