Verify login credentials before spending rate-limit budget (closes #150)
All checks were successful
check / check (push) Successful in 2m46s

In the shipped default, any stranger denied the operator the only
administrative path at 5 requests per minute: TRUSTED_PROXIES is empty,
the README requires a reverse proxy, so every login POST shared one
bucket keyed on the proxy.

Credentials are now verified first and only a FAILED attempt spends
budget, so a correct password is never throttled. Failures are counted
per (client bucket, submitted username), bounded. Concurrent Argon2id
verifications are capped at two, and the queue for them at 16 — because
verifying first lets an attacker force a 64 MB hash per request, and
bounding the wait alone bounds nothing.

The issue's own recommendation was insufficient and is rejected here:
keying by username stops an attacker locking out a DIFFERENT account,
but this is a single-admin product with a predictable bootstrap
username, so flooding the operator's own name still locks them out.

This is speculative — it implements a corrected recommendation ahead of
the owner's ruling so the decision can be made by merging or reverting.
Three things are disclosed rather than glossed: online guessing rises
from 5/min to roughly 27/s, because the 429 is a label on the response
and not a gate in front of the hash; the residual exposure is a loss of
login AVAILABILITY, not latency, and a determined flood still denies
login while it runs, at ~400x the cost and clearing the moment it
stops; and the endpoint should be provisioned for ~400 MB resident, not
the 203 MB of live commitment it itemises.

Independently reviewed four times. Reviewers disproved the suspected
FIFO starvation by measurement, then caught two successive memory
bounds the code did not have — the second by parking waiters and
reading the heap rather than checking the arithmetic.
This commit was merged in pull request #171.
This commit is contained in:
2026-08-18 01:55:41 +02:00
parent 992b3c68f5
commit 977fe87588
20 changed files with 2010 additions and 139 deletions

View File

@@ -1,7 +1,9 @@
package middleware
import (
"context"
"net/http"
"time"
)
// NewLoggingResponseWriterForTest wraps newLoggingResponseWriter
@@ -35,9 +37,79 @@ func IsClientTLS(r *http.Request) bool {
return isClientTLS(r)
}
// LoginRateLimitConst exposes the loginRateLimit constant.
// LoginRateLimitConst exposes the loginRateLimit constant: the
// number of FAILED login attempts one client may make against one
// submitted username per interval.
const LoginRateLimitConst = loginRateLimit
// LoginFailureMaxKeysConst exposes the cap on each of the login
// guard's key sets.
const LoginFailureMaxKeysConst = loginFailureMaxKeys
// PasswordVerifyConcurrencyConst exposes the bound on concurrent
// Argon2id verifications.
const PasswordVerifyConcurrencyConst = passwordVerifyConcurrency
// PasswordVerifyMaxWaitersConst exposes the bound on how many
// requests may queue for a verification slot.
const PasswordVerifyMaxWaitersConst = passwordVerifyMaxWaiters
// LoginGuard is the login failure counter and verification
// semaphore, exposed for direct testing.
type LoginGuard = loginGuard
// NewLoginGuardForTest builds a guard with test-sized parameters.
func NewLoginGuardForTest(
limit int,
interval time.Duration,
maxKeys, concurrency, maxWaiters int,
wait time.Duration,
) *LoginGuard {
return newLoginGuard(
limit, interval, maxKeys, concurrency, maxWaiters, wait,
)
}
// QueuedWaitersForTest reports how many requests are currently
// queued for a verification slot.
func (g *LoginGuard) QueuedWaitersForTest() int {
return len(g.queue)
}
// SetNowForTest replaces the guard's clock.
func (g *LoginGuard) SetNowForTest(now func() time.Time) {
g.mu.Lock()
defer g.mu.Unlock()
g.now = now
}
// FailForTest exposes fail.
func (g *LoginGuard) FailForTest(clientKey, username string) bool {
return g.fail(clientKey, username)
}
// SucceedForTest exposes succeed.
func (g *LoginGuard) SucceedForTest(clientKey, username string) {
g.succeed(clientKey, username)
}
// AcquireForTest exposes acquire.
func (g *LoginGuard) AcquireForTest(
ctx context.Context,
) (func(), bool) {
return g.acquire(ctx)
}
// TrackedKeysForTest reports how many failure counters the guard
// holds, per-username and per-address respectively.
func (g *LoginGuard) TrackedKeysForTest() (int, int) {
g.mu.Lock()
defer g.mu.Unlock()
return len(g.byUser), len(g.byAddr)
}
// PasswordChangeRateLimitConst exposes the
// passwordChangeRateLimit constant.
const PasswordChangeRateLimitConst = passwordChangeRateLimit

View File

@@ -0,0 +1,370 @@
package middleware
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"sync"
"time"
)
const (
// loginFailureMaxKeys bounds how many distinct failure counters
// each of the guard's two key sets holds. The submitted username
// is part of a key, so the key set is attacker-influenced and
// needs a hard cap or the limiter becomes the memory
// amplification surface it exists to protect.
//
// A single-admin deployment has a handful of legitimate (client,
// username) pairs, so 1024 is three orders of magnitude of
// headroom before a real operator can be pushed onto the
// fallback. It costs little: a counter is a ~64-byte key string,
// a 32-byte window and map overhead, call it 170 bytes, so both
// key sets full is 2 * 1024 * 170 bytes, under 0.4 MB.
loginFailureMaxKeys = 1024
// passwordVerifyConcurrency bounds how many Argon2id
// verifications may run at once across every password-verifying
// endpoint. Because credentials are now verified before any
// limiter budget is spent, an attacker can force one hash per
// request, and each hash allocates argon2Memory — 64 MB. Two
// slots commit at most 128 MB to password hashing, which fits
// inside the smallest container this service is realistically
// given alongside its own working set; four would commit 256 MB
// and crowd it. A single-admin product needs no concurrent
// logins at all, so the second slot exists only so that one
// stalled request does not serialise the endpoint.
passwordVerifyConcurrency = 2
// passwordVerifyWait is how long a request waits for a
// verification slot before it is answered 503. Slots are handed
// out in arrival order, so a legitimate request queues behind
// the requests already waiting rather than behind the flood as a
// whole. The wait is well inside the 60s request timeout.
passwordVerifyWait = 5 * time.Second
// passwordVerifyMaxWaiters bounds how many requests may be
// queued for a slot at once. Past it, acquire sheds immediately
// with 503 instead of joining the queue.
//
// The wait bounds how long one request occupies memory; this
// bounds how many do so at the same time, and without it the
// 128 MB hashing budget above is the smaller half of the real
// footprint. At the 400 req/s a saturation attack can offer, an
// unbounded queue would park ~2000 requests for the full five
// seconds.
//
// A waiter costs far more than maxFormBodySize suggests: that
// caps the raw body read, not what the parse retains. MaxBodySize,
// CSRF and ParseForm all run before acquire, so a parked waiter
// holds r.Form plus r.PostForm plus its header block for the
// whole wait. Measured on the pinned go1.26.1 toolchain, as the
// HeapAlloc delta across two GCs with 64 waiters parked in the
// handler: an ordinary two-field login form retains ~0 MB, but a
// 1 MB urlencoded body at Go's 10,000-parameter parse cap retains
// 2.82 MB (3.09 MB with %41 escapes), and adding the ~0.9 MB of
// headers httpMaxHeaderBytes allows takes it to 4.18 MB. The
// retained parse and the header block dominate; the raw body does
// not.
//
// Arithmetic, from the measured 4.18 MB worst case: 16 waiters
// commit ~67 MB of queue memory, and peak commitment for the
// endpoint is 128 MB of Argon2id plus the 18 requests that retain
// a parsed form — 16 queued and the 2 being hashed — at
// 18 * 4.18 MB, so ~75 MB: about 203 MB in all. Cross-check
// against the deadline: two slots at the ~27 verifications/s
// measured on a review host (with the race detector on, so the
// real rate is higher) drain a full 16-deep queue in about 0.6 s,
// far inside passwordVerifyWait.
//
// Those 203 MB are live bytes, not resident bytes: the Go
// collector lets the heap reach roughly twice the live set before
// collecting, with transient parse garbage on top. The review
// measured a peak HeapAlloc of 392 MB against this guard under 18
// adversarial requests, so provision on the order of 400 MB rather
// than 203 MB.
passwordVerifyMaxWaiters = 16
// failureKeyHashBytes is how much of the username digest goes
// into a failure key. 64 bits over at most loginFailureMaxKeys
// live keys makes a collision negligible, and a collision would
// only merge two usernames' failure counters, which throttles
// sooner rather than later.
failureKeyHashBytes = 8
)
// failureWindow counts failed credential verifications for one
// bucket, and records when that count lapses.
type failureWindow struct {
count int
resetAt time.Time
}
// loginGuard is what replaced the pre-emptive rate limiter on the
// login POST.
//
// A limiter that spends budget on arrival cannot protect a
// single-admin product: behind the reverse proxy the deployment
// requires, with TRUSTED_PROXIES unset, every client keys on the
// proxy, so a stranger trickling five POSTs a minute keeps the one
// bucket full and the operator's own correct password is answered 429
// forever. There is no second administrative path.
//
// So budget is spent only by a FAILED verification. A correct
// password is never throttled, whatever the counters say, which is
// the only shape that guarantees the operator can get in. Two
// consequences follow and are handled here:
//
// - Every login request now costs an Argon2id hash, so the number
// running concurrently is bounded by slots. Without that bound
// this trades an admin lockout for memory exhaustion, which is
// strictly worse.
// - Counting per (client, username) makes the key set
// attacker-influenced, so both key sets are capped. Beyond the
// per-username cap, failures fall back to a counter keyed on the
// client alone; beyond that cap too, a failure is answered as
// throttled without being recorded, since refusing to answer a
// wrong password costs the operator nothing.
type loginGuard struct {
mu sync.Mutex
byUser map[string]*failureWindow
byAddr map[string]*failureWindow
slots chan struct{}
// queue holds one token per request waiting for a slot. A token
// is taken non-blockingly, so a request that finds it full is
// shed rather than queued, and is given up as soon as the wait
// ends however it ends.
queue chan struct{}
limit int
interval time.Duration
maxKeys int
wait time.Duration
// now is time.Now outside tests.
now func() time.Time
}
// newLoginGuard builds a guard with the given failure limit per
// interval, key-set cap, verification concurrency, queue depth and
// slot wait.
func newLoginGuard(
limit int,
interval time.Duration,
maxKeys, concurrency, maxWaiters int,
wait time.Duration,
) *loginGuard {
return &loginGuard{
byUser: make(map[string]*failureWindow),
byAddr: make(map[string]*failureWindow),
slots: make(chan struct{}, concurrency),
queue: make(chan struct{}, maxWaiters),
limit: limit,
interval: interval,
maxKeys: maxKeys,
wait: wait,
now: time.Now,
}
}
// acquire reserves a verification slot, waiting up to the guard's
// wait for one. It reports false when the queue of waiters is
// already full, when no slot became available in time, or when the
// request was cancelled first; the caller must then answer 503
// without verifying anything. The returned function releases the
// slot and must be called exactly once.
func (g *loginGuard) acquire(ctx context.Context) (func(), bool) {
// Shedding past the queue depth is what keeps waiting memory
// bounded; the wait alone only bounds how long one waiter holds
// its parsed form, not how many hold one at once.
select {
case g.queue <- struct{}{}:
default:
return nil, false
}
// Held only for the wait. A request that gets a slot gives its
// queue token back before it starts hashing, so the depth is a
// bound on waiters rather than on requests in the handler.
defer func() { <-g.queue }()
timer := time.NewTimer(g.wait)
defer timer.Stop()
// The blocking send is deliberate: a receive on a full buffered
// channel hands the slot straight to the head of the send queue,
// so slots go out in arrival order and a later arrival cannot
// barge past a request already waiting.
select {
case g.slots <- struct{}{}:
return func() { <-g.slots }, true
case <-timer.C:
return nil, false
case <-ctx.Done():
return nil, false
}
}
// fail records one failed credential verification by clientKey
// against username, and reports whether this client has now spent
// its failure budget and should be answered 429.
func (g *loginGuard) fail(clientKey, username string) bool {
g.mu.Lock()
defer g.mu.Unlock()
now := g.now()
window := g.window(
g.byUser, userFailureKey(clientKey, username), now,
)
if window == nil {
window = g.window(g.byAddr, clientKey, now)
}
if window == nil {
// Both key sets are full and neither already tracks this
// client, so nothing can be counted without unbounded
// growth. Answering the failure as throttled is the safe
// direction: it never touches a correct password.
return true
}
window.count++
return window.count >= g.limit
}
// succeed forgives clientKey's failures against username. A correct
// password clears the counters, so an operator who mistypes several
// times and then gets it right is not throttled afterwards.
func (g *loginGuard) succeed(clientKey, username string) {
g.mu.Lock()
defer g.mu.Unlock()
delete(g.byUser, userFailureKey(clientKey, username))
delete(g.byAddr, clientKey)
}
// window returns the live counter for key in set, resetting a lapsed
// one and creating a missing one when the cap allows. It returns nil
// only when key is absent and set is full even after lapsed entries
// are swept.
func (g *loginGuard) window(
set map[string]*failureWindow,
key string,
now time.Time,
) *failureWindow {
window, ok := set[key]
if ok {
if !now.Before(window.resetAt) {
window.count = 0
window.resetAt = now.Add(g.interval)
}
return window
}
if len(set) >= g.maxKeys {
sweepLapsed(set, now)
}
if len(set) >= g.maxKeys {
return nil
}
window = &failureWindow{resetAt: now.Add(g.interval)}
set[key] = window
return window
}
// sweepLapsed drops counters whose interval has elapsed.
func sweepLapsed(set map[string]*failureWindow, now time.Time) {
for key, window := range set {
if !now.Before(window.resetAt) {
delete(set, key)
}
}
}
// userFailureKey identifies one (client, submitted username) pair.
// The username is hashed rather than embedded: a submitted username
// is attacker-controlled text of attacker-chosen length, and hashing
// makes every key the same size whatever was sent.
func userFailureKey(clientKey, username string) string {
sum := sha256.Sum256([]byte(username))
return clientKey + "|" +
hex.EncodeToString(sum[:failureKeyHashBytes])
}
// guard returns the middleware's login guard, building it on first
// use so that every construction path — fx and the test constructor
// alike — gets one.
func (m *Middleware) guard() *loginGuard {
m.loginGuardOnce.Do(func() {
m.loginGuard = newLoginGuard(
loginRateLimit,
loginRateInterval,
loginFailureMaxKeys,
passwordVerifyConcurrency,
passwordVerifyMaxWaiters,
passwordVerifyWait,
)
})
return m.loginGuard
}
// BeginPasswordVerification reserves one of the bounded Argon2id
// verification slots. It reports false when the queue of waiting
// requests is already at passwordVerifyMaxWaiters, or when no slot
// became free within passwordVerifyWait; in either case the caller
// must answer 503 and must not verify a password. The returned
// function releases the slot and must be called exactly once.
//
// Every endpoint that hashes a password on request must go through
// this, or the bound has a hole: the memory is committed per hash,
// not per endpoint.
func (m *Middleware) BeginPasswordVerification(
ctx context.Context,
) (func(), bool) {
return m.guard().acquire(ctx)
}
// RecordLoginFailure counts a failed credential verification for the
// request's client against the submitted username, and reports
// whether the response should be 429 rather than 401.
func (m *Middleware) RecordLoginFailure(
r *http.Request,
username string,
) bool {
throttled := m.guard().fail(m.clientKey(r), username)
if throttled {
m.log.Warn(
"login failure limit exceeded", "path", r.URL.Path,
)
}
return throttled
}
// ForgiveLoginFailures clears the failure counters for the request's
// client and the submitted username after a successful
// authentication.
func (m *Middleware) ForgiveLoginFailures(
r *http.Request,
username string,
) {
m.guard().succeed(m.clientKey(r), username)
}
// LoginFailureInterval is how long a spent login failure budget
// takes to refill, which is what a throttled login answers as
// Retry-After.
func (m *Middleware) LoginFailureInterval() time.Duration {
return m.guard().interval
}

