Verify login credentials before spending rate-limit budget (closes #150)
All checks were successful
check / check (push) Successful in 2m54s
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.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewLoggingResponseWriterForTest wraps newLoggingResponseWriter
|
||||
@@ -35,9 +37,69 @@ 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
|
||||
|
||||
// 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 int,
|
||||
wait time.Duration,
|
||||
) *LoginGuard {
|
||||
return newLoginGuard(
|
||||
limit, interval, maxKeys, concurrency, wait,
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
299
internal/middleware/loginguard.go
Normal file
299
internal/middleware/loginguard.go
Normal file
@@ -0,0 +1,299 @@
|
||||
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
|
||||
|
||||
// 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{}
|
||||
|
||||
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 and slot wait.
|
||||
func newLoginGuard(
|
||||
limit int,
|
||||
interval time.Duration,
|
||||
maxKeys, concurrency int,
|
||||
wait time.Duration,
|
||||
) *loginGuard {
|
||||
return &loginGuard{
|
||||
byUser: make(map[string]*failureWindow),
|
||||
byAddr: make(map[string]*failureWindow),
|
||||
slots: make(chan struct{}, concurrency),
|
||||
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 none became available or 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) {
|
||||
timer := time.NewTimer(g.wait)
|
||||
defer timer.Stop()
|
||||
|
||||
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,
|
||||
passwordVerifyWait,
|
||||
)
|
||||
})
|
||||
|
||||
return m.loginGuard
|
||||
}
|
||||
|
||||
// BeginPasswordVerification reserves one of the bounded Argon2id
|
||||
// verification slots. It reports false when none became free within
|
||||
// passwordVerifyWait, in which 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
|
||||
}
|
||||
352
internal/middleware/loginguard_test.go
Normal file
352
internal/middleware/loginguard_test.go
Normal file
@@ -0,0 +1,352 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
basicauth "github.com/99designs/basicauth-go"
|
||||
@@ -43,6 +44,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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user