mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
fix: count distinct auth attempts, not repeated ones, toward IP lockout (#819)
The lockout fires after 10 auth failures from an address in 60 seconds and then answers RequestLimitExceeded to everything from that address. A client repeating one hopeless request reaches the threshold as fast as an attacker working through a keyspace, and 503 tells it to retry later - advice it can never act on, because the fault is a credential that will never resolve. On wattle four guests holding deleted STS session credentials produced 53,392 rejected requests and 69,820 503s in a day, and could not recover by fixing their credentials because the lockout is on the address, not the principal. RecordFailure now takes a fingerprint identifying the attempt, and the window holds one entry per distinct fingerprint: a repeat refreshes its timestamp instead of adding to the count. Whatever an attacker must vary is what the fingerprint carries - the key id, the presented signature, a digest of the session token - so guessing is throttled exactly as before while one dead credential presented 14,000 times stays one fault. A request rejected before its key id is parsed has no identity to protect, so malformed envelopes and skewed timestamps still count every occurrence.
This commit is contained in:
+17
-12
@@ -60,7 +60,9 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
"requestTime", signingTime(r),
|
||||
"serverTime", time.Now().UTC().Format("20060102T150405Z"),
|
||||
"maxSkew", sigv4.MaxClockSkew)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
// Anonymous: the request was rejected before its key id was parsed,
|
||||
// so there is no client identity for the lockout to protect.
|
||||
gw.RateLimiter.RecordFailure(clientIP, anonymousAttempt)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch)
|
||||
default:
|
||||
// Malformed Authorization, bad credential scope, unsupported
|
||||
@@ -68,7 +70,7 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
// error names which, and is the only record of it.
|
||||
slog.Warn("Auth failure: malformed signature envelope",
|
||||
"sourceIP", clientIP, "err", err)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP, anonymousAttempt)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature)
|
||||
}
|
||||
return
|
||||
@@ -116,7 +118,7 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
secret, principal, lookupCode = gw.resolveSessionAKID(r, sig.Credential.AccessKeyID, clientIP)
|
||||
default:
|
||||
slog.Warn("Auth failure: unknown AKID prefix", "accessKeyID", sig.Credential.AccessKeyID, "sourceIP", clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP, failureFingerprint("unknown-prefix", sig.Credential.AccessKeyID))
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorInvalidClientTokenId)
|
||||
return
|
||||
}
|
||||
@@ -137,7 +139,10 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
"action", mismatchAction(r, sig.Credential.Service),
|
||||
"canonicalRequest", redactedCanonicalRequest(sig),
|
||||
"err", err)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
// Fingerprinted by the signature as well as the key id: each guess at a
|
||||
// secret produces a different one, while a client retrying an identical
|
||||
// bad request produces the same one.
|
||||
gw.RateLimiter.RecordFailure(clientIP, failureFingerprint("signature", sig.Credential.AccessKeyID, sig.Signature))
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch)
|
||||
return
|
||||
}
|
||||
@@ -268,7 +273,7 @@ func (gw *GatewayConfig) resolveLongLivedAKID(accessKeyID, clientIP string) (str
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), awserrors.ErrorIAMNoSuchEntity) {
|
||||
slog.Warn("Auth failure: access key not found", "accessKeyID", accessKeyID, "sourceIP", clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP, failureFingerprint("akid-not-found", accessKeyID))
|
||||
return "", principalContext{}, awserrors.ErrorInvalidClientTokenId
|
||||
}
|
||||
slog.Error("IAM lookup failed", "accessKeyID", accessKeyID, "err", err)
|
||||
@@ -276,7 +281,7 @@ func (gw *GatewayConfig) resolveLongLivedAKID(accessKeyID, clientIP string) (str
|
||||
}
|
||||
if ak.Status != handlers_iam.AccessKeyStatusActive {
|
||||
slog.Warn("Auth failure: access key inactive", "accessKeyID", accessKeyID, "sourceIP", clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP, failureFingerprint("akid-inactive", accessKeyID))
|
||||
return "", principalContext{}, awserrors.ErrorInvalidClientTokenId
|
||||
}
|
||||
secret, err := gw.IAMService.DecryptSecret(ak.SecretAccessKey)
|
||||
@@ -284,7 +289,7 @@ func (gw *GatewayConfig) resolveLongLivedAKID(accessKeyID, clientIP string) (str
|
||||
// Undecryptable secret (e.g. master key rotated): treat as auth failure, not
|
||||
// server fault, so the client re-authenticates instead of retrying a dead request.
|
||||
slog.Error("Failed to decrypt IAM secret", "accessKeyID", accessKeyID, "err", err)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP, failureFingerprint("akid-secret", accessKeyID))
|
||||
return "", principalContext{}, awserrors.ErrorInvalidClientTokenId
|
||||
}
|
||||
return secret, principalContext{
|
||||
@@ -309,13 +314,13 @@ func (gw *GatewayConfig) resolveSessionAKID(r *http.Request, accessKeyID, client
|
||||
}
|
||||
if cred == nil {
|
||||
slog.Warn("Auth failure: session credential not found", "accessKeyID", accessKeyID, "sourceIP", clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP, failureFingerprint("session-not-found", accessKeyID))
|
||||
return "", principalContext{}, awserrors.ErrorInvalidClientTokenId
|
||||
}
|
||||
if time.Now().UTC().After(cred.ExpiresAt) {
|
||||
slog.Warn("Auth failure: session credential expired",
|
||||
"accessKeyID", accessKeyID, "sourceIP", clientIP, "expiresAt", cred.ExpiresAt)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP, failureFingerprint("session-expired", accessKeyID))
|
||||
return "", principalContext{}, awserrors.ErrorExpiredToken
|
||||
}
|
||||
|
||||
@@ -323,13 +328,13 @@ func (gw *GatewayConfig) resolveSessionAKID(r *http.Request, accessKeyID, client
|
||||
if tokenHeader == "" {
|
||||
slog.Warn("Auth failure: session AKID presented without X-Amz-Security-Token",
|
||||
"accessKeyID", accessKeyID, "sourceIP", clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP, failureFingerprint("session-no-token", accessKeyID))
|
||||
return "", principalContext{}, awserrors.ErrorInvalidClientTokenId
|
||||
}
|
||||
if !gw.STSService.VerifySessionToken(cred, tokenHeader) {
|
||||
slog.Warn("Auth failure: session token HMAC mismatch",
|
||||
"accessKeyID", accessKeyID, "sourceIP", clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP, failureFingerprint("session-token-mismatch", accessKeyID, tokenDigest(tokenHeader)))
|
||||
return "", principalContext{}, awserrors.ErrorInvalidClientTokenId
|
||||
}
|
||||
|
||||
@@ -337,7 +342,7 @@ func (gw *GatewayConfig) resolveSessionAKID(r *http.Request, accessKeyID, client
|
||||
if err != nil {
|
||||
// Unverifiable secret: same auth-failure reasoning as resolveLongLivedAKID.
|
||||
slog.Error("Failed to decrypt session secret", "accessKeyID", accessKeyID, "err", err)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP, failureFingerprint("session-secret", accessKeyID))
|
||||
return "", principalContext{}, awserrors.ErrorInvalidClientTokenId
|
||||
}
|
||||
if cred.PrincipalType == principalTypeUser {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -10,18 +13,31 @@ import (
|
||||
|
||||
const (
|
||||
failureWindow = 60 * time.Second // sliding window for auth-failure counting
|
||||
maxFailures = 10 // failures within window before lockout
|
||||
maxFailures = 10 // distinct attempts within window before lockout
|
||||
initialLockout = 30 * time.Second // first lockout duration
|
||||
backoffMultiplier = 2 // lockout duration multiplier on repeat
|
||||
maxLockout = 5 * time.Minute // cap on escalating lockout
|
||||
gcInterval = 60 * time.Second // stale-entry eviction interval
|
||||
)
|
||||
|
||||
// anonymousAttempt is the fingerprint for a failure that named no credential —
|
||||
// a request rejected before its key id was parsed. There is no client identity
|
||||
// to shield from the lockout, so every occurrence counts.
|
||||
const anonymousAttempt = ""
|
||||
|
||||
// attempt is one distinct failed authentication attempt inside the window.
|
||||
// Repeating the same attempt refreshes at instead of adding another entry, so
|
||||
// the count measures how many things were tried rather than how many times.
|
||||
type attempt struct {
|
||||
fingerprint string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// ipRecord tracks auth failure state for a single client IP.
|
||||
type ipRecord struct {
|
||||
failures []time.Time // recent failure timestamps (within window)
|
||||
lockedUntil time.Time // zero = not locked
|
||||
lockouts int // lockout count for backoff calculation
|
||||
failures []attempt // distinct recent failed attempts (within window)
|
||||
lockedUntil time.Time // zero = not locked
|
||||
lockouts int // lockout count for backoff calculation
|
||||
}
|
||||
|
||||
// AuthRateLimiter tracks per-IP authentication failure rates and enforces
|
||||
@@ -75,8 +91,13 @@ func (rl *AuthRateLimiter) CheckIP(ip string) string {
|
||||
}
|
||||
|
||||
// RecordFailure records an auth failure for the IP, locking it out with
|
||||
// escalating backoff when the threshold is reached.
|
||||
func (rl *AuthRateLimiter) RecordFailure(ip string) {
|
||||
// escalating backoff once maxFailures distinct attempts fall inside the window.
|
||||
// fingerprint identifies the attempt — the key id, the signature it presented,
|
||||
// whatever the caller had to get right. Repeating one hopeless request is one
|
||||
// fault however many times it is sent, and must not lock the address out: the
|
||||
// lockout answers "retry later" to everything from that address, which a client
|
||||
// whose credential is permanently dead can never act on.
|
||||
func (rl *AuthRateLimiter) RecordFailure(ip, fingerprint string) {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
@@ -89,7 +110,7 @@ func (rl *AuthRateLimiter) RecordFailure(ip string) {
|
||||
}
|
||||
|
||||
rec.failures = pruneOldFailures(rec.failures, now)
|
||||
rec.failures = append(rec.failures, now)
|
||||
rec.failures = recordAttempt(rec.failures, fingerprint, now)
|
||||
|
||||
if len(rec.failures) >= maxFailures && (rec.lockedUntil.IsZero() || now.After(rec.lockedUntil)) {
|
||||
lockout := initialLockout
|
||||
@@ -106,12 +127,27 @@ func (rl *AuthRateLimiter) RecordFailure(ip string) {
|
||||
|
||||
slog.Warn("Rate limit: IP locked out",
|
||||
"ip", ip,
|
||||
"failures", maxFailures,
|
||||
"distinct_attempts", maxFailures,
|
||||
"lockout_duration", lockout,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// failureFingerprint identifies a failed attempt: the reason plus whatever the
|
||||
// caller had to get right for it. Two failures sharing one are the same fault
|
||||
// repeated, so only the first of them counts toward a lockout.
|
||||
func failureFingerprint(reason string, parts ...string) string {
|
||||
return reason + "\x00" + strings.Join(parts, "\x00")
|
||||
}
|
||||
|
||||
// tokenDigest reduces a presented session token to a fingerprint component.
|
||||
// Each distinct token is a distinct guess and must count, but a bearer token has
|
||||
// no business being held in rate-limiter state.
|
||||
func tokenDigest(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// RecordSuccess clears all failure state for the IP.
|
||||
func (rl *AuthRateLimiter) RecordSuccess(ip string) {
|
||||
rl.mu.RLock()
|
||||
@@ -164,15 +200,32 @@ func (rl *AuthRateLimiter) cleanup() {
|
||||
}
|
||||
}
|
||||
|
||||
// pruneOldFailures returns failures within the sliding window.
|
||||
func pruneOldFailures(failures []time.Time, now time.Time) []time.Time {
|
||||
// pruneOldFailures returns attempts within the sliding window.
|
||||
func pruneOldFailures(failures []attempt, now time.Time) []attempt {
|
||||
cutoff := now.Add(-failureWindow)
|
||||
n := 0
|
||||
for _, t := range failures {
|
||||
if t.After(cutoff) {
|
||||
failures[n] = t
|
||||
for _, a := range failures {
|
||||
if a.at.After(cutoff) {
|
||||
failures[n] = a
|
||||
n++
|
||||
}
|
||||
}
|
||||
return failures[:n]
|
||||
}
|
||||
|
||||
// recordAttempt refreshes a fingerprint already in the window, or appends it.
|
||||
// An empty fingerprint always appends: it means the failure named no credential
|
||||
// to protect, so repetition is all there is to count. The slice is scanned
|
||||
// rather than mapped — it holds at most maxFailures entries, because reaching
|
||||
// that clears it.
|
||||
func recordAttempt(failures []attempt, fingerprint string, now time.Time) []attempt {
|
||||
if fingerprint != anonymousAttempt {
|
||||
for i := range failures {
|
||||
if failures[i].fingerprint == fingerprint {
|
||||
failures[i].at = now
|
||||
return failures
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(failures, attempt{fingerprint: fingerprint, at: now})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The case this was written for: four guests holding deleted session
|
||||
// credentials sent 53,392 rejected requests and locked their addresses out for
|
||||
// five days. One credential presented 14,000 times is one fault, and the
|
||||
// lockout tells a client to retry later when it can never succeed.
|
||||
func TestRecordFailureIgnoresARepeatedAttempt(t *testing.T) {
|
||||
rl := NewAuthRateLimiter()
|
||||
defer rl.Stop()
|
||||
|
||||
const ip = "10.15.8.11"
|
||||
stale := failureFingerprint("session-not-found", "ASIASTALEKEY00000000")
|
||||
for range maxFailures * 5 {
|
||||
rl.RecordFailure(ip, stale)
|
||||
}
|
||||
|
||||
assert.Empty(t, rl.CheckIP(ip), "one attempt repeated must never lock the address out")
|
||||
|
||||
rl.mu.RLock()
|
||||
defer rl.mu.RUnlock()
|
||||
assert.Len(t, rl.records[ip].failures, 1, "the window holds one entry per distinct attempt")
|
||||
}
|
||||
|
||||
// Guessing has to stay rate limited: whatever the attacker varies is what the
|
||||
// fingerprint carries, so each guess is a fresh attempt.
|
||||
func TestRecordFailureLocksOutDistinctAttempts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fp func(i int) string
|
||||
}{
|
||||
{"key ids", func(i int) string {
|
||||
return failureFingerprint("akid-not-found", fmt.Sprintf("AKIAGUESS%d", i))
|
||||
}},
|
||||
{"signatures for one key", func(i int) string {
|
||||
return failureFingerprint("signature", "AKIAVALIDKEY00000000", fmt.Sprintf("sig-%d", i))
|
||||
}},
|
||||
{"session tokens for one key", func(i int) string {
|
||||
return failureFingerprint("session-token-mismatch", "ASIAVALIDKEY00000000", tokenDigest(fmt.Sprintf("token-%d", i)))
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rl := NewAuthRateLimiter()
|
||||
defer rl.Stop()
|
||||
|
||||
ip := "10.15.9." + strconv.Itoa(len(tc.name))
|
||||
for i := range maxFailures {
|
||||
rl.RecordFailure(ip, tc.fp(i))
|
||||
}
|
||||
|
||||
assert.Equal(t, awserrors.ErrorRequestLimitExceeded, rl.CheckIP(ip))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A repeat refreshes the entry it matches. Without that a client retrying
|
||||
// steadily would drop out of the window and re-enter it, which is harmless here
|
||||
// but would make the count depend on retry timing rather than on what was tried.
|
||||
func TestRecordFailureRefreshesARepeatedAttempt(t *testing.T) {
|
||||
rl := NewAuthRateLimiter()
|
||||
defer rl.Stop()
|
||||
|
||||
const ip = "10.15.8.12"
|
||||
fp := failureFingerprint("session-not-found", "ASIASTALEKEY00000000")
|
||||
rl.RecordFailure(ip, fp)
|
||||
|
||||
rl.mu.Lock()
|
||||
rl.records[ip].failures[0].at = time.Now().Add(-failureWindow / 2)
|
||||
rl.mu.Unlock()
|
||||
|
||||
rl.RecordFailure(ip, fp)
|
||||
|
||||
rl.mu.RLock()
|
||||
defer rl.mu.RUnlock()
|
||||
require.Len(t, rl.records[ip].failures, 1)
|
||||
assert.WithinDuration(t, time.Now(), rl.records[ip].failures[0].at, time.Second)
|
||||
}
|
||||
|
||||
// End to end: a client whose credential will never resolve keeps getting the
|
||||
// verdict that says so, and never the 503 that tells it to retry.
|
||||
func TestStaleCredentialNeverLocksTheAddressOut(t *testing.T) {
|
||||
handler, _ := auditRouter(t, map[string]*handlers_iam.AccessKey{})
|
||||
|
||||
for range maxFailures * 3 {
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, signedRequest("10.15.8.14:54321"))
|
||||
require.Equal(t, http.StatusForbidden, w.Code, "a dead credential is a client fault, not throttling")
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +19,15 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// distinctFingerprint returns a fingerprint no other attempt shares, which is
|
||||
// the shape of credential guessing: the lockout counts distinct attempts, so a
|
||||
// test driving it to the threshold must vary them.
|
||||
func distinctFingerprint() string {
|
||||
return failureFingerprint("test", strconv.Itoa(int(fingerprintSeq.Add(1))))
|
||||
}
|
||||
|
||||
var fingerprintSeq atomic.Int64
|
||||
|
||||
func TestCheckIP_AllowsUnknownIP(t *testing.T) {
|
||||
rl := NewAuthRateLimiter()
|
||||
defer rl.Stop()
|
||||
@@ -32,7 +43,7 @@ func TestRecordFailure_BelowThreshold(t *testing.T) {
|
||||
|
||||
ip := "10.0.0.2"
|
||||
for range maxFailures - 1 {
|
||||
rl.RecordFailure(ip)
|
||||
rl.RecordFailure(ip, distinctFingerprint())
|
||||
}
|
||||
|
||||
if errCode := rl.CheckIP(ip); errCode != "" {
|
||||
@@ -46,7 +57,7 @@ func TestRecordFailure_AtThreshold(t *testing.T) {
|
||||
|
||||
ip := "10.0.0.3"
|
||||
for range maxFailures {
|
||||
rl.RecordFailure(ip)
|
||||
rl.RecordFailure(ip, distinctFingerprint())
|
||||
}
|
||||
|
||||
if errCode := rl.CheckIP(ip); errCode != awserrors.ErrorRequestLimitExceeded {
|
||||
@@ -60,7 +71,7 @@ func TestCheckIP_RejectsLockedIP(t *testing.T) {
|
||||
|
||||
ip := "10.0.0.4"
|
||||
for range maxFailures {
|
||||
rl.RecordFailure(ip)
|
||||
rl.RecordFailure(ip, distinctFingerprint())
|
||||
}
|
||||
|
||||
if errCode := rl.CheckIP(ip); errCode != awserrors.ErrorRequestLimitExceeded {
|
||||
@@ -79,14 +90,14 @@ func TestRecordSuccess_ClearsState(t *testing.T) {
|
||||
ip := "10.0.0.5"
|
||||
// Accumulate failures but stay below threshold.
|
||||
for range maxFailures - 1 {
|
||||
rl.RecordFailure(ip)
|
||||
rl.RecordFailure(ip, distinctFingerprint())
|
||||
}
|
||||
|
||||
rl.RecordSuccess(ip)
|
||||
|
||||
// After success, all state should be cleared — can accumulate failures again from 0.
|
||||
for range maxFailures - 1 {
|
||||
rl.RecordFailure(ip)
|
||||
rl.RecordFailure(ip, distinctFingerprint())
|
||||
}
|
||||
|
||||
if errCode := rl.CheckIP(ip); errCode != "" {
|
||||
@@ -100,7 +111,7 @@ func TestRecordSuccess_ClearsLockout(t *testing.T) {
|
||||
|
||||
ip := "10.0.0.6"
|
||||
for range maxFailures {
|
||||
rl.RecordFailure(ip)
|
||||
rl.RecordFailure(ip, distinctFingerprint())
|
||||
}
|
||||
|
||||
if errCode := rl.CheckIP(ip); errCode == "" {
|
||||
@@ -122,7 +133,7 @@ func TestEscalatingBackoff(t *testing.T) {
|
||||
|
||||
// First lockout: 30s
|
||||
for range maxFailures {
|
||||
rl.RecordFailure(ip)
|
||||
rl.RecordFailure(ip, distinctFingerprint())
|
||||
}
|
||||
|
||||
rl.mu.Lock()
|
||||
@@ -141,7 +152,7 @@ func TestEscalatingBackoff(t *testing.T) {
|
||||
rl.mu.Unlock()
|
||||
|
||||
for range maxFailures {
|
||||
rl.RecordFailure(ip)
|
||||
rl.RecordFailure(ip, distinctFingerprint())
|
||||
}
|
||||
|
||||
rl.mu.Lock()
|
||||
@@ -160,7 +171,7 @@ func TestEscalatingBackoff(t *testing.T) {
|
||||
rl.mu.Unlock()
|
||||
|
||||
for range maxFailures {
|
||||
rl.RecordFailure(ip)
|
||||
rl.RecordFailure(ip, distinctFingerprint())
|
||||
}
|
||||
|
||||
rl.mu.Lock()
|
||||
@@ -179,7 +190,7 @@ func TestEscalatingBackoff(t *testing.T) {
|
||||
rl.mu.Unlock()
|
||||
|
||||
for range maxFailures {
|
||||
rl.RecordFailure(ip)
|
||||
rl.RecordFailure(ip, distinctFingerprint())
|
||||
}
|
||||
|
||||
rl.mu.Lock()
|
||||
@@ -198,7 +209,7 @@ func TestEscalatingBackoff(t *testing.T) {
|
||||
rl.mu.Unlock()
|
||||
|
||||
for range maxFailures {
|
||||
rl.RecordFailure(ip)
|
||||
rl.RecordFailure(ip, distinctFingerprint())
|
||||
}
|
||||
|
||||
rl.mu.Lock()
|
||||
@@ -221,13 +232,13 @@ func TestFailureWindowSliding(t *testing.T) {
|
||||
rec := &ipRecord{}
|
||||
oldTime := time.Now().Add(-failureWindow - time.Second)
|
||||
for range maxFailures - 1 {
|
||||
rec.failures = append(rec.failures, oldTime)
|
||||
rec.failures = append(rec.failures, attempt{fingerprint: distinctFingerprint(), at: oldTime})
|
||||
}
|
||||
rl.records[ip] = rec
|
||||
rl.mu.Unlock()
|
||||
|
||||
// Add one recent failure — total "recent" failures should be just 1.
|
||||
rl.RecordFailure(ip)
|
||||
rl.RecordFailure(ip, distinctFingerprint())
|
||||
|
||||
if errCode := rl.CheckIP(ip); errCode != "" {
|
||||
t.Fatalf("expected IP to be allowed (old failures expired), got %q", errCode)
|
||||
@@ -243,7 +254,7 @@ func TestCleanup_EvictsStaleEntries(t *testing.T) {
|
||||
// Insert a stale entry: lockout expired and all failures old.
|
||||
rl.mu.Lock()
|
||||
rl.records[ip] = &ipRecord{
|
||||
failures: []time.Time{time.Now().Add(-failureWindow - time.Second)},
|
||||
failures: []attempt{{fingerprint: "stale", at: time.Now().Add(-failureWindow - time.Second)}},
|
||||
lockedUntil: time.Now().Add(-time.Second),
|
||||
lockouts: 1,
|
||||
}
|
||||
@@ -269,7 +280,7 @@ func TestCleanup_KeepsActiveEntries(t *testing.T) {
|
||||
// Insert an entry that's still locked.
|
||||
rl.mu.Lock()
|
||||
rl.records[ip] = &ipRecord{
|
||||
failures: []time.Time{time.Now()},
|
||||
failures: []attempt{{fingerprint: "recent", at: time.Now()}},
|
||||
lockedUntil: time.Now().Add(30 * time.Second),
|
||||
lockouts: 1,
|
||||
}
|
||||
@@ -297,7 +308,7 @@ func TestConcurrentAccess(t *testing.T) {
|
||||
for range 20 {
|
||||
wg.Go(func() {
|
||||
rl.CheckIP(ip)
|
||||
rl.RecordFailure(ip)
|
||||
rl.RecordFailure(ip, distinctFingerprint())
|
||||
rl.RecordSuccess(ip)
|
||||
rl.CheckIP(ip)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -117,10 +118,16 @@ func TestRequestAuditRecordsUnauthenticatedRejection(t *testing.T) {
|
||||
// parses: the credential scope date must match X-Amz-Date or the request is
|
||||
// rejected as malformed before the access key is ever looked at.
|
||||
func signedRequest(remoteAddr string) *http.Request {
|
||||
return signedRequestForKey(remoteAddr, "AKIAINVALIDKEY000000")
|
||||
}
|
||||
|
||||
// signedRequestForKey is signedRequest with the presented key id chosen by the
|
||||
// caller, so a test can send distinct attempts rather than one repeated.
|
||||
func signedRequestForKey(remoteAddr, accessKeyID string) *http.Request {
|
||||
now := time.Now().UTC()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = remoteAddr
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIAINVALIDKEY000000/"+
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+accessKeyID+"/"+
|
||||
now.Format("20060102")+"/"+testRegion+"/ec2/aws4_request, "+
|
||||
"SignedHeaders=host;x-amz-date, Signature="+
|
||||
"0000000000000000000000000000000000000000000000000000000000000000")
|
||||
@@ -147,16 +154,17 @@ func TestRequestAuditRecordsRejectedAccessKey(t *testing.T) {
|
||||
func TestRequestAuditRecordsRateLimitLockout(t *testing.T) {
|
||||
handler, audit := auditRouter(t, map[string]*handlers_iam.AccessKey{})
|
||||
|
||||
send := func() int {
|
||||
// Distinct key ids: one key id repeated is one fault and never locks out.
|
||||
send := func(accessKeyID string) int {
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, signedRequest("10.15.8.13:54321"))
|
||||
handler.ServeHTTP(w, signedRequestForKey("10.15.8.13:54321", accessKeyID))
|
||||
return w.Code
|
||||
}
|
||||
|
||||
for range maxFailures {
|
||||
send()
|
||||
for i := range maxFailures {
|
||||
send(fmt.Sprintf("AKIAGUESS%011d", i))
|
||||
}
|
||||
assert.Equal(t, http.StatusServiceUnavailable, send())
|
||||
assert.Equal(t, http.StatusServiceUnavailable, send("AKIAINVALIDKEY000000"))
|
||||
|
||||
require.NotNil(t, audit())
|
||||
assert.Equal(t, "10.15.8.13", audit().clientIP)
|
||||
|
||||
Reference in New Issue
Block a user