View File

@@ -0,0 +1,511 @@
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"
)
// 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
)
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,
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())
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: 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 on the elapsed time
// rather than hanging until the package test timeout.
probeWait = 200 * time.Millisecond
// Shedding takes no measurable time; queueing takes the whole
// probeWait. Anything under half of it is unambiguous.
shedFast = probeWait / 2
)
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)()
got := probeQueueCap(g, probeWait)
require.NotNil(
t, got,
"a request arriving past the queue cap is still waiting to "+
"be queued; it must have been shed",
)
assert.False(
t, got.ok,
"a request arriving past the queue cap must be shed",
)
assert.Less(
t, got.elapsed, shedFast,
"shedding must be immediate; waiting for a place in the "+
"queue is the memory growth this bounds",
)
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()
}
})
}
require.Eventually(
t,
func() bool { return g.QueuedWaitersForTest() == n },
time.Second, time.Millisecond,
"the waiters must reach the queue before the cap is tested",
)
return func() {
cancel()
wg.Wait()
}
}
// probeResult is what the queue-cap probe reports: whether it got a
// slot, and how long it took to find out.
type probeResult struct {
ok bool
elapsed time.Duration
}
// probeQueueCap acquires from another goroutine and reports the
// result, or nil if the call was still blocked after wait.
//
// 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.
func probeQueueCap(
g *middleware.LoginGuard,
wait time.Duration,
) *probeResult {
probed := make(chan probeResult, 1)
go func() {
start := time.Now()
release, ok := g.AcquireForTest(context.Background())
if ok {
release()
}
probed <- probeResult{ok: ok, elapsed: time.Since(start)}
}()
select {
case result := <-probed:
return &result
case <-time.After(wait):
return nil
}
}

