M8: per-key UPDATE rate limiting (token bucket)
Hamilton M8: a compromised TSIG key — or a misconfigured client retrying forever — must not be able to drive unbounded UPDATE traffic. Each UPDATE costs disk IOPS, a git commit, and a slot in the SOA serial counter (now 9999/day per zone). Without a cap, a few hours of runaway traffic could exhaust the SOA serial counter and brick the zone for the day. Implementation: per-key token bucket in ratelimit.go. Default 100 tokens / 60 seconds. New keys start full so legitimate clients see no delay at boot. Refill is continuous, capped at the burst value. Configurable in Corefile: rate-limit off # disable entirely rate-limit <burst> <period-secs> # e.g., rate-limit 200 60 Enforcement runs in ServeDNS after TSIG verification — a request that fails auth doesn't consume a token (and a forged TSIG can't be used to deny service to a real key holder, since we never reached the rate check). 100/min is well above ACME's needs: a worst-case full-renewal storm across our ~84 zones emits maybe 200 UPDATEs total over several minutes. Anything beyond is suspicious by definition. New tests covering: first-call allowed, burst exhaustion, refill behavior, per-key isolation, refill-cap (no idle-accumulation overflow).
This commit is contained in:
parent
6ab2b6af6d
commit
8d1477350a
4 changed files with 222 additions and 0 deletions
83
ratelimit_test.go
Normal file
83
ratelimit_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package rfc2136
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRateLimiter_FirstCallAllowed(t *testing.T) {
|
||||
rl := newRateLimiter(5, time.Minute)
|
||||
now := time.Now()
|
||||
if !rl.allow("key-a", now) {
|
||||
t.Errorf("first call for new key must be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_BurstExhausts(t *testing.T) {
|
||||
rl := newRateLimiter(3, time.Minute)
|
||||
now := time.Now()
|
||||
// First 3 calls succeed.
|
||||
for i := 0; i < 3; i++ {
|
||||
if !rl.allow("key-a", now) {
|
||||
t.Fatalf("call %d should be allowed (burst=3)", i+1)
|
||||
}
|
||||
}
|
||||
// 4th immediately after burst should be denied (no time elapsed
|
||||
// for refill).
|
||||
if rl.allow("key-a", now) {
|
||||
t.Errorf("4th call exceeded burst; should be denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_RefillsOverTime(t *testing.T) {
|
||||
// burst=2, period=1s → refill rate is 2 tokens/sec.
|
||||
rl := newRateLimiter(2, time.Second)
|
||||
t0 := time.Now()
|
||||
if !rl.allow("k", t0) {
|
||||
t.Fatal("call 1")
|
||||
}
|
||||
if !rl.allow("k", t0) {
|
||||
t.Fatal("call 2")
|
||||
}
|
||||
if rl.allow("k", t0) {
|
||||
t.Fatal("call 3 should be denied; bucket empty")
|
||||
}
|
||||
// Advance time by 500ms — should refill ~1 token.
|
||||
if !rl.allow("k", t0.Add(500*time.Millisecond)) {
|
||||
t.Errorf("expected refill after 500ms")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_PerKeyIsolation(t *testing.T) {
|
||||
rl := newRateLimiter(2, time.Minute)
|
||||
now := time.Now()
|
||||
// Exhaust key-a.
|
||||
rl.allow("key-a", now)
|
||||
rl.allow("key-a", now)
|
||||
if rl.allow("key-a", now) {
|
||||
t.Fatal("key-a still has tokens; setup wrong")
|
||||
}
|
||||
// key-b is independent — must still be allowed.
|
||||
if !rl.allow("key-b", now) {
|
||||
t.Errorf("key-b was rate-limited despite no prior use")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiter_DoesNotOverflow guards against refill math
|
||||
// accumulating beyond burst (which would let an attacker burst more
|
||||
// after a long idle period than the configured cap).
|
||||
func TestRateLimiter_DoesNotOverflow(t *testing.T) {
|
||||
rl := newRateLimiter(5, time.Second)
|
||||
t0 := time.Now()
|
||||
rl.allow("k", t0) // create bucket
|
||||
// Advance time 1 hour. Refill should cap at burst=5.
|
||||
tFuture := t0.Add(time.Hour)
|
||||
for i := 0; i < 5; i++ {
|
||||
if !rl.allow("k", tFuture) {
|
||||
t.Fatalf("post-idle call %d should be allowed (cap=5)", i+1)
|
||||
}
|
||||
}
|
||||
if rl.allow("k", tFuture) {
|
||||
t.Errorf("post-idle call 6 should be denied; cap exceeded")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue