From 2ced9154bdc3ac35ffdb2f2391256e6d37da95b0 Mon Sep 17 00:00:00 2001 From: Josh Siviour Date: Tue, 18 Aug 2026 07:54:09 +1000 Subject: [PATCH] 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. --- spinifex/gateway/auth.go | 29 +++--- spinifex/gateway/ratelimit.go | 79 ++++++++++++--- spinifex/gateway/ratelimit_attempts_test.go | 104 ++++++++++++++++++++ spinifex/gateway/ratelimit_test.go | 43 +++++--- spinifex/gateway/requestaudit_test.go | 20 ++-- 5 files changed, 228 insertions(+), 47 deletions(-) create mode 100644 spinifex/gateway/ratelimit_attempts_test.go diff --git a/spinifex/gateway/auth.go b/spinifex/gateway/auth.go index 5b20ba9a9..e6b3d092f 100644 --- a/spinifex/gateway/auth.go +++ b/spinifex/gateway/auth.go @@ -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 { diff --git a/spinifex/gateway/ratelimit.go b/spinifex/gateway/ratelimit.go index 0c9259b9c..9f850b49d 100644 --- a/spinifex/gateway/ratelimit.go +++ b/spinifex/gateway/ratelimit.go @@ -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}) +} diff --git a/spinifex/gateway/ratelimit_attempts_test.go b/spinifex/gateway/ratelimit_attempts_test.go new file mode 100644 index 000000000..3fe3cdf3e --- /dev/null +++ b/spinifex/gateway/ratelimit_attempts_test.go @@ -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") + } +} diff --git a/spinifex/gateway/ratelimit_test.go b/spinifex/gateway/ratelimit_test.go index 2157acf48..138d5cc53 100644 --- a/spinifex/gateway/ratelimit_test.go +++ b/spinifex/gateway/ratelimit_test.go @@ -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) }) diff --git a/spinifex/gateway/requestaudit_test.go b/spinifex/gateway/requestaudit_test.go index edc062f72..247a6dd77 100644 --- a/spinifex/gateway/requestaudit_test.go +++ b/spinifex/gateway/requestaudit_test.go @@ -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)