Files
fancy-dns/internal/api/ratelimit_test.go
T
developer e8c5b414d9
build / build-push (push) Failing after 23s
half broken
2026-09-27 17:17:53 +00:00

357 lines
14 KiB
Go

package api
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// rateLimited is the config knob every test in this file turns: the per-minute
// request budget and the per-IP failure lockout are the same struct.
func rateLimited(c *config.Config, perMinute, failures int, lockout time.Duration) {
modeAPIKey(c)
c.API.RateLimit = config.AuthRate{PerMinute: perMinute, Failures: failures, Lockout: config.Duration(lockout)}
}
// behindProxy is the X-Forwarded-For header that makes a request look like it
// came from ip. The harness's own connections come from 127.0.0.1, so a test
// that wants two distinct clients has to trust the loopback proxy.
func behindProxy(ip string) string { return "X-Forwarded-For: " + ip }
// badLogin posts a login with a key that is not configured. It is what a
// brute-force attempt looks like from the server's side.
func badLogin(h *harness, header string) response {
return h.do(http.MethodPost, "/api/auth/login", `{"api_key":"nope"}`, header)
}
// goodLogin posts a login with the key the harness was configured with.
func goodLogin(h *harness, header string) response {
return h.do(http.MethodPost, "/api/auth/login", `{"api_key":"rw-key"}`, header)
}
// TestLoginBruteForceEventuallyHitsTheRateLimiter is the brute-force case, and
// the reason api.rate_limit exists at all: hammering the login endpoint with
// guessed keys must eventually be refused with 429 rather than answered with an
// unlimited stream of 401s.
//
// The per_minute limit is documented as "requests per minute per IP for the
// login endpoint", and the login route is registered through the identity
// wrapper, so nothing used to ever count these requests.
func TestLoginBruteForceEventuallyHitsTheRateLimiter(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
// A generous per-minute budget so the lockout, not the request
// count, is what stops the attack.
rateLimited(c, 1000, 3, 5*time.Minute)
})
var locked response
for i := 0; i < 20; i++ {
res := badLogin(h, "")
if res.status == http.StatusUnauthorized {
continue
}
locked = res
break
}
if locked.status != http.StatusTooManyRequests {
t.Fatalf("20 bad logins from one IP never hit the rate limit: last status %d, want 429: %s",
locked.status, locked.body)
}
if locked.header.Get("Retry-After") == "" {
t.Error("429 from the login limiter carries no Retry-After header")
}
// A *correct* key during the lockout must be refused too. Otherwise the
// lockout is only an obstacle to the attacker and the operator whose key
// was typo'd gets in anyway.
res := goodLogin(h, "")
if res.status != http.StatusTooManyRequests {
t.Fatalf("a correct key during the lockout = %d, want 429: %s", res.status, res.body)
}
if _, ok := res.json(t)["token"]; ok {
t.Error("the lockout still issued a session token")
}
// The lockout is not a login-only affair in this design: the same per-IP
// state gates the rest of the API, so a brute-force attempt from a host
// that also runs the console cannot keep hammering other routes either.
if res := h.get("/api/whoami"); res.status != http.StatusTooManyRequests {
t.Errorf("an authed route during the lockout = %d, want 429: %s", res.status, res.body)
}
}
// TestLoginLockoutExpiresWithTheClock checks the lockout is temporary: once the
// configured lockout has passed, the same IP is served again. The clock is the
// server's, so the test advances it instead of sleeping.
func TestLoginLockoutExpiresWithTheClock(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
rateLimited(c, 1000, 3, 5*time.Minute)
})
base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
now := base
h.srv.now = func() time.Time { return now }
for i := 0; i < 3; i++ {
if res := badLogin(h, ""); res.status != http.StatusUnauthorized {
t.Fatalf("bad login %d = %d, want 401: %s", i+1, res.status, res.body)
}
}
// Still inside the lockout window.
now = base.Add(5*time.Minute - time.Second)
if res := goodLogin(h, ""); res.status != http.StatusTooManyRequests {
t.Fatalf("one second before the lockout expires = %d, want 429: %s", res.status, res.body)
}
// Past it.
now = base.Add(5*time.Minute + time.Second)
res := goodLogin(h, "")
if res.status != http.StatusOK {
t.Fatalf("login after the lockout expired = %d, want 200: %s", res.status, res.body)
}
if _, ok := res.json(t)["token"]; !ok {
t.Error("login after the lockout expired returned no token")
}
}
// TestLoginLockoutIsPerIP checks one attacker cannot lock out everyone else
// behind the same proxy: the state is keyed on the forwarded client address,
// which is only honoured from a trusted proxy.
func TestLoginLockoutIsPerIP(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
rateLimited(c, 1000, 3, 5*time.Minute)
c.API.TrustedProxies = []string{"127.0.0.0/8"}
})
const attacker = "198.51.100.7"
for i := 0; i < 10; i++ {
if res := badLogin(h, behindProxy(attacker)); res.status == http.StatusOK {
t.Fatalf("bad login %d was accepted: %s", i+1, res.body)
}
}
if res := badLogin(h, behindProxy(attacker)); res.status != http.StatusTooManyRequests {
t.Fatalf("the attacker was never locked out: %d: %s", res.status, res.body)
}
// A different client behind the same proxy must be untouched.
res := goodLogin(h, behindProxy("203.0.113.9"))
if res.status != http.StatusOK {
t.Fatalf("a second client behind the same proxy = %d, want 200: %s", res.status, res.body)
}
// And so must the proxy's own address (no X-Forwarded-For at all).
if res := goodLogin(h, ""); res.status != http.StatusOK {
t.Fatalf("the proxy address itself = %d, want 200: %s", res.status, res.body)
}
}
// TestLoginLockoutDisabledWithoutFailures pins the config side: failures <= 0
// turns the lockout off, so an operator who has not opted in cannot lock
// themselves out of the console.
func TestLoginLockoutDisabledWithoutFailures(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
rateLimited(c, 0, 0, 5*time.Minute)
})
for i := 0; i < 25; i++ {
if res := badLogin(h, ""); res.status != http.StatusUnauthorized {
t.Fatalf("bad login %d = %d, want 401: no lockout is configured: %s", i+1, res.status, res.body)
}
}
if res := goodLogin(h, ""); res.status != http.StatusOK {
t.Fatalf("login after 25 failures = %d, want 200: %s", res.status, res.body)
}
}
// TestSuccessfulLoginClearsTheFailureCounter checks a success resets the
// failure counter: an operator who fat-fingers the password three times, waits
// out the lockout and then logs in correctly must not be one typo away from
// another lockout.
func TestSuccessfulLoginClearsTheFailureCounter(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
rateLimited(c, 1000, 3, time.Minute)
})
base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
now := base
h.srv.now = func() time.Time { return now }
for i := 0; i < 3; i++ {
badLogin(h, "")
}
now = base.Add(2 * time.Minute) // past the one-minute lockout
if res := goodLogin(h, ""); res.status != http.StatusOK {
t.Fatalf("login after the lockout = %d, want 200: %s", res.status, res.body)
}
// The counter started again: without the reset the first post-success
// failure would already be locked out. Three more failures are still
// answered 401 and the fourth re-arms the lockout, which is exactly what
// "the counter restarted" means.
for i := 0; i < 3; i++ {
if res := badLogin(h, ""); res.status != http.StatusUnauthorized {
t.Fatalf("post-success failure %d = %d, want 401: the counter was not reset", i+1, res.status)
}
}
if res := badLogin(h, ""); res.status != http.StatusTooManyRequests {
t.Errorf("the fourth post-success failure = %d, want 429: %s", res.status, res.body)
}
}
// TestSuccessfulLoginDoesNotResetTheRequestWindow pins a deliberate separation:
// a successful login clears the *failure* state, but it must not hand the
// client a fresh per-minute request budget. clearLoginFailures used to delete
// the whole per-IP state, so one successful login in the middle of a burst
// wiped the rate-limit window too — an attacker holding one valid key could use
// it to make brute force free, and the fix is to only reset what the login
// knows about.
func TestSuccessfulLoginDoesNotResetTheRequestWindow(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
// Four requests a minute and no lockout at all, so the window is the
// only thing that can refuse: three failures, then a correct key,
// then the budget is spent.
rateLimited(c, 4, 0, 5*time.Minute)
})
for i := 0; i < 3; i++ {
if res := badLogin(h, ""); res.status != http.StatusUnauthorized {
t.Fatalf("bad login %d = %d, want 401: %s", i+1, res.status, res.body)
}
}
if res := goodLogin(h, ""); res.status != http.StatusOK {
t.Fatalf("the correct key = %d, want 200: %s", res.status, res.body)
}
res := badLogin(h, "")
if res.status != http.StatusTooManyRequests {
t.Fatalf("after four requests in the window = %d, want 429: a successful login must not reset the window: %s",
res.status, res.body)
}
}
// TestAuthedRouteRateLimit checks the per-minute budget on the authenticated
// routes, which is where allowRequest has always been wired: N+1 requests in a
// window get 429 with a Retry-After, and the Nth is still served.
func TestAuthedRouteRateLimit(t *testing.T) {
h := newHarness(t, func(c *config.Config) { rateLimited(c, 5, 0, 5*time.Minute) })
for i := 0; i < 5; i++ {
if res := h.get("/api/whoami"); res.status != http.StatusOK {
t.Fatalf("request %d = %d, want 200: %s", i+1, res.status, res.body)
}
}
res := h.get("/api/whoami")
if res.status != http.StatusTooManyRequests {
t.Fatalf("the 6th request in a minute = %d, want 429: %s", res.status, res.body)
}
if got := res.header.Get("Retry-After"); got == "" {
t.Error("429 from the per-minute limit carries no Retry-After header")
}
// The refused request must not have reached the handler.
if h.fake.count("Status") != 0 {
t.Error("a rate-limited request still reached a hook")
}
}
// TestAuthedRouteRateLimitWindowResets checks the window is a minute wide and
// is driven by the server's clock, not by a timer that has to expire in real
// time.
func TestAuthedRouteRateLimitWindowResets(t *testing.T) {
h := newHarness(t, func(c *config.Config) { rateLimited(c, 3, 0, 5*time.Minute) })
base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
now := base
h.srv.now = func() time.Time { return now }
for i := 0; i < 3; i++ {
if res := h.get("/api/whoami"); res.status != http.StatusOK {
t.Fatalf("request %d = %d, want 200: %s", i+1, res.status, res.body)
}
}
if res := h.get("/api/whoami"); res.status != http.StatusTooManyRequests {
t.Fatalf("the 4th request = %d, want 429: %s", res.status, res.body)
}
// One second before the window rolls over is still inside it.
now = base.Add(time.Minute - time.Second)
if res := h.get("/api/whoami"); res.status != http.StatusTooManyRequests {
t.Fatalf("one second before the window rolls = %d, want 429: %s", res.status, res.body)
}
now = base.Add(time.Minute)
if res := h.get("/api/whoami"); res.status != http.StatusOK {
t.Fatalf("first request in the next window = %d, want 200: %s", res.status, res.body)
}
}
// TestAuthedRouteRateLimitIsPerIP checks the budget is per client address, so
// one noisy operator cannot lock out the rest of the console's users.
func TestAuthedRouteRateLimitIsPerIP(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
rateLimited(c, 2, 0, 5*time.Minute)
c.API.TrustedProxies = []string{"127.0.0.0/8"}
})
for i := 0; i < 3; i++ {
res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", behindProxy("198.51.100.1"))
want := http.StatusOK
if i == 2 {
want = http.StatusTooManyRequests
}
if res.status != want {
t.Fatalf("request %d from the noisy client = %d, want %d: %s", i+1, res.status, want, res.body)
}
}
if res := h.do(http.MethodGet, "/api/whoami", "", "X-API-Key: rw-key", behindProxy("203.0.113.9")); res.status != http.StatusOK {
t.Fatalf("a different client = %d, want 200: %s", res.status, res.body)
}
}
// TestAuthedRouteRateLimitDisabledWhenNotConfigured pins the default: with
// rate_limit unset (which is what a config that never mentions it gets) the
// authenticated routes are not limited at all.
func TestAuthedRouteRateLimitDisabledWhenNotConfigured(t *testing.T) {
h := newHarness(t, nil) // testConfig leaves RateLimit zero
for i := 0; i < 20; i++ {
if res := h.get("/api/whoami"); res.status != http.StatusOK {
t.Fatalf("request %d with no rate limit configured = %d, want 200: %s", i+1, res.status, res.body)
}
}
}
// TestRateLimitCleanupDropsStaleEntries exercises the opportunistic cleanup in
// allowRequest. There is no sweeper goroutine: the per-IP map only shrinks
// when a request comes in and finds it over the 4096-entry cap, so an attacker
// rotating source addresses is the case that matters, and an entry that has
// not been touched for ten minutes is the one that goes.
func TestRateLimitCleanupDropsStaleEntries(t *testing.T) {
h := newHarness(t, func(c *config.Config) {
// A budget no test can hit: this is about the map, not the counter.
rateLimited(c, 1_000_000, 0, time.Minute)
})
base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
now := base
h.srv.now = func() time.Time { return now }
// Fill the map past the cap with one request per synthetic client.
allow := func(ip string) {
r := httptest.NewRequest(http.MethodGet, "/api/whoami", nil)
r.RemoteAddr = ip + ":40000"
if !h.srv.allowRequest(httptest.NewRecorder(), r) {
t.Fatalf("client %s was rate limited", ip)
}
}
for i := 0; i < 4097; i++ {
allow(fmt.Sprintf("10.%d.%d.%d", i/65536, (i/256)%256, i%256))
}
if got := len(h.srv.limits); got <= 4096 {
t.Fatalf("the map holds %d entries, want more than the 4096 cap", got)
}
// Nothing is stale yet, so a request at the same instant must not shrink
// the map: the cap is a trigger, not a deadline.
allow("10.9.9.9")
if got := len(h.srv.limits); got <= 4096 {
t.Errorf("the cleanup dropped %d entries that were not stale", 4097+1-got)
}
// Past the ten-minute idle horizon every entry is stale, and the next
// request sweeps them.
now = base.Add(11 * time.Minute)
allow("10.9.9.8")
if got := len(h.srv.limits); got > 2 {
t.Errorf("after the idle horizon the map holds %d entries, want only the two live clients", got)
}
}