Files
webhooker/internal/middleware/loginguard_test.go
clawbot f932a86e8d
All checks were successful
check / check (push) Successful in 2m50s
Stop a slow host turning a login-guard test into a segfault (closes #186)
CI run 232 failed
TestLoginGuard_SaturatedSemaphoreRefusesRatherThanQueueing and then
took the whole internal/middleware test binary down with a SIGSEGV, on
a commit whose own gates were green. acquire returns (nil, false) on
every refusal path, the assertion on ok was non-fatal, and the next
line called the nil release. Two defects sit behind that, and the
second one is not confined to the test.

acquire selected over a slot send and an already-armed wait timer. Go
picks among ready cases uniformly at random, so a process descheduled
for longer than the wait sheds a request with slots standing free —
under load, which is exactly when shedding a login is least
defensible. A non-blocking preamble now takes a free slot before any
timer is armed, the same shape lifecycle.waitDone uses to settle its
own both-ready race. It cannot let a late arrival barge past a queued
waiter: a waiter can only be parked on a full buffer, and a release
refills that buffer from the head of the send queue under the channel
lock, so the preamble's send fails whenever anyone is waiting. Placing
it ahead of the queue admission also stops a request that never waits
from occupying a waiter's place.

The preamble does not consult ctx, so an already-cancelled request
with a free slot is now granted it rather than refused about half the
time. That is deliberate and matches waitDone; acquire's doc said
otherwise and has been corrected to describe what the code does.

The test's third acquire is therefore settled by construction rather
than by the wait being long enough, and
TestLoginGuard_FreeSlotBeatsAnExpiredWait pins that: 1000 passes with
a wait already elapsed on arrival, the worst case scheduling can
produce. Without the preamble each pass can go the wrong way and the
loop fails within a few passes; measured on the reverted-preamble
mutation, pass 2.

Assertions whose value is dereferenced afterwards are require, not
assert. A sweep of every test file found one sibling of the same
shape: webhook_db_manager_test.go checked a slice length non-fatally
and indexed it on the next line, so the regression it guards would
have surfaced as an index-out-of-range panic through
internal/database rather than as a failing test.

TestLoginGuard_SemaphoreBoundsConcurrentVerifications used a 10 ms
sleep to make two workers overlap and asserted the observed maximum
was exactly two. A sleep only makes overlap likely; on a host that can
deschedule a goroutine for longer than the sleep the workers serialise
and the maximum comes back as one. The slot holders now rendezvous
instead, and the barrier opens only once every worker's acquire has
returned and any slot it won has been counted — not at the
concurrency-th holder, which would fix the lower bound while
destroying the upper one the test exists to enforce, since holders
would leave before an over-admitting guard's extra workers had been
observed. The test no longer sleeps at all.

The queue-cap test in the same file held the two tightest wall-clock
margins in the repo: it asserted a shed request returned in under
100 ms, timed across a goroutine hand-off, and gave the probe 200 ms
to return at all. Both bounded host latency rather than guard
behaviour. The elapsed-time assertion is gone, since a shed request is
told from a queued one by the queue depth, which is a state fact; the
probe's budget is now five seconds against a queue wait of a minute,
which only a guard that queues can exhaust. Mutating the queue
admission back to a blocking send still fails the test.
2026-08-18 02:29:56 +00:00

630 lines
18 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/database"
"sneak.berlin/go/webhooker/internal/middleware"
)
// mib converts the Argon2id memory parameter, which is in KiB, to MB.
const mib = 1024
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"
// racePasses is how many times a both-cases-ready select race is
// run. A pass can only go the wrong way once the zero-duration
// timer has fired, so the per-pass detection probability is
// somewhere below 1/2 rather than exactly it; the bound that
// matters is that passes are independent, so a regression that
// survives is exponentially unlikely in N. The test still waits
// on nothing.
racePasses = 1000
)
// 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,
middleware.PasswordVerifyMaxWaitersConst,
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
// rendezvousDeadlock is the deadlock guard described below.
// It is orders of magnitude longer than any scheduling delay,
// so it never decides the result, and well inside script/test's
// 30s timeout, so a wedge fails on the assertion instead of
// blowing the package timeout.
rendezvousDeadlock = 5 * time.Second
)
g := newGuard(middleware.LoginFailureMaxKeysConst, concurrency)
var (
mu sync.Mutex
inside int
highest int
wg sync.WaitGroup
recorded sync.WaitGroup
once sync.Once
)
// Slot holders rendezvous instead of sleeping, and they hold until
// every worker has been answered. A sleep only makes overlap
// likely — on a host loaded enough to deschedule a goroutine for
// longer than the sleep the workers serialise and the maximum
// observed comes back as 1 — so the rendezvous is what makes the
// overlap a fact rather than a race won.
//
// The barrier must not open at the concurrency-th holder, which
// would fix the lower bound at the cost of the upper one this test
// exists to enforce: holders would leave as soon as the count
// reached concurrency, so a guard admitting extra requests would
// let them arrive after the first holders had already left and
// highest would report concurrency however many were really let
// in. It opens instead once every worker's acquire has returned
// and any slot it won has been counted, so under a broken guard
// every admitted worker is inside simultaneously and highest is
// the true maximum. Under a correct guard the refused workers
// return within the guard's own wait, which decides nothing beyond
// how long that takes.
overlapped := make(chan struct{})
closeOverlapped := func() {
once.Do(func() { close(overlapped) })
}
// Deadlock guard, not a timing margin: no assertion depends on its
// length, and the only way to reach it is a worker that never
// returns from acquire at all. It is here so that such a wedge
// fails legibly on the assertion below instead of hanging until
// the package test timeout.
abandon := time.AfterFunc(rendezvousDeadlock, closeOverlapped)
defer abandon.Stop()
recorded.Add(workers)
go func() {
recorded.Wait()
closeOverlapped()
}()
for range workers {
wg.Go(func() {
release, ok := g.AcquireForTest(context.Background())
if !ok {
recorded.Done()
return
}
defer release()
mu.Lock()
inside++
if inside > highest {
highest = inside
}
mu.Unlock()
// Counted before signalling, so the barrier can never open
// while an admitted worker is still on its way to being
// counted.
recorded.Done()
<-overlapped
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.
//
// Neither half of this rides on the wait being long enough. The
// refusal holds the only slot across the whole of the second call, so
// there is no wait it could get lucky with — the wait fixes only how
// long the refusal takes, not whether it happens. The reuse after
// release is settled by acquire's non-blocking preamble, which is
// pinned separately by TestLoginGuard_FreeSlotBeatsAnExpiredWait. So
// the wait below is sized to keep the test quick, not to win a race.
func TestLoginGuard_SaturatedSemaphoreRefusesRatherThanQueueing(
t *testing.T,
) {
t.Parallel()
g := middleware.NewLoginGuardForTest(
middleware.LoginRateLimitConst,
guardInterval,
middleware.LoginFailureMaxKeysConst,
1,
middleware.PasswordVerifyMaxWaitersConst,
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())
// require, not assert: acquire returns a nil release alongside a
// false ok, so calling it after a non-fatal assertion turns one
// failed test into a segfault that takes the whole package test
// binary down. Every assertion whose value is dereferenced later
// has to stop the test.
require.True(
t, ok, "the slot must be reusable once released",
)
release()
}
// TestLoginGuard_FreeSlotBeatsAnExpiredWait is the determinism this
// file used to lack. acquire selects over a slot send and a wait
// timer, and Go chooses among ready cases uniformly at random, so a
// call made after the timer had already fired was a coin flip: on a
// loaded host the previous test's third acquire could be refused
// with its slot standing free, and then dereference the nil release
// it got back.
//
// The wait here is already elapsed on arrival, which is the worst
// case that scheduling can produce, so a free slot must still be
// granted every time. Without acquire's non-blocking preamble each
// pass is an independent coin flip and the loop fails within a few
// passes; with it the property holds by construction and no wall
// clock is involved.
func TestLoginGuard_FreeSlotBeatsAnExpiredWait(t *testing.T) {
t.Parallel()
g := middleware.NewLoginGuardForTest(
middleware.LoginRateLimitConst,
guardInterval,
middleware.LoginFailureMaxKeysConst,
1,
middleware.PasswordVerifyMaxWaitersConst,
0,
)
for pass := range racePasses {
release, ok := g.AcquireForTest(context.Background())
require.Truef(
t, ok,
"pass %d was refused a slot that was free; an expired "+
"wait must never beat an available slot",
pass,
)
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: the number of
// slots is the hashing budget divided by what one Argon2id hash
// actually costs.
//
// The per-hash figure is read out of the shipped password
// parameters rather than copied here. A guard that asserts a literal
// against a literal cannot see the thing it guards: raising
// argon2Memory would leave it green while the real ceiling doubled.
func TestPasswordVerifyConcurrency_MatchesMemoryBudget(t *testing.T) {
t.Parallel()
// Memory is the real argon2Memory, in KiB.
perHashMB := int(database.DefaultPasswordConfig().Memory) / mib
require.Positive(
t, perHashMB,
"the Argon2id memory parameter must be readable in MB",
)
// The memory this service commits to password hashing.
const budgetMB = 128
assert.Equal(
t,
middleware.PasswordVerifyConcurrencyConst,
budgetMB/perHashMB,
"the verification concurrency must be the %d MB hashing "+
"budget divided by the %d MB one Argon2id hash costs; "+
"if the Argon2id parameters changed, the slot count "+
"must change with them",
budgetMB, perHashMB,
)
}
// TestLoginGuard_ShedsPastTheQueueCap pins the memory bound on
// waiting, as distinct from the bound on hashing. A waiter arrives
// with its form already parsed, and the retained parse plus its
// header block cost several MB — far more than maxFormBodySize
// suggests, since that caps only the raw body read — so an unbounded
// queue would hold that much per waiting request for the whole wait;
// past the cap the guard must refuse instantly rather than grow.
func TestLoginGuard_ShedsPastTheQueueCap(t *testing.T) {
t.Parallel()
const (
maxWaiters = 2
// Long enough that a queued waiter never times out on its
// own, so anything the test observes leaving the queue left
// because it was shed.
neverElapses = time.Minute
// The probe carries its own deadline, so a guard that queues
// the probe instead of shedding it fails here rather than
// hanging until the package test timeout.
//
// This is a patience budget, not a margin to be won. A shed
// returns in microseconds and a probe that queued instead
// would not return for neverElapses, so the two are a whole
// minute apart and any budget between them separates them. It
// is set far above any scheduling stall a loaded host can
// produce, because the previous 200 ms — and the 100 ms
// elapsed-time assertion it fed — bounded the latency of a
// goroutine hand-off, which is a false red waiting to happen
// on the machine this suite runs on. What actually proves the
// probe was not queued is the queue depth asserted below.
probePatience = 5 * time.Second
)
g := middleware.NewLoginGuardForTest(
middleware.LoginRateLimitConst,
guardInterval,
middleware.LoginFailureMaxKeysConst,
1,
maxWaiters,
neverElapses,
)
// Occupy the only slot, so everything after this queues.
release, ok := g.AcquireForTest(context.Background())
require.True(t, ok)
defer release()
defer fillQueue(t, g, maxWaiters)()
granted, answered := probeQueueCap(g, probePatience)
require.True(
t, answered,
"a request arriving past the queue cap is still waiting to "+
"be queued; it must have been shed",
)
assert.False(
t, granted,
"a request arriving past the queue cap must be shed",
)
assert.Equal(
t, maxWaiters, g.QueuedWaitersForTest(),
"a shed request must not have grown the queue",
)
}
// fillQueue starts n waiters on g and returns once all of them are
// queued for a slot. The returned function releases them and waits
// for them to exit.
func fillQueue(
t *testing.T,
g *middleware.LoginGuard,
n int,
) func() {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
for range n {
wg.Go(func() {
done, got := g.AcquireForTest(ctx)
if got {
done()
}
})
}
// Patience budget, not a margin: the waiters park in microseconds
// and nothing releases them, so the only way to exhaust this is a
// guard that never queues. One second is the same order as the
// scheduling stalls this suite has to survive, so it is not one.
require.Eventually(
t,
func() bool { return g.QueuedWaitersForTest() == n },
5*time.Second, time.Millisecond,
"the waiters must reach the queue before the cap is tested",
)
return func() {
cancel()
wg.Wait()
}
}
// probeQueueCap acquires from another goroutine. It reports, in
// order, whether the call was granted a slot and whether it was
// answered at all within wait; a call that never returned reports
// false for both.
//
// It runs off the test goroutine deliberately. Joining a full queue
// is not cancellable by context — refusing to join is the property
// under test — so a guard that fails this would otherwise hang the
// package until the test timeout instead of failing here.
//
// It reports no elapsed time. Timing a goroutine hand-off measures
// the host, not the guard, and the caller distinguishes shedding from
// queueing by the queue depth instead.
func probeQueueCap(
g *middleware.LoginGuard,
wait time.Duration,
) (bool, bool) {
probed := make(chan bool, 1)
go func() {
release, ok := g.AcquireForTest(context.Background())
if ok {
release()
}
probed <- ok
}()
select {
case result := <-probed:
return result, true
case <-time.After(wait):
return false, false
}
}