feat(gateway): per-IP auth failure rate limiting (#138)

This commit is contained in:
Julian Sommer
2026-04-15 09:56:04 +10:00
committed by GitHub
parent b7b639084b
commit 1ad03bb518
8 changed files with 681 additions and 10 deletions
+1 -5
View File
@@ -51,7 +51,7 @@ if [ -d /var/lib/spinifex/config ]; then
fi
echo "=== Running setup.sh (creates users, dirs, systemd units) ==="
sudo INSTALL_SPINIFEX_TARBALL=/tmp/spinifex-local.tar.gz bash "$PROJECT_ROOT/scripts/setup.sh"
sudo INSTALL_SPINIFEX_TARBALL=/tmp/spinifex-local.tar.gz INSTALL_SPINIFEX_SKIP_NEWGRP=1 bash "$PROJECT_ROOT/scripts/setup.sh"
rm -f /tmp/spinifex-local.tar.gz
echo "=== Setting up OVN ==="
@@ -70,10 +70,6 @@ sudo systemctl start spinifex.target
# Check if user needs to re-login for spinifex group membership
if ! id -Gn 2>/dev/null | grep -qw spinifex; then
newgrp spinifex
# echo ""
# echo "NOTE: You were added to the 'spinifex' group but your current"
# echo "session doesn't reflect it yet. Log out and back in, or run:"
# echo " newgrp spinifex"
fi
echo "=== Building and importing LB image ==="
+3 -1
View File
@@ -8,6 +8,7 @@
# INSTALL_SPINIFEX_TARBALL Path to local tarball (skips download, for testing/air-gapped)
# INSTALL_SPINIFEX_SKIP_APT Set to 1 to skip apt dependency install
# INSTALL_SPINIFEX_SKIP_AWS Set to 1 to skip AWS CLI install
# INSTALL_SPINIFEX_SKIP_NEWGRP Set to 1 to skip newgrp exec at end (for callers like dev-install.sh)
set -e
@@ -571,7 +572,8 @@ main() {
# stdin is the drained pipe, so redirect from /dev/tty and exec so the new
# shell becomes the foreground process. Skip when we can't actually open
# /dev/tty (CI, cloud-init, ssh -T — stat passes but open fails with ENXIO).
if ! id -Gn 2>/dev/null | grep -qw "$SPINIFEX_GROUP" \
if [ "${INSTALL_SPINIFEX_SKIP_NEWGRP:-0}" != "1" ] \
&& ! id -Gn 2>/dev/null | grep -qw "$SPINIFEX_GROUP" \
&& ( : </dev/tty ) 2>/dev/null; then
echo ""
echo " Activating '$SPINIFEX_GROUP' group in a subshell — type 'exit' when done."
+1 -1
View File
@@ -804,7 +804,7 @@ var ErrorLookup = map[string]ErrorMessage{
ErrorPrivateIpAddressLimitExceeded: {HTTPCode: 400, Message: "You've reached the limit on the number of private IP addresses that you can assign to the specified network interface for that type of instance. For more information, see IP addresses per network interface."},
ErrorRequestEntityTooLarge: {HTTPCode: 413, Message: "Request body exceeds the maximum allowed size."},
ErrorRequestExpired: {HTTPCode: 403, Message: "The request reached the service more than 15 minutes after the date stamp on the request or more than 15 minutes after the request expiration date (such as for presigned URLs), or the date stamp on the request is more than 15 minutes in the future. If you're using temporary security credentials, this error can also occur if the credentials have expired. For more information, see Temporary security credentials in the IAM User Guide."},
ErrorRequestLimitExceeded: {HTTPCode: 500, Message: "The maximum request rate permitted by the Amazon EC2 APIs has been exceeded for your account. For best results, use an increasing or variable sleep interval between requests. For more information, see Query API request rate."},
ErrorRequestLimitExceeded: {HTTPCode: 503, Message: "The maximum request rate permitted by the Amazon EC2 APIs has been exceeded for your account. For best results, use an increasing or variable sleep interval between requests. For more information, see Query API request rate."},
ErrorRequestResourceCountExceeded: {HTTPCode: 409, Message: "Details in your Spot request exceed the numbers allowed by the Spot service in one of the following ways, depending on the action that generated the error: \u2014If you get this error when you submitted a request for Spot Instances, check the number of Spot Instances specified in your request. The number shouldn't exceed the 3,000 maximum allowed per request. Resend your Spot Instance request and specify a number less than 3,000. If your account's regional Spot request limit is greater than 3,000 instances, you can access these instances by submitting multiple smaller requests. \u2014If you get this error when you sent Describe Spot Instance requests, check the number of requests for Spot Instance data, the amount of data you requested, and how often you sent the request. The frequency with which you requested the data combined with the amount of data exceeds the levels allowed by the Spot service. Try again and submit fewer large Describe requests over longer intervals."},
ErrorReservationCapacityExceeded: {HTTPCode: 400, Message: "The targeted Capacity Reservation does not enough available instance capacity to fulfill your request. Either increase the instance capacity for the targeted Capacity Reservation, or target a different Capacity Reservation."},
ErrorReservedInstancesCountExceeded: {HTTPCode: 400, Message: "You've reached the limit for the number of Reserved Instances."},
+1 -1
View File
@@ -347,7 +347,7 @@ func TestErrorLookup(t *testing.T) {
{code: "PrivateIpAddressLimitExceeded", http: 400, message: "You've reached the limit on the number of private IP addresses that you can assign to the specified network interface for that type of instance. For more information, see IP addresses per network interface."},
{code: "RequestEntityTooLarge", http: 413, message: "Request body exceeds the maximum allowed size."},
{code: "RequestExpired", http: 403, message: "The request reached the service more than 15 minutes after the date stamp on the request or more than 15 minutes after the request expiration date (such as for presigned URLs), or the date stamp on the request is more than 15 minutes in the future. If you're using temporary security credentials, this error can also occur if the credentials have expired. For more information, see Temporary security credentials in the IAM User Guide."},
{code: "RequestLimitExceeded", http: 500, message: "The maximum request rate permitted by the Amazon EC2 APIs has been exceeded for your account. For best results, use an increasing or variable sleep interval between requests. For more information, see Query API request rate."},
{code: "RequestLimitExceeded", http: 503, message: "The maximum request rate permitted by the Amazon EC2 APIs has been exceeded for your account. For best results, use an increasing or variable sleep interval between requests. For more information, see Query API request rate."},
{code: "RequestResourceCountExceeded", http: 409, message: "Details in your Spot request exceed the numbers allowed by the Spot service in one of the following ways, depending on the action that generated the error: \u2014If you get this error when you submitted a request for Spot Instances, check the number of Spot Instances specified in your request. The number shouldn't exceed the 3,000 maximum allowed per request. Resend your Spot Instance request and specify a number less than 3,000. If your account's regional Spot request limit is greater than 3,000 instances, you can access these instances by submitting multiple smaller requests. \u2014If you get this error when you sent Describe Spot Instance requests, check the number of requests for Spot Instance data, the amount of data you requested, and how often you sent the request. The frequency with which you requested the data combined with the amount of data exceeds the levels allowed by the Spot service. Try again and submit fewer large Describe requests over longer intervals."},
{code: "ReservationCapacityExceeded", http: 400, message: "The targeted Capacity Reservation does not enough available instance capacity to fulfill your request. Either increase the instance capacity for the targeted Capacity Reservation, or target a different Capacity Reservation."},
{code: "ReservedInstancesCountExceeded", http: 400, message: "You've reached the limit for the number of Reserved Instances."},
+14
View File
@@ -29,8 +29,18 @@ const (
// SigV4AuthMiddleware returns stdlib middleware that validates AWS Signature V4 authentication.
func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
if gw.RateLimiter == nil {
gw.RateLimiter = NewAuthRateLimiter()
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Rate-limit check: reject locked-out IPs before any crypto work.
clientIP := extractClientIP(r)
if errCode := gw.RateLimiter.CheckIP(clientIP); errCode != "" {
gw.writeSigV4Error(w, r, errCode)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
gw.writeSigV4Error(w, r, awserrors.ErrorMissingAuthenticationToken)
@@ -97,6 +107,7 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
if err != nil {
if strings.Contains(err.Error(), awserrors.ErrorIAMNoSuchEntity) {
slog.Debug("Access key not found", "accessKeyID", accessKey)
gw.RateLimiter.RecordFailure(clientIP)
gw.writeSigV4Error(w, r, awserrors.ErrorInvalidClientTokenId)
return
}
@@ -106,6 +117,7 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
}
if ak.Status != handlers_iam.AccessKeyStatusActive {
slog.Debug("Access key inactive", "accessKeyID", accessKey)
gw.RateLimiter.RecordFailure(clientIP)
gw.writeSigV4Error(w, r, awserrors.ErrorInvalidClientTokenId)
return
}
@@ -163,6 +175,7 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
slog.Debug("Signature mismatch",
"accessKeyId", accessKey,
)
gw.RateLimiter.RecordFailure(clientIP)
gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch)
return
}
@@ -176,6 +189,7 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
ctx = context.WithValue(ctx, ctxAccessKey, accessKey)
slog.Debug("SigV4 authentication successful", "accessKey", accessKey, "identity", ak.UserName)
gw.RateLimiter.RecordSuccess(clientIP)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
+8 -2
View File
@@ -43,8 +43,9 @@ type GatewayConfig struct {
Region string // Region this gateway is running in
AZ string // Availability zone this gateway is running in
IAMService handlers_iam.IAMService
Version string // Build-time version string (set from cmd.Version)
Commit string // Build-time commit hash (set from cmd.Commit)
RateLimiter *AuthRateLimiter // Per-IP auth failure rate limiter
Version string // Build-time version string (set from cmd.Version)
Commit string // Build-time commit hash (set from cmd.Commit)
}
var supportedServices = map[string]bool{
@@ -91,6 +92,11 @@ func (gw *GatewayConfig) SetupRoutes() http.Handler {
// Set it as the default logger
slog.SetDefault(slogger)
// Initialize auth rate limiter if not already set.
if gw.RateLimiter == nil {
gw.RateLimiter = NewAuthRateLimiter()
}
r := chi.NewRouter()
if !gw.DisableLogging {
+212
View File
@@ -0,0 +1,212 @@
package gateway
import (
"log/slog"
"net"
"net/http"
"sync"
"time"
"github.com/mulgadc/spinifex/spinifex/awserrors"
)
const (
// failureWindow is the sliding window for counting auth failures.
failureWindow = 60 * time.Second
// maxFailures is the number of consecutive failures within the window before lockout.
maxFailures = 10
// initialLockout is the first lockout duration after hitting the threshold.
initialLockout = 30 * time.Second
// backoffMultiplier scales the lockout duration on repeated lockouts.
backoffMultiplier = 2
// maxLockout caps the escalating lockout duration.
maxLockout = 5 * time.Minute
// gcInterval is how often stale entries are evicted.
gcInterval = 60 * time.Second
)
// ipRecord tracks auth failure state for a single client IP.
type ipRecord struct {
failures []time.Time // timestamps of recent failures (within window)
lockedUntil time.Time // zero value = not locked
lockouts int // number of times this IP has been locked out (for backoff)
}
// AuthRateLimiter tracks per-IP authentication failure rates and enforces
// escalating lockouts after repeated failures.
type AuthRateLimiter struct {
mu sync.RWMutex
records map[string]*ipRecord
stop chan struct{}
done chan struct{}
}
// NewAuthRateLimiter creates and starts an AuthRateLimiter with background GC.
// Call Stop to shut down the GC goroutine.
func NewAuthRateLimiter() *AuthRateLimiter {
rl := &AuthRateLimiter{
records: make(map[string]*ipRecord),
stop: make(chan struct{}),
done: make(chan struct{}),
}
go rl.gcLoop()
return rl
}
// Stop cancels the background GC goroutine and waits for it to exit.
func (rl *AuthRateLimiter) Stop() {
select {
case <-rl.stop:
// Already stopped.
default:
close(rl.stop)
}
<-rl.done
}
// CheckIP returns an empty string if the IP is allowed to proceed, or an error
// code matching ErrorRequestLimitExceeded if the IP is currently locked out.
func (rl *AuthRateLimiter) CheckIP(ip string) string {
rl.mu.RLock()
defer rl.mu.RUnlock()
rec, ok := rl.records[ip]
if !ok {
return ""
}
if !rec.lockedUntil.IsZero() && time.Now().Before(rec.lockedUntil) {
slog.Debug("Rate limit: rejecting locked IP", "ip", ip, "locked_until", rec.lockedUntil)
return awserrors.ErrorRequestLimitExceeded
}
return ""
}
// RecordFailure records an authentication failure for the given IP. If the
// failure count within the sliding window reaches the threshold, the IP is
// locked out with escalating backoff.
func (rl *AuthRateLimiter) RecordFailure(ip string) {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
rec, ok := rl.records[ip]
if !ok {
rec = &ipRecord{}
rl.records[ip] = rec
}
// Prune failures outside the sliding window.
rec.failures = pruneOldFailures(rec.failures, now)
rec.failures = append(rec.failures, now)
if len(rec.failures) >= maxFailures && (rec.lockedUntil.IsZero() || now.After(rec.lockedUntil)) {
// Calculate lockout duration with escalating backoff.
lockout := initialLockout
for range rec.lockouts {
lockout *= time.Duration(backoffMultiplier)
if lockout >= maxLockout {
lockout = maxLockout
break
}
}
rec.lockedUntil = now.Add(lockout)
rec.lockouts++
rec.failures = nil // Reset for the next window after lockout.
slog.Warn("Rate limit: IP locked out",
"ip", ip,
"failures", maxFailures,
"lockout_duration", lockout,
)
}
}
// RecordSuccess clears all failure state for the given IP, immediately
// restoring access for legitimate clients.
func (rl *AuthRateLimiter) RecordSuccess(ip string) {
rl.mu.RLock()
_, ok := rl.records[ip]
rl.mu.RUnlock()
if !ok {
return
}
rl.mu.Lock()
defer rl.mu.Unlock()
if _, ok := rl.records[ip]; ok {
slog.Info("Rate limit: IP lockout cleared on success", "ip", ip)
delete(rl.records, ip)
}
}
// gcLoop runs cleanup on a fixed interval until Stop is called.
func (rl *AuthRateLimiter) gcLoop() {
defer close(rl.done)
ticker := time.NewTicker(gcInterval)
defer ticker.Stop()
for {
select {
case <-rl.stop:
return
case <-ticker.C:
rl.cleanup()
}
}
}
// cleanup evicts stale entries whose lockout has expired and whose failures
// are all outside the sliding window.
func (rl *AuthRateLimiter) cleanup() {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
for ip, rec := range rl.records {
// Skip if still locked.
if !rec.lockedUntil.IsZero() && now.Before(rec.lockedUntil) {
continue
}
// Prune old failures.
rec.failures = pruneOldFailures(rec.failures, now)
if len(rec.failures) == 0 {
slog.Debug("Rate limit: GC evicted stale entry", "ip", ip)
delete(rl.records, ip)
}
}
}
// extractClientIP returns the IP address from the request's RemoteAddr,
// stripping the port. Handles both IPv4 and IPv6.
func extractClientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
// RemoteAddr might already be bare IP (no port).
return r.RemoteAddr
}
return host
}
// pruneOldFailures returns only the failures within the sliding window.
func pruneOldFailures(failures []time.Time, now time.Time) []time.Time {
cutoff := now.Add(-failureWindow)
n := 0
for _, t := range failures {
if t.After(cutoff) {
failures[n] = t
n++
}
}
return failures[:n]
}
+441
View File
@@ -0,0 +1,441 @@
package gateway
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
)
func TestCheckIP_AllowsUnknownIP(t *testing.T) {
rl := NewAuthRateLimiter()
defer rl.Stop()
if errCode := rl.CheckIP("10.0.0.1"); errCode != "" {
t.Fatalf("expected empty error for unknown IP, got %q", errCode)
}
}
func TestRecordFailure_BelowThreshold(t *testing.T) {
rl := NewAuthRateLimiter()
defer rl.Stop()
ip := "10.0.0.2"
for range maxFailures - 1 {
rl.RecordFailure(ip)
}
if errCode := rl.CheckIP(ip); errCode != "" {
t.Fatalf("expected IP to be allowed after %d failures, got %q", maxFailures-1, errCode)
}
}
func TestRecordFailure_AtThreshold(t *testing.T) {
rl := NewAuthRateLimiter()
defer rl.Stop()
ip := "10.0.0.3"
for range maxFailures {
rl.RecordFailure(ip)
}
if errCode := rl.CheckIP(ip); errCode != awserrors.ErrorRequestLimitExceeded {
t.Fatalf("expected %s after %d failures, got %q", awserrors.ErrorRequestLimitExceeded, maxFailures, errCode)
}
}
func TestCheckIP_RejectsLockedIP(t *testing.T) {
rl := NewAuthRateLimiter()
defer rl.Stop()
ip := "10.0.0.4"
for range maxFailures {
rl.RecordFailure(ip)
}
// Should be locked.
if errCode := rl.CheckIP(ip); errCode != awserrors.ErrorRequestLimitExceeded {
t.Fatalf("expected locked IP to be rejected, got %q", errCode)
}
// Repeated checks should also be rejected.
if errCode := rl.CheckIP(ip); errCode != awserrors.ErrorRequestLimitExceeded {
t.Fatalf("expected locked IP to still be rejected, got %q", errCode)
}
}
func TestRecordSuccess_ClearsState(t *testing.T) {
rl := NewAuthRateLimiter()
defer rl.Stop()
ip := "10.0.0.5"
// Accumulate failures but stay below threshold.
for range maxFailures - 1 {
rl.RecordFailure(ip)
}
rl.RecordSuccess(ip)
// After success, all state should be cleared — can accumulate failures again from 0.
for range maxFailures - 1 {
rl.RecordFailure(ip)
}
if errCode := rl.CheckIP(ip); errCode != "" {
t.Fatalf("expected IP to be allowed after success reset, got %q", errCode)
}
}
func TestRecordSuccess_ClearsLockout(t *testing.T) {
rl := NewAuthRateLimiter()
defer rl.Stop()
ip := "10.0.0.6"
for range maxFailures {
rl.RecordFailure(ip)
}
// Should be locked.
if errCode := rl.CheckIP(ip); errCode == "" {
t.Fatal("expected IP to be locked")
}
rl.RecordSuccess(ip)
// After success, should be unlocked.
if errCode := rl.CheckIP(ip); errCode != "" {
t.Fatalf("expected IP to be allowed after success, got %q", errCode)
}
}
func TestEscalatingBackoff(t *testing.T) {
rl := NewAuthRateLimiter()
defer rl.Stop()
ip := "10.0.0.7"
// First lockout: 30s
for range maxFailures {
rl.RecordFailure(ip)
}
rl.mu.Lock()
rec := rl.records[ip]
firstLockout := time.Until(rec.lockedUntil)
rl.mu.Unlock()
if firstLockout > initialLockout+time.Second || firstLockout < initialLockout-time.Second {
t.Fatalf("expected first lockout ~%v, got %v", initialLockout, firstLockout)
}
// Simulate lockout expiry and trigger second lockout.
rl.mu.Lock()
rec.lockedUntil = time.Now().Add(-time.Second) // expired
rec.failures = nil // reset failures for next round
rl.mu.Unlock()
for range maxFailures {
rl.RecordFailure(ip)
}
rl.mu.Lock()
secondLockout := time.Until(rec.lockedUntil)
rl.mu.Unlock()
expectedSecond := initialLockout * backoffMultiplier
if secondLockout > expectedSecond+time.Second || secondLockout < expectedSecond-time.Second {
t.Fatalf("expected second lockout ~%v, got %v", expectedSecond, secondLockout)
}
// Third lockout: 120s
rl.mu.Lock()
rec.lockedUntil = time.Now().Add(-time.Second)
rec.failures = nil
rl.mu.Unlock()
for range maxFailures {
rl.RecordFailure(ip)
}
rl.mu.Lock()
thirdLockout := time.Until(rec.lockedUntil)
rl.mu.Unlock()
expectedThird := initialLockout * backoffMultiplier * backoffMultiplier
if thirdLockout > expectedThird+time.Second || thirdLockout < expectedThird-time.Second {
t.Fatalf("expected third lockout ~%v, got %v", expectedThird, thirdLockout)
}
// Fourth lockout: 30s * 2^3 = 240s = 4m.
rl.mu.Lock()
rec.lockedUntil = time.Now().Add(-time.Second)
rec.failures = nil
rl.mu.Unlock()
for range maxFailures {
rl.RecordFailure(ip)
}
rl.mu.Lock()
fourthLockout := time.Until(rec.lockedUntil)
rl.mu.Unlock()
expectedFourth := initialLockout * backoffMultiplier * backoffMultiplier * backoffMultiplier
if fourthLockout > expectedFourth+time.Second || fourthLockout < expectedFourth-time.Second {
t.Fatalf("expected fourth lockout ~%v, got %v", expectedFourth, fourthLockout)
}
// Fifth lockout: 30s * 2^4 = 480s, capped at maxLockout (300s).
rl.mu.Lock()
rec.lockedUntil = time.Now().Add(-time.Second)
rec.failures = nil
rl.mu.Unlock()
for range maxFailures {
rl.RecordFailure(ip)
}
rl.mu.Lock()
fifthLockout := time.Until(rec.lockedUntil)
rl.mu.Unlock()
if fifthLockout > maxLockout+time.Second || fifthLockout < maxLockout-time.Second {
t.Fatalf("expected fifth lockout to cap at ~%v, got %v", maxLockout, fifthLockout)
}
}
func TestFailureWindowSliding(t *testing.T) {
rl := NewAuthRateLimiter()
defer rl.Stop()
ip := "10.0.0.8"
// Inject failures that are outside the sliding window.
rl.mu.Lock()
rec := &ipRecord{}
oldTime := time.Now().Add(-failureWindow - time.Second)
for range maxFailures - 1 {
rec.failures = append(rec.failures, oldTime)
}
rl.records[ip] = rec
rl.mu.Unlock()
// Add one recent failure — total "recent" failures should be just 1.
rl.RecordFailure(ip)
if errCode := rl.CheckIP(ip); errCode != "" {
t.Fatalf("expected IP to be allowed (old failures expired), got %q", errCode)
}
}
func TestCleanup_EvictsStaleEntries(t *testing.T) {
rl := NewAuthRateLimiter()
defer rl.Stop()
ip := "10.0.0.9"
// 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)},
lockedUntil: time.Now().Add(-time.Second),
lockouts: 1,
}
rl.mu.Unlock()
rl.cleanup()
rl.mu.Lock()
_, exists := rl.records[ip]
rl.mu.Unlock()
if exists {
t.Fatal("expected stale entry to be evicted by cleanup")
}
}
func TestCleanup_KeepsActiveEntries(t *testing.T) {
rl := NewAuthRateLimiter()
defer rl.Stop()
ip := "10.0.0.10"
// Insert an entry that's still locked.
rl.mu.Lock()
rl.records[ip] = &ipRecord{
failures: []time.Time{time.Now()},
lockedUntil: time.Now().Add(30 * time.Second),
lockouts: 1,
}
rl.mu.Unlock()
rl.cleanup()
rl.mu.Lock()
_, exists := rl.records[ip]
rl.mu.Unlock()
if !exists {
t.Fatal("expected active entry to be kept by cleanup")
}
}
func TestConcurrentAccess(t *testing.T) {
rl := NewAuthRateLimiter()
defer rl.Stop()
var wg sync.WaitGroup
ips := []string{"10.0.0.20", "10.0.0.21", "10.0.0.22"}
for _, ip := range ips {
for range 20 {
wg.Go(func() {
rl.CheckIP(ip)
rl.RecordFailure(ip)
rl.RecordSuccess(ip)
rl.CheckIP(ip)
})
}
}
wg.Wait()
}
func TestExtractClientIP(t *testing.T) {
tests := []struct {
name string
remoteAddr string
expected string
}{
{"IPv4 with port", "192.168.1.1:12345", "192.168.1.1"},
{"IPv6 with port", "[::1]:12345", "::1"},
{"IPv4 bare", "192.168.1.1", "192.168.1.1"},
{"IPv6 full with port", "[2001:db8::1]:443", "2001:db8::1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &http.Request{RemoteAddr: tt.remoteAddr}
got := extractClientIP(r)
if got != tt.expected {
t.Errorf("extractClientIP(%q) = %q, want %q", tt.remoteAddr, got, tt.expected)
}
})
}
}
// setupTestAppWithRateLimiter creates a test HTTP handler with SigV4 auth and
// the given rate limiter attached.
func setupTestAppWithRateLimiter(accessKey, secretKey string, rl *AuthRateLimiter) http.Handler {
encryptedSecret, err := handlers_iam.EncryptSecret(secretKey, testMasterKey)
if err != nil {
panic("failed to encrypt test secret: " + err.Error())
}
mockSvc := &mockIAMService{
masterKey: testMasterKey,
accessKeys: map[string]*handlers_iam.AccessKey{
accessKey: {
AccessKeyID: accessKey,
SecretAccessKey: encryptedSecret,
UserName: "root",
Status: "Active",
},
},
}
gw := &GatewayConfig{
DisableLogging: true,
Region: testRegion,
IAMService: mockSvc,
RateLimiter: rl,
}
r := chi.NewRouter()
r.Use(gw.SigV4AuthMiddleware())
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
return r
}
func TestRateLimitIntegration_LockedIPGets503(t *testing.T) {
rl := NewAuthRateLimiter()
defer rl.Stop()
handler := setupTestAppWithRateLimiter(testAccessKey, testSecretKey, rl)
// Send maxFailures requests with invalid signatures to trigger lockout.
for range maxFailures {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
req.RemoteAddr = "10.99.0.1:54321"
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIAINVALIDKEY000000/20240101/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-date, Signature=badsig")
req.Header.Set("X-Amz-Date", time.Now().UTC().Format("20060102T150405Z"))
doRequest(handler, req)
}
// Next request from same IP should be rate-limited.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
req.RemoteAddr = "10.99.0.1:54321"
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIAINVALIDKEY000000/20240101/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-date, Signature=badsig")
req.Header.Set("X-Amz-Date", time.Now().UTC().Format("20060102T150405Z"))
resp := doRequest(handler, req)
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("expected 503 for rate-limited IP, got %d", resp.StatusCode)
}
if !strings.Contains(string(body), "RequestLimitExceeded") {
t.Errorf("expected RequestLimitExceeded in response, got: %s", string(body))
}
}
func TestRateLimitIntegration_SuccessResetsLockout(t *testing.T) {
rl := NewAuthRateLimiter()
defer rl.Stop()
handler := setupTestAppWithRateLimiter(testAccessKey, testSecretKey, rl)
// Accumulate failures below threshold.
for range maxFailures - 1 {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
req.RemoteAddr = "10.99.0.2:54321"
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIAINVALIDKEY000000/20240101/us-east-1/ec2/aws4_request, SignedHeaders=host;x-amz-date, Signature=badsig")
req.Header.Set("X-Amz-Date", time.Now().UTC().Format("20060102T150405Z"))
doRequest(handler, req)
}
// Now send a valid request — should succeed and clear failure state.
authHeader, timestamp := generateTestAuthHeader("GET", "/", "", "", testAccessKey, testSecretKey, testRegion, testService)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "localhost:9999"
req.RemoteAddr = "10.99.0.2:54321"
req.Header.Set("Authorization", authHeader)
req.Header.Set("X-Amz-Date", timestamp)
resp := doRequest(handler, req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 on valid request, got %d", resp.StatusCode)
}
// Verify state was cleared — should be no record.
rl.mu.Lock()
_, exists := rl.records["10.99.0.2"]
rl.mu.Unlock()
if exists {
t.Fatal("expected IP record to be cleared after successful auth")
}
}