View File

@@ -7,6 +7,7 @@ import (
"net"
"net/http"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
@@ -116,6 +117,12 @@ type Middleware struct {
log *slog.Logger
params *MiddlewareParams
session *session.Session
// loginGuard counts failed credential verifications and bounds
// concurrent password hashing. It is built on first use so that
// every construction path gets one; see guard().
loginGuardOnce sync.Once
loginGuard *loginGuard
}
// New creates a Middleware from the provided fx parameters.

View File

@@ -12,11 +12,15 @@ import (
)
const (
// loginRateLimit is the maximum number of login attempts
// per interval.
// loginRateLimit is the maximum number of FAILED login attempts
// one client may make against one submitted username per
// interval before further failures are answered 429. Successful
// attempts are never counted and never throttled — see
// loginGuard.
loginRateLimit = 5
// loginRateInterval is the time window for the rate limit.
// loginRateInterval is the time window for the login failure
// limit.
loginRateInterval = 1 * time.Minute
// passwordChangeRateLimit is the maximum number of password
@@ -216,7 +220,7 @@ func (m *Middleware) clientKey(r *http.Request) string {
return bucketKey(peer)
}
// tooManyRequests returns the 429 handler used by the login,
// tooManyRequests returns the 429 handler used by the
// password-change and per-entrypoint receiver limiters: it logs the
// rejection with logMessage and answers with responseMessage.
// httprate adds the Retry-After header (RFC 6585). The aggregate
@@ -255,26 +259,15 @@ func (m *Middleware) floodTooManyRequests(
}
}
// LoginRateLimit returns middleware that enforces per-IP rate
// limiting on login attempts using go-chi/httprate. Only POST
// requests are rate-limited; GET requests (rendering the login
// form) pass through unaffected. When the rate limit is exceeded,
// a 429 Too Many Requests response is returned. Clients are
// identified by rateLimitKey.
func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
return m.postRateLimit(
loginRateLimit,
loginRateInterval,
"login rate limit exceeded",
"Too many login attempts. Please try again later.",
)
}
// PasswordChangeRateLimit returns middleware that enforces
// per-IP rate limiting on password change attempts. The change
// endpoint verifies the current password, so without a limit a
// stolen session could be used to brute-force it; the limit
// matches the login endpoint's.
// stolen session could be used to brute-force it.
//
// Unlike the login POST this limit is still spent on arrival, which
// is safe here: RequireAuth runs ahead of it, so only a request
// already carrying a valid session can reach the bucket, and an
// operator locked out of changing a password can still log in.
func (m *Middleware) PasswordChangeRateLimit() func(http.Handler) http.Handler {
return m.postRateLimit(
passwordChangeRateLimit,

View File

@@ -20,14 +20,14 @@ import (
"sneak.berlin/go/webhooker/internal/middleware"
)
func TestLoginRateLimit_AllowsGET(t *testing.T) {
func TestPostRateLimit_AllowsGET(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
var callCount int
handler := m.LoginRateLimit()(http.HandlerFunc(
handler := m.PasswordChangeRateLimit()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
callCount++
@@ -39,7 +39,7 @@ func TestLoginRateLimit_AllowsGET(t *testing.T) {
for i := range 20 {
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet, "/pages/login", nil,
http.MethodGet, "/user/admin/password", nil,
)
req.RemoteAddr = "192.168.1.1:12345"
@@ -110,20 +110,6 @@ func runPostLimitTest(
assert.Equal(t, limit, callCount)
}
func TestLoginRateLimit_LimitsPOST(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
runPostLimitTest(
t,
m.LoginRateLimit(),
middleware.LoginRateLimitConst,
"/pages/login",
"10.0.0.1:12345",
)
}
func TestPasswordChangeRateLimit_LimitsPOST(t *testing.T) {
t.Parallel()
@@ -138,19 +124,19 @@ func TestPasswordChangeRateLimit_LimitsPOST(t *testing.T) {
)
}
func TestLoginRateLimit_IndependentPerIP(t *testing.T) {
func TestPostRateLimit_IndependentPerIP(t *testing.T) {
t.Parallel()
m, _ := testMiddleware(t, config.EnvironmentDev)
handler := m.LoginRateLimit()(http.HandlerFunc(
handler := m.PasswordChangeRateLimit()(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
// Exhaust limit for IP1
for range middleware.LoginRateLimitConst {
for range middleware.PasswordChangeRateLimitConst {
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, "/pages/login", nil,
@@ -367,7 +353,14 @@ func TestReceiverRateLimit_CountsEveryMethod(t *testing.T) {
}
const (
loginPath = "/pages/login"
// limitedPath is the endpoint these tests drive the shared POST
// rate limiter through. It is the password-change path: since
// the login POST verifies credentials before spending any
// budget, the password-change limiter is the only pre-emptive
// POST limiter left, and it is what pins the shared key
// function's behaviour here.
limitedPath = "/user/admin/password"
headerXFF = "X-Forwarded-For"
headerReal = "X-Real-IP"
headerTrue = "True-Client-IP"
@@ -415,10 +408,10 @@ func assertSharedBucket(
m := rateLimitMiddleware(
t, &config.Config{TrustedProxies: proxies},
)
handler := m.LoginRateLimit()(okHandler())
handler := m.PasswordChangeRateLimit()(okHandler())
for i := range middleware.LoginRateLimitConst {
w := postWithHeaders(handler, peer, loginPath, headers(i))
for i := range middleware.PasswordChangeRateLimitConst {
w := postWithHeaders(handler, peer, limitedPath, headers(i))
assert.Equal(
t, http.StatusOK, w.Code,
"request %d should pass", i,
@@ -426,8 +419,8 @@ func assertSharedBucket(
}
w := postWithHeaders(
handler, peer, loginPath,
headers(middleware.LoginRateLimitConst),
handler, peer, limitedPath,
headers(middleware.PasswordChangeRateLimitConst),
)
assert.Equal(t, http.StatusTooManyRequests, w.Code, msg)
}
@@ -549,24 +542,24 @@ func TestRateLimitKey_ForwardedHonouredFromTrustedPeer(
m := rateLimitMiddleware(t, &config.Config{
TrustedProxies: trustedProxies(trustedProxyCIDR),
})
handler := m.LoginRateLimit()(okHandler())
handler := m.PasswordChangeRateLimit()(okHandler())
const peer = trustedPeer
first := map[string]string{headerXFF: clientIPv4}
for range middleware.LoginRateLimitConst {
postWithHeaders(handler, peer, loginPath, first)
for range middleware.PasswordChangeRateLimitConst {
postWithHeaders(handler, peer, limitedPath, first)
}
w := postWithHeaders(handler, peer, loginPath, first)
w := postWithHeaders(handler, peer, limitedPath, first)
assert.Equal(
t, http.StatusTooManyRequests, w.Code,
"the forwarded client's own bucket must fill up",
)
w = postWithHeaders(
handler, peer, loginPath,
handler, peer, limitedPath,
map[string]string{headerXFF: clientIPv4Alt},
)
assert.Equal(
@@ -662,7 +655,7 @@ func TestRateLimitKey_LongChainAllocationIsBounded(t *testing.T) {
})
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, loginPath, nil,
context.Background(), http.MethodPost, limitedPath, nil,
)
req.RemoteAddr = trustedPeer
req.Header.Set(
@@ -869,7 +862,7 @@ func clientKeyFor(
t.Helper()
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, loginPath, nil,
context.Background(), http.MethodPost, limitedPath, nil,
)
req.RemoteAddr = remoteAddr
@@ -1019,23 +1012,23 @@ func TestRateLimitKey_UnparseablePeerKeepsDistinctBuckets(
)
}
// TestLoginRateLimit_IPv6SharesBucketWithinSlash64 is the behavioural
// TestPostRateLimit_IPv6SharesBucketWithinSlash64 is the behavioural
// half, and the regression test for the bypass itself: a client that
// rotates source addresses inside its own routed /64 must stay in one
// bucket. Reverting the masking makes this test fail, because each
// rotated address would mint a fresh bucket and nothing would be
// rejected.
func TestLoginRateLimit_IPv6SharesBucketWithinSlash64(t *testing.T) {
func TestPostRateLimit_IPv6SharesBucketWithinSlash64(t *testing.T) {
t.Parallel()
m := rateLimitMiddleware(t, &config.Config{})
handler := m.LoginRateLimit()(okHandler())
handler := m.PasswordChangeRateLimit()(okHandler())
for i := range middleware.LoginRateLimitConst {
for i := range middleware.PasswordChangeRateLimitConst {
w := postWithHeaders(
handler,
fmt.Sprintf("[2001:db8:1:2::%d]:44444", i+1),
loginPath, nil,
limitedPath, nil,
)
assert.Equal(
t, http.StatusOK, w.Code, "request %d should pass", i,
@@ -1043,7 +1036,7 @@ func TestLoginRateLimit_IPv6SharesBucketWithinSlash64(t *testing.T) {
}
w := postWithHeaders(
handler, "[2001:db8:1:2::ffff]:44444", loginPath, nil,
handler, "[2001:db8:1:2::ffff]:44444", limitedPath, nil,
)
assert.Equal(
t, http.StatusTooManyRequests, w.Code,
@@ -1052,23 +1045,23 @@ func TestLoginRateLimit_IPv6SharesBucketWithinSlash64(t *testing.T) {
)
}
// TestLoginRateLimit_IPv6IndependentAcrossSlash64 is the other side
// TestPostRateLimit_IPv6IndependentAcrossSlash64 is the other side
// of the trade: bucketing by /64 must not merge separate allocations,
// so a client in a different /64 keeps its own limit.
func TestLoginRateLimit_IPv6IndependentAcrossSlash64(t *testing.T) {
func TestPostRateLimit_IPv6IndependentAcrossSlash64(t *testing.T) {
t.Parallel()
m := rateLimitMiddleware(t, &config.Config{})
handler := m.LoginRateLimit()(okHandler())
handler := m.PasswordChangeRateLimit()(okHandler())
for range middleware.LoginRateLimitConst + 1 {
for range middleware.PasswordChangeRateLimitConst + 1 {
postWithHeaders(
handler, "[2001:db8:1:2::1]:44444", loginPath, nil,
handler, "[2001:db8:1:2::1]:44444", limitedPath, nil,
)
}
w := postWithHeaders(
handler, "[2001:db8:1:3::1]:44444", loginPath, nil,
handler, "[2001:db8:1:3::1]:44444", limitedPath, nil,
)
assert.Equal(
t, http.StatusOK, w.Code,
@@ -1076,23 +1069,23 @@ func TestLoginRateLimit_IPv6IndependentAcrossSlash64(t *testing.T) {
)
}
// TestLoginRateLimit_IPv4IndependentPerAddress guards against the
// TestPostRateLimit_IPv4IndependentPerAddress guards against the
// masking leaking into IPv4: two addresses one apart must still hold
// separate buckets.
func TestLoginRateLimit_IPv4IndependentPerAddress(t *testing.T) {
func TestPostRateLimit_IPv4IndependentPerAddress(t *testing.T) {
t.Parallel()
m := rateLimitMiddleware(t, &config.Config{})
handler := m.LoginRateLimit()(okHandler())
handler := m.PasswordChangeRateLimit()(okHandler())
for range middleware.LoginRateLimitConst + 1 {
for range middleware.PasswordChangeRateLimitConst + 1 {
postWithHeaders(
handler, clientIPv4+":44444", loginPath, nil,
handler, clientIPv4+":44444", limitedPath, nil,
)
}
w := postWithHeaders(
handler, clientIPv4Alt+":44444", loginPath, nil,
handler, clientIPv4Alt+":44444", limitedPath, nil,
)
assert.Equal(
t, http.StatusOK, w.Code,
@@ -1112,7 +1105,7 @@ func forwardedKeyFor(
t.Helper()
req := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, loginPath, nil,
context.Background(), http.MethodPost, limitedPath, nil,
)
req.RemoteAddr = trustedPeer
req.Header.Set(headerXFF, forwarded)
@@ -1177,11 +1170,77 @@ func TestRateLimitKey_ForwardedIPv6BucketsByPrefix(t *testing.T) {
}
}
// TestLoginRateLimit_ForwardedIPv6SharesBucketWithinSlash64 is the
// TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer covers the
// third bucketKey call site: the peer IS a trusted proxy, but the
// forwarded chain cannot name a client, so the key falls back to the
// peer address — and that fallback owes the same /64 masking every
// other key gets.
//
// Every existing test of this fallback uses an IPv4 proxy, where
// bucketKey is the identity function, so replacing the call with
// peer.String() leaves the whole suite green. Only operator-listed
// addresses reach this line and the fallback is fail-closed, so this
// pins behaviour rather than fixing a defect.
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
t *testing.T,
) {
t.Parallel()
const (
proxyCIDR = "2001:db8:ffff::/48"
proxyPeer = "[2001:db8:ffff:1::5]:44444"
wantKey = "2001:db8:ffff:1::/64"
)
m := rateLimitMiddleware(t, &config.Config{
TrustedProxies: trustedProxies(proxyCIDR),
})
for _, tc := range []struct {
name string
forwarded string
about string
}{{
name: "absent",
about: "no X-Forwarded-For at all falls back to the peer",
}, {
name: "unreadable-hop",
forwarded: "unknown",
about: "a hop that is not a bare address ends the walk " +
"and falls back to the peer",
}, {
name: "all-hops-trusted",
forwarded: "2001:db8:ffff:2::9",
about: "a chain naming only trusted proxies names no " +
"client, so the peer is used",
}} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost, limitedPath, nil,
)
req.RemoteAddr = proxyPeer
if tc.forwarded != "" {
req.Header.Set(headerXFF, tc.forwarded)
}
assert.Equal(
t, wantKey,
middleware.ClientKeyForTest(m, req),
"%s, masked to its /64", tc.about,
)
})
}
}
// TestPostRateLimit_ForwardedIPv6SharesBucketWithinSlash64 is the
// behavioural half on the production path: behind a trusted proxy, a
// client rotating source addresses inside its own routed /64 must
// stay in one bucket.
func TestLoginRateLimit_ForwardedIPv6SharesBucketWithinSlash64(
func TestPostRateLimit_ForwardedIPv6SharesBucketWithinSlash64(
t *testing.T,
) {
t.Parallel()
@@ -1198,10 +1257,10 @@ func TestLoginRateLimit_ForwardedIPv6SharesBucketWithinSlash64(
)
}
// TestLoginRateLimit_ForwardedIPv6IndependentAcrossSlash64 is the
// TestPostRateLimit_ForwardedIPv6IndependentAcrossSlash64 is the
// other side of that trade on the same path: bucketing by /64 must
// not merge two allocations reaching the proxy.
func TestLoginRateLimit_ForwardedIPv6IndependentAcrossSlash64(
func TestPostRateLimit_ForwardedIPv6IndependentAcrossSlash64(
t *testing.T,
) {
t.Parallel()
@@ -1209,15 +1268,15 @@ func TestLoginRateLimit_ForwardedIPv6IndependentAcrossSlash64(
m := rateLimitMiddleware(t, &config.Config{
TrustedProxies: trustedProxies(trustedProxyCIDR),
})
handler := m.LoginRateLimit()(okHandler())
handler := m.PasswordChangeRateLimit()(okHandler())
spent := map[string]string{headerXFF: clientIPv6}
for range middleware.LoginRateLimitConst + 1 {
postWithHeaders(handler, trustedPeer, loginPath, spent)
for range middleware.PasswordChangeRateLimitConst + 1 {
postWithHeaders(handler, trustedPeer, limitedPath, spent)
}
w := postWithHeaders(
handler, trustedPeer, loginPath,
handler, trustedPeer, limitedPath,
map[string]string{headerXFF: clientIPv6Other},
)
assert.Equal(