Files
palette/internal/api/adminratelimit_test.go
T
palette-agent 9811191648
CI / test (pull_request) Successful in 23s
CI / docker (pull_request) Skipped
admin: per-IP rate limit (5/min) on admin key attempts (#66)
2026-09-09 09:15:01 -05:00

81 lines
2.5 KiB
Go

package api
// #66: admin key attempts must be rate limited per IP (5/min), constant-time
// compared, and failures logged. Hammering bad keys must yield 429s.
import (
"net/http/httptest"
"strings"
"testing"
)
// TestAdminKeyRateLimited: burst of 5 bad-key attempts allowed (401), the 6th
// gets 429, and even the correct key is blocked from that IP until refill.
func TestAdminKeyRateLimited(t *testing.T) {
srv := newTestServer(t)
h := srv.routes()
reqIP := "10.7.7.1:1234"
var got429, retryAfter bool
var lastCode int
for i := 0; i < 10; i++ {
req := httptest.NewRequest("POST", "/admin/api/settings", nil)
req.RemoteAddr = reqIP
req.Header.Set("X-Admin-Key", "wrong-key")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
lastCode = rec.Code
if rec.Code == 429 {
got429 = true
retryAfter = rec.Header().Get("Retry-After") != ""
break
}
}
if !got429 {
t.Fatalf("expected 429 after hammering bad keys, last status %d", lastCode)
}
if !retryAfter {
t.Error("429 missing Retry-After header")
}
// Correct key from the same IP is also locked out.
req := httptest.NewRequest("POST", "/admin/api/settings", nil)
req.RemoteAddr = reqIP
req.Header.Set("X-Admin-Key", srv.adminKey)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 429 {
t.Errorf("correct key after lockout: got %d, want 429", rec.Code)
}
// A different IP is unaffected.
req2 := httptest.NewRequest("POST", "/admin/api/settings", strings.NewReader(`{"rate_limit_burst":5,"rate_limit_per_minute":60,"max_content_bytes":1048576,"custom_slug_reservation_days":30,"burn_viewer_window_minutes":15}`))
req2.RemoteAddr = "203.0.113.9:1234"
req2.Header.Set("X-Admin-Key", srv.adminKey)
rec2 := httptest.NewRecorder()
h.ServeHTTP(rec2, req2)
if rec2.Code != 200 {
t.Errorf("correct key from another IP: got %d, want 200", rec2.Code)
}
}
// TestAdminKeyConstantTimeCompare: sanity check that the comparison is
// constant-time (uses subtle.ConstantTimeCompare, not ==).
func TestAdminKeyConstantTimeCompare(t *testing.T) {
srv := newTestServer(t)
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("X-Admin-Key", "test-admin-key")
if !srv.adminKeyOK(r, srv.adminKey) {
t.Fatal("correct key rejected")
}
r.Header.Set("X-Admin-Key", "wrong")
if srv.adminKeyOK(r, srv.adminKey) {
t.Fatal("wrong key accepted")
}
// differ in length: must not panic/mismatch unexpectedly
r.Header.Set("X-Admin-Key", "test-admin-key-longer")
if srv.adminKeyOK(r, srv.adminKey) {
t.Fatal("longer wrong key accepted")
}
}