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:
Josh Siviour
2026-08-18 07:54:09 +10:00
committed by GitHub
parent fc04fb9da7
commit 2ced9154bd
5 changed files with 228 additions and 47 deletions
+17 -12
View File
@@ -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 {
+66 -13
View File
@@ -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})
}
+104
View File
@@ -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")
}
}
+27 -16
View File
@@ -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)
})
+14 -6
View File
@@ -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)