Admin key endpoint: per-IP rate limiting and lockout (#66) #73

Merged
poslop merged 1 commits from issue-66-admin-ratelimit into main 2026-09-09 14:26:14 +00:00
3 changed files with 91 additions and 0 deletions
+5
View File
@@ -172,6 +172,11 @@ func (a *apiServer) adminKeyOK(r *http.Request, key string) bool {
func (a *apiServer) adminAuth(next http.HandlerFunc, key string) http.HandlerFunc { func (a *apiServer) adminAuth(next http.HandlerFunc, key string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if !rateLimitAdmin(r) {
log.Printf("admin auth RATE LIMITED: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
writeRateLimited(w, 60)
return
}
if !a.adminKeyOK(r, key) { if !a.adminKeyOK(r, key) {
log.Printf("admin auth FAILURE: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) log.Printf("admin auth FAILURE: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
writeErr(w, 401, "unauthorized") writeErr(w, 401, "unauthorized")
+80
View File
@@ -0,0 +1,80 @@
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")
}
}
+6
View File
@@ -84,6 +84,12 @@ func rateLimitUnlock(id string, r *http.Request) bool {
return globalLimiter.allow("unlock:"+id+":"+clientIP(r), 5.0/60.0, 5) return globalLimiter.allow("unlock:"+id+":"+clientIP(r), 5.0/60.0, 5)
} }
// rateLimitAdmin: 5 attempts per minute per IP on the admin key check (#66),
// same pattern as the unlock limiter (#34).
func rateLimitAdmin(r *http.Request) bool {
return globalLimiter.allow("admin:"+clientIP(r), 5.0/60.0, 5)
}
// writeRateLimited responds 429 with Retry-After based on refill rate. // writeRateLimited responds 429 with Retry-After based on refill rate.
func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) { func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
w.Header().Set("Retry-After", strconv.Itoa(retryAfterSecs)) w.Header().Set("Retry-After", strconv.Itoa(retryAfterSecs))