1 parent
cc7466823b
commit
e8c5b414d9
44 files changed
+7649
-344
No files matched your search
@@ -0,0 +1,356 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user