All checks were successful
check / check (push) Successful in 2m54s
With TRUSTED_PROXIES empty behind the reverse proxy production is required to run behind, every login POST keyed on the proxy's address and shared one 5/minute bucket. A stranger sending five POSTs a minute -- 0.08 requests per second, from anywhere -- kept that bucket permanently full, and the operator's own correct password was answered 429 indefinitely with no second administrative path. The login POST no longer has a pre-emptive limiter. The handler verifies credentials first and spends budget only on a FAILED attempt, so a correct password is never throttled whatever the counters hold. Three things follow, and are implemented together because the first is unsafe without the other two: - Failures are counted per (client bucket, submitted username), five per minute, after which further failures get 429 with a Retry-After. A successful login clears the counter, so mistyping and then succeeding does not leave the operator throttled. - Both key sets are capped at 1024 entries. The submitted username is attacker-controlled, so past the first cap failures fall back to a counter keyed on the client alone, and past both caps a failure is answered as throttled without being recorded. Tracked state stays under half a megabyte and does not grow with invented usernames. - Concurrent Argon2id verifications are capped at two, a 128 MB ceiling at 64 MB per hash. Every password-hashing endpoint takes a slot, including the password-change endpoint, which holds one across both its hashes. A request that waits five seconds without a slot is answered 503 and no hash runs for it. An unknown username is verified against a dummy hash instead of returning early, so a nonexistent account costs the same time as a real one and the response cannot be used to enumerate usernames. The password-change limiter is unchanged: RequireAuth runs ahead of it, so only a request already carrying a valid session reaches its bucket. Also adds the missing test for the third bucketKey call site, where the peer is a trusted proxy but the forwarded chain names no client. Every existing test of that fallback uses an IPv4 proxy, where bucketKey is the identity function, so dropping the /64 masking there left the suite green. README and the TRUSTED_PROXIES startup warning updated: a shared bucket now costs precision, not the availability of the admin path.
353 lines
8.8 KiB
Go
353 lines
8.8 KiB
Go
package middleware_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/middleware"
|
|
)
|
|
|
|
const (
|
|
// guardInterval is the failure window these tests use. It is
|
|
// long enough that nothing lapses mid-test on its own; tests
|
|
// that need a lapse drive the clock instead.
|
|
guardInterval = time.Minute
|
|
|
|
// guardWait is the slot wait for tests that expect to get a
|
|
// slot. Tests that expect to be refused set their own.
|
|
guardWait = 2 * time.Second
|
|
|
|
guardClient = "198.51.100.7"
|
|
guardUser = "admin"
|
|
)
|
|
|
|
// newGuard builds a guard with production-shaped defaults and the
|
|
// given key-set cap and verification concurrency.
|
|
func newGuard(maxKeys, concurrency int) *middleware.LoginGuard {
|
|
return middleware.NewLoginGuardForTest(
|
|
middleware.LoginRateLimitConst,
|
|
guardInterval,
|
|
maxKeys,
|
|
concurrency,
|
|
guardWait,
|
|
)
|
|
}
|
|
|
|
// TestLoginGuard_ThrottlesRepeatedFailures is the brute-force half:
|
|
// wrong passwords for one username from one client key still run out
|
|
// of budget and are answered 429.
|
|
func TestLoginGuard_ThrottlesRepeatedFailures(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
g := newGuard(middleware.LoginFailureMaxKeysConst, 1)
|
|
|
|
for i := range middleware.LoginRateLimitConst - 1 {
|
|
assert.False(
|
|
t, g.FailForTest(guardClient, guardUser),
|
|
"failure %d is still inside the budget", i,
|
|
)
|
|
}
|
|
|
|
assert.True(
|
|
t, g.FailForTest(guardClient, guardUser),
|
|
"the last failure of the budget must throttle",
|
|
)
|
|
assert.True(
|
|
t, g.FailForTest(guardClient, guardUser),
|
|
"failures past the budget must stay throttled",
|
|
)
|
|
}
|
|
|
|
// TestLoginGuard_SuccessForgivesFailures pins the forgiveness rule:
|
|
// an operator who mistypes several times and then gets it right must
|
|
// not be left throttled.
|
|
func TestLoginGuard_SuccessForgivesFailures(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
g := newGuard(middleware.LoginFailureMaxKeysConst, 1)
|
|
|
|
for range middleware.LoginRateLimitConst {
|
|
g.FailForTest(guardClient, guardUser)
|
|
}
|
|
|
|
g.SucceedForTest(guardClient, guardUser)
|
|
|
|
assert.False(
|
|
t, g.FailForTest(guardClient, guardUser),
|
|
"a success must reset the counter, so the next mistake "+
|
|
"starts a fresh budget",
|
|
)
|
|
}
|
|
|
|
// TestLoginGuard_FailuresAreKeyedPerUsername proves the second half
|
|
// of the keying: one username's spent budget does not throttle
|
|
// another's from the same client.
|
|
func TestLoginGuard_FailuresAreKeyedPerUsername(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
g := newGuard(middleware.LoginFailureMaxKeysConst, 1)
|
|
|
|
for range middleware.LoginRateLimitConst {
|
|
g.FailForTest(guardClient, guardUser)
|
|
}
|
|
|
|
assert.True(t, g.FailForTest(guardClient, guardUser))
|
|
assert.False(
|
|
t, g.FailForTest(guardClient, "someone-else"),
|
|
"a different submitted username must have its own budget",
|
|
)
|
|
}
|
|
|
|
// TestLoginGuard_WindowLapses covers the interval: a counter that has
|
|
// gone quiet for the whole window starts again from zero.
|
|
func TestLoginGuard_WindowLapses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
g := newGuard(middleware.LoginFailureMaxKeysConst, 1)
|
|
|
|
var now atomic.Int64
|
|
|
|
now.Store(time.Now().UnixNano())
|
|
g.SetNowForTest(func() time.Time {
|
|
return time.Unix(0, now.Load())
|
|
})
|
|
|
|
for range middleware.LoginRateLimitConst {
|
|
g.FailForTest(guardClient, guardUser)
|
|
}
|
|
|
|
assert.True(t, g.FailForTest(guardClient, guardUser))
|
|
|
|
now.Add(int64(guardInterval) + 1)
|
|
|
|
assert.False(
|
|
t, g.FailForTest(guardClient, guardUser),
|
|
"a lapsed window must start a fresh budget",
|
|
)
|
|
}
|
|
|
|
// TestLoginGuard_UsernameKeySetIsBounded is the memory bound. The
|
|
// submitted username is attacker-controlled, so an attacker rotating
|
|
// usernames must not be able to grow the guard without limit: past
|
|
// the cap, tracking falls back to a counter keyed on the client
|
|
// address alone.
|
|
func TestLoginGuard_UsernameKeySetIsBounded(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
maxKeys = 8
|
|
attempts = 500
|
|
)
|
|
|
|
g := newGuard(maxKeys, 1)
|
|
|
|
for i := range attempts {
|
|
g.FailForTest(guardClient, fmt.Sprintf("user-%d", i))
|
|
}
|
|
|
|
byUser, byAddr := g.TrackedKeysForTest()
|
|
|
|
assert.LessOrEqual(
|
|
t, byUser, maxKeys,
|
|
"the per-username key set must not grow past its cap",
|
|
)
|
|
assert.LessOrEqual(
|
|
t, byAddr, maxKeys,
|
|
"the fallback key set must not grow past its cap either",
|
|
)
|
|
assert.Positive(
|
|
t, byAddr,
|
|
"past the cap, failures must fall back to the address "+
|
|
"bucket rather than being dropped",
|
|
)
|
|
assert.Less(
|
|
t, byUser+byAddr, attempts,
|
|
"memory must not grow with the number of distinct "+
|
|
"usernames submitted",
|
|
)
|
|
}
|
|
|
|
// TestLoginGuard_BeyondBothCapsStaysThrottled covers the hard stop.
|
|
// When both key sets are full of live counters and the client is in
|
|
// neither, there is nothing to count without unbounded growth, so the
|
|
// failure is answered as throttled. That costs the operator nothing:
|
|
// a correct password never reaches this path.
|
|
func TestLoginGuard_BeyondBothCapsStaysThrottled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const maxKeys = 4
|
|
|
|
g := newGuard(maxKeys, 1)
|
|
|
|
// Fill the per-username set from one client, then fill the
|
|
// address set from distinct clients.
|
|
for i := range maxKeys {
|
|
g.FailForTest(guardClient, fmt.Sprintf("user-%d", i))
|
|
}
|
|
|
|
for i := range maxKeys {
|
|
g.FailForTest(fmt.Sprintf("203.0.113.%d", i), "whoever")
|
|
}
|
|
|
|
assert.True(
|
|
t, g.FailForTest("203.0.113.200", "brand-new"),
|
|
"a client that fits in neither full key set must be "+
|
|
"answered as throttled rather than tracked",
|
|
)
|
|
|
|
byUser, byAddr := g.TrackedKeysForTest()
|
|
assert.LessOrEqual(t, byUser, maxKeys)
|
|
assert.LessOrEqual(t, byAddr, maxKeys)
|
|
}
|
|
|
|
// TestLoginGuard_SemaphoreBoundsConcurrentVerifications is the memory
|
|
// bound on the hashing itself. Verifying credentials before spending
|
|
// limiter budget means an attacker can force one Argon2id hash per
|
|
// request, and each allocates 64 MB; without this bound the fix for
|
|
// an admin lockout would be a memory-exhaustion DoS instead.
|
|
func TestLoginGuard_SemaphoreBoundsConcurrentVerifications(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
concurrency = 2
|
|
workers = 12
|
|
)
|
|
|
|
g := newGuard(middleware.LoginFailureMaxKeysConst, concurrency)
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
inside int
|
|
highest int
|
|
wg sync.WaitGroup
|
|
)
|
|
|
|
for range workers {
|
|
wg.Go(func() {
|
|
release, ok := g.AcquireForTest(context.Background())
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
defer release()
|
|
|
|
mu.Lock()
|
|
|
|
inside++
|
|
if inside > highest {
|
|
highest = inside
|
|
}
|
|
|
|
mu.Unlock()
|
|
|
|
// Hold the slot long enough that the other workers are
|
|
// certainly contending for it.
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
mu.Lock()
|
|
inside--
|
|
mu.Unlock()
|
|
})
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
assert.Equal(
|
|
t, concurrency, highest,
|
|
"no more than %d verifications may run at once", concurrency,
|
|
)
|
|
}
|
|
|
|
// TestLoginGuard_SaturatedSemaphoreRefusesRatherThanQueueing pins
|
|
// what happens when every slot is taken for longer than the wait: the
|
|
// request is refused, so the caller answers 503 without allocating
|
|
// another 64 MB hash.
|
|
func TestLoginGuard_SaturatedSemaphoreRefusesRatherThanQueueing(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
g := middleware.NewLoginGuardForTest(
|
|
middleware.LoginRateLimitConst,
|
|
guardInterval,
|
|
middleware.LoginFailureMaxKeysConst,
|
|
1,
|
|
10*time.Millisecond,
|
|
)
|
|
|
|
release, ok := g.AcquireForTest(context.Background())
|
|
require.True(t, ok, "the first acquire must get the only slot")
|
|
|
|
_, ok = g.AcquireForTest(context.Background())
|
|
assert.False(
|
|
t, ok,
|
|
"with the only slot held, a second request must be refused "+
|
|
"rather than wait indefinitely",
|
|
)
|
|
|
|
release()
|
|
|
|
release, ok = g.AcquireForTest(context.Background())
|
|
assert.True(
|
|
t, ok, "the slot must be reusable once released",
|
|
)
|
|
|
|
release()
|
|
}
|
|
|
|
// TestLoginGuard_AcquireHonoursCancellation proves a client that
|
|
// disconnects while queued frees its place immediately instead of
|
|
// holding it for the full wait.
|
|
func TestLoginGuard_AcquireHonoursCancellation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
g := newGuard(middleware.LoginFailureMaxKeysConst, 1)
|
|
|
|
release, ok := g.AcquireForTest(context.Background())
|
|
require.True(t, ok)
|
|
|
|
defer release()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, ok = g.AcquireForTest(ctx)
|
|
assert.False(
|
|
t, ok, "a cancelled request must not wait for a slot",
|
|
)
|
|
}
|
|
|
|
// TestPasswordVerifyConcurrency_MatchesMemoryBudget pins the
|
|
// concurrency constant to the arithmetic behind it: Argon2id here is
|
|
// 64 MB per hash, so the number of slots is the number of 64 MB
|
|
// allocations the process is willing to commit to password hashing.
|
|
// Raising it raises peak resident memory by 64 MB a slot.
|
|
func TestPasswordVerifyConcurrency_MatchesMemoryBudget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
argon2MemoryMB = 64
|
|
budgetMB = 128
|
|
)
|
|
|
|
assert.Equal(
|
|
t,
|
|
budgetMB/argon2MemoryMB,
|
|
middleware.PasswordVerifyConcurrencyConst,
|
|
"the verification concurrency is %d MB of Argon2id memory "+
|
|
"divided by %d MB per hash",
|
|
budgetMB, argon2MemoryMB,
|
|
)
|
|
}
|