All checks were successful
check / check (push) Successful in 2m52s
With TRUSTED_PROXIES empty behind the reverse proxy production is required to run behind, every login POST keyed on the proxy's address and shared one 5/minute bucket. A stranger sending five POSTs a minute -- 0.08 requests per second, from anywhere -- kept that bucket permanently full, and the operator's own correct password was answered 429 indefinitely with no second administrative path. The login POST no longer has a pre-emptive limiter. The handler verifies credentials first and spends budget only on a FAILED attempt, so a correct password is never throttled whatever the counters hold. Three things follow, and are implemented together because the first is unsafe without the other two: - Failures are counted per (client bucket, submitted username), five per minute, after which further failures get 429 with a Retry-After. A successful login clears the counter, so mistyping and then succeeding does not leave the operator throttled. - Both key sets are capped at 1024 entries. The submitted username is attacker-controlled, so past the first cap failures fall back to a counter keyed on the client alone, and past both caps a failure is answered as throttled without being recorded. Tracked state stays under half a megabyte and does not grow with invented usernames. - Concurrent Argon2id verifications are capped at two, a 128 MB ceiling at 64 MB per hash, and the queue for those slots is capped at 64 waiters. Every password-hashing endpoint takes a slot, including the password-change endpoint, which holds one across both its hashes. A request that waits five seconds without a slot is answered 503, and one that arrives with the queue already full is shed with 503 immediately rather than joining it. Bounding the wait alone would not bound memory: a waiter reaches the guard with its form parsed, so it holds up to the 1 MB body cap for the whole wait, and at flood rates an unbounded queue is worth gigabytes against a 128 MB hashing budget. 64 waiters is 64 MB of committed queue memory, shallow enough that two slots drain a full queue inside the five-second deadline; peak commitment is 128 MB of hashing plus about 66 MB of parsed bodies. An unknown username is verified against a dummy hash instead of returning early, so a nonexistent account costs the same time as a real one and the response cannot be used to enumerate usernames. The password-change limiter is unchanged: RequireAuth runs ahead of it, so only a request already carrying a valid session reaches its bucket. Two consequences are documented rather than fixed, because they follow from the shape the issue asks for. Online guessing throughput rises from 5 a minute to roughly 27 a second, about 2.3 million a day: the credential check always precedes the counter, so the 429 is a label on the response rather than a gate in front of the hash, and what bounds brute force is the semaphore. And under a sustained flood the residual exposure is a loss of login availability, not merely of latency -- above about 27 requests a second most attempts are shed with 503, so a determined flood still denies login for as long as it runs. It costs roughly 400x more to run, nothing accumulates, and the first attempt after it stops succeeds. Restarting the service does not help: the counters a restart clears are not what is saturated. Also adds the missing test for the third bucketKey call site, where the peer is a trusted proxy but the forwarded chain names no client. Every existing test of that fallback uses an IPv4 proxy, where bucketKey is the identity function, so dropping the /64 masking there left the suite green. README and the TRUSTED_PROXIES startup warning updated: a shared bucket now costs precision, not the availability of the admin path.
1288 lines
35 KiB
Go
1288 lines
35 KiB
Go
package middleware_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/middleware"
|
|
)
|
|
|
|
func TestPostRateLimit_AllowsGET(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
var callCount int
|
|
|
|
handler := m.PasswordChangeRateLimit()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
callCount++
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
// GET requests should never be rate-limited
|
|
for i := range 20 {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet, "/user/admin/password", nil,
|
|
)
|
|
req.RemoteAddr = "192.168.1.1:12345"
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"GET request %d should pass", i,
|
|
)
|
|
}
|
|
|
|
assert.Equal(t, 20, callCount)
|
|
}
|
|
|
|
// runPostLimitTest exercises a POST-only rate limit middleware:
|
|
// the first limit POSTs to path from ip must pass, and the next
|
|
// one must be rejected with 429 without reaching the handler.
|
|
func runPostLimitTest(
|
|
t *testing.T,
|
|
mw func(http.Handler) http.Handler,
|
|
limit int,
|
|
path, ip string,
|
|
) {
|
|
t.Helper()
|
|
|
|
var callCount int
|
|
|
|
handler := mw(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
callCount++
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
// The first limit POST requests should succeed
|
|
for i := range limit {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost, path, nil,
|
|
)
|
|
req.RemoteAddr = ip
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"POST request %d should pass", i,
|
|
)
|
|
}
|
|
|
|
// Next POST should be rate-limited
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost, path, nil,
|
|
)
|
|
req.RemoteAddr = ip
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.Equal(
|
|
t, http.StatusTooManyRequests, w.Code,
|
|
"POST after limit should be 429",
|
|
)
|
|
assert.Equal(t, limit, callCount)
|
|
}
|
|
|
|
func TestPasswordChangeRateLimit_LimitsPOST(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
runPostLimitTest(
|
|
t,
|
|
m.PasswordChangeRateLimit(),
|
|
middleware.PasswordChangeRateLimitConst,
|
|
"/user/admin/password",
|
|
"10.0.0.2:12345",
|
|
)
|
|
}
|
|
|
|
func TestPostRateLimit_IndependentPerIP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
handler := m.PasswordChangeRateLimit()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
// Exhaust limit for IP1
|
|
for range middleware.PasswordChangeRateLimitConst {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost, "/pages/login", nil,
|
|
)
|
|
req.RemoteAddr = "1.2.3.4:12345"
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
}
|
|
|
|
// IP1 should be rate-limited
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost, "/pages/login", nil,
|
|
)
|
|
req.RemoteAddr = "1.2.3.4:12345"
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
|
|
|
// IP2 should still be allowed
|
|
req2 := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost, "/pages/login", nil,
|
|
)
|
|
req2.RemoteAddr = "5.6.7.8:12345"
|
|
|
|
w2 := httptest.NewRecorder()
|
|
handler.ServeHTTP(w2, req2)
|
|
|
|
assert.Equal(
|
|
t, http.StatusOK, w2.Code,
|
|
"different IP should not be affected",
|
|
)
|
|
}
|
|
|
|
// okHandler is the terminal handler the limiter middleware wraps
|
|
// in these tests: it answers 200 to anything that reaches it.
|
|
func okHandler() http.Handler {
|
|
return http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
)
|
|
}
|
|
|
|
// rateLimitMiddleware builds a Middleware around cfg, whose
|
|
// TrustedProxies field is what the rate limit key function gates
|
|
// forwarded-header trust on.
|
|
func rateLimitMiddleware(
|
|
t *testing.T, cfg *config.Config,
|
|
) *middleware.Middleware {
|
|
t.Helper()
|
|
|
|
log := slog.New(slog.NewTextHandler(
|
|
os.Stderr,
|
|
&slog.HandlerOptions{Level: slog.LevelDebug},
|
|
))
|
|
|
|
return middleware.NewForTest(log, cfg, nil)
|
|
}
|
|
|
|
// trustedProxies parses CIDR strings for a test Config.
|
|
func trustedProxies(cidrs ...string) []netip.Prefix {
|
|
prefixes := make([]netip.Prefix, 0, len(cidrs))
|
|
for _, cidr := range cidrs {
|
|
prefixes = append(prefixes, netip.MustParsePrefix(cidr))
|
|
}
|
|
|
|
return prefixes
|
|
}
|
|
|
|
// postWithHeaders sends one POST to the handler from peer with the
|
|
// given headers set and returns the recorder.
|
|
func postWithHeaders(
|
|
handler http.Handler,
|
|
peer, path string,
|
|
headers map[string]string,
|
|
) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, path, nil,
|
|
)
|
|
req.RemoteAddr = peer
|
|
|
|
for name, value := range headers {
|
|
req.Header.Set(name, value)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
return w
|
|
}
|
|
|
|
// receiverLimitedHandler builds a ReceiverRateLimit-wrapped
|
|
// handler with the given per-minute limit and no trusted proxies.
|
|
func receiverLimitedHandler(
|
|
t *testing.T, limit int,
|
|
) http.Handler {
|
|
t.Helper()
|
|
|
|
m := rateLimitMiddleware(
|
|
t, &config.Config{ReceiverRateLimit: limit},
|
|
)
|
|
|
|
return m.ReceiverRateLimit()(okHandler())
|
|
}
|
|
|
|
// receiverPost sends one POST to the handler from the given IP
|
|
// and path and returns the recorder.
|
|
func receiverPost(
|
|
handler http.Handler, ip, path string,
|
|
) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost, path, nil,
|
|
)
|
|
req.RemoteAddr = ip
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
return w
|
|
}
|
|
|
|
func TestReceiverRateLimit_LimitsPerIPAndPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const limit = 3
|
|
|
|
handler := receiverLimitedHandler(t, limit)
|
|
|
|
// The first limit requests from one IP to one entrypoint
|
|
// pass.
|
|
for i := range limit {
|
|
w := receiverPost(
|
|
handler, "9.9.9.9:1234", "/webhook/uuid-a",
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"request %d should pass", i,
|
|
)
|
|
}
|
|
|
|
// The next request over the limit is rejected with a 429
|
|
// carrying a Retry-After header.
|
|
w := receiverPost(
|
|
handler, "9.9.9.9:1234", "/webhook/uuid-a",
|
|
)
|
|
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
|
assert.NotEmpty(
|
|
t, w.Header().Get("Retry-After"),
|
|
"429 must carry a Retry-After header",
|
|
)
|
|
|
|
// The same IP is not limited on a different entrypoint.
|
|
w = receiverPost(
|
|
handler, "9.9.9.9:1234", "/webhook/uuid-b",
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"a different entrypoint must not be affected",
|
|
)
|
|
|
|
// A different IP is not limited on the same entrypoint.
|
|
w = receiverPost(
|
|
handler, "8.8.8.8:1234", "/webhook/uuid-a",
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"a different client IP must not be affected",
|
|
)
|
|
}
|
|
|
|
// TestReceiverRateLimit_CountsEveryMethod proves the receiver
|
|
// limit counts non-POST requests too: a GET shares the bucket
|
|
// with a POST and is itself rejected once over the limit.
|
|
func TestReceiverRateLimit_CountsEveryMethod(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
limit = 2
|
|
ip = "7.7.7.7:1234"
|
|
path = "/webhook/uuid-c"
|
|
)
|
|
|
|
handler := receiverLimitedHandler(t, limit)
|
|
|
|
get := func() *httptest.ResponseRecorder {
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet, path, nil,
|
|
)
|
|
req.RemoteAddr = ip
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
return w
|
|
}
|
|
|
|
// One POST plus one GET fill the bucket, so the GET must
|
|
// have been counted.
|
|
assert.Equal(
|
|
t, http.StatusOK, receiverPost(handler, ip, path).Code,
|
|
)
|
|
assert.Equal(t, http.StatusOK, get().Code)
|
|
|
|
assert.Equal(
|
|
t, http.StatusTooManyRequests, get().Code,
|
|
"a GET over the limit must be rate-limited",
|
|
)
|
|
}
|
|
|
|
const (
|
|
// limitedPath is the endpoint these tests drive the shared POST
|
|
// rate limiter through. It is the password-change path: since
|
|
// the login POST verifies credentials before spending any
|
|
// budget, the password-change limiter is the only pre-emptive
|
|
// POST limiter left, and it is what pins the shared key
|
|
// function's behaviour here.
|
|
limitedPath = "/user/admin/password"
|
|
|
|
headerXFF = "X-Forwarded-For"
|
|
headerReal = "X-Real-IP"
|
|
headerTrue = "True-Client-IP"
|
|
|
|
// clientIPv4 is the sample IPv4 client address these tests key
|
|
// on, both directly and in IPv4-mapped form. clientIPv4Alt is
|
|
// its neighbour, used to show the two do not share a bucket.
|
|
clientIPv4 = "198.51.100.7"
|
|
clientIPv4Alt = "198.51.100.8"
|
|
|
|
// clientIPv6 and clientIPv6Same are two addresses inside one
|
|
// routed /64, so both must key on clientBucketV6.
|
|
// clientIPv6Other is a different allocation and must key on
|
|
// clientOtherBucketV6.
|
|
clientIPv6 = "2001:db8:1:2:3:4:5:6"
|
|
clientIPv6Same = "2001:db8:1:2:aaaa:bbbb:cccc:dddd"
|
|
clientIPv6Other = "2001:db8:1:3::1"
|
|
clientBucketV6 = "2001:db8:1:2::/64"
|
|
clientOtherBucketV6 = "2001:db8:1:3::/64"
|
|
|
|
// trustedProxyCIDR is the proxy network the forwarded-path
|
|
// tests configure, and trustedPeer an address inside it. A
|
|
// production deployment is required to run behind a reverse
|
|
// proxy with TRUSTED_PROXIES set, so this is the shape the
|
|
// bucketing has to hold in.
|
|
trustedProxyCIDR = "10.0.0.0/8"
|
|
trustedPeer = "10.0.0.1:44444"
|
|
)
|
|
|
|
// assertSharedBucket drives the login limiter from peer with the
|
|
// trusted-proxy set proxies, sending one more request than the limit
|
|
// allows and varying the headers on each with headers(i). Every
|
|
// request must land in the same bucket, so the last one is rejected:
|
|
// if any of the varying header values reached the key, the run would
|
|
// have minted fresh buckets and nothing would be rejected.
|
|
func assertSharedBucket(
|
|
t *testing.T,
|
|
proxies []netip.Prefix,
|
|
peer string,
|
|
headers func(i int) map[string]string,
|
|
msg string,
|
|
) {
|
|
t.Helper()
|
|
|
|
m := rateLimitMiddleware(
|
|
t, &config.Config{TrustedProxies: proxies},
|
|
)
|
|
handler := m.PasswordChangeRateLimit()(okHandler())
|
|
|
|
for i := range middleware.PasswordChangeRateLimitConst {
|
|
w := postWithHeaders(handler, peer, limitedPath, headers(i))
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"request %d should pass", i,
|
|
)
|
|
}
|
|
|
|
w := postWithHeaders(
|
|
handler, peer, limitedPath,
|
|
headers(middleware.PasswordChangeRateLimitConst),
|
|
)
|
|
assert.Equal(t, http.StatusTooManyRequests, w.Code, msg)
|
|
}
|
|
|
|
// TestRateLimitKey_SpoofedForwardedFromUntrustedPeer is the test
|
|
// this gating exists for: with no trusted proxies configured (the
|
|
// default), a client that rotates a forwarded header on every
|
|
// request must stay in one bucket. If forwarded headers were
|
|
// trusted unconditionally, each spoofed value would mint a fresh
|
|
// bucket and the limit would stop no one.
|
|
func TestRateLimitKey_SpoofedForwardedFromUntrustedPeer(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
for _, header := range []string{
|
|
headerXFF, headerReal, headerTrue,
|
|
} {
|
|
t.Run(header, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSharedBucket(
|
|
t, nil, "203.0.113.9:44444",
|
|
func(i int) map[string]string {
|
|
return map[string]string{
|
|
header: fmt.Sprintf(
|
|
"198.51.100.%d", i+1,
|
|
),
|
|
}
|
|
},
|
|
"a spoofed "+header+" from an untrusted peer "+
|
|
"must not mint a fresh bucket",
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRateLimitKey_SingleValuedHeadersIgnoredFromTrustedPeer is the
|
|
// regression test for the bypass hiding inside the trusted case.
|
|
// Real reverse proxies (nginx, HAProxy, Caddy, ALB) set only
|
|
// X-Forwarded-For and pass every other client header through
|
|
// verbatim, so a client behind the configured proxy can send its own
|
|
// X-Real-IP or True-Client-IP. Reading either would hand that client
|
|
// a fresh bucket per request from inside exactly the deployment
|
|
// TRUSTED_PROXIES exists to serve, so neither header is read at all.
|
|
func TestRateLimitKey_SingleValuedHeadersIgnoredFromTrustedPeer(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
for _, header := range []string{headerReal, headerTrue} {
|
|
t.Run(header, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSharedBucket(
|
|
t, trustedProxies(trustedProxyCIDR),
|
|
trustedPeer,
|
|
func(i int) map[string]string {
|
|
return map[string]string{
|
|
header: fmt.Sprintf(
|
|
"198.51.100.%d", i+1,
|
|
),
|
|
}
|
|
},
|
|
header+" from a trusted peer must not mint a "+
|
|
"fresh bucket: only X-Forwarded-For is read",
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRateLimitKey_MalformedRightmostHopFallsBackToPeer covers the
|
|
// other end of the chain walk. The rightmost X-Forwarded-For entry
|
|
// is the one the trusted proxy appended; if it cannot be read as an
|
|
// address the chain is not the shape the walk assumes, and every
|
|
// entry to its left may have come from the client. The walk must
|
|
// stop and fall back to the peer rather than select one of them.
|
|
func TestRateLimitKey_MalformedRightmostHopFallsBackToPeer(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
// Forms seen in the wild: host:port (Azure Application
|
|
// Gateway, IIS ARR), a bracketed IPv6 literal, and the
|
|
// RFC 7239 placeholder token.
|
|
for _, tail := range []string{
|
|
"198.51.100.7:1234", "[2001:db8::1]", "unknown",
|
|
} {
|
|
t.Run(tail, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSharedBucket(
|
|
t, trustedProxies(trustedProxyCIDR),
|
|
trustedPeer,
|
|
func(i int) map[string]string {
|
|
return map[string]string{
|
|
headerXFF: fmt.Sprintf(
|
|
"9.9.9.%d, %s", i+1, tail,
|
|
),
|
|
}
|
|
},
|
|
"an unparseable rightmost hop must fall back "+
|
|
"to the peer address, not select a "+
|
|
"client-controlled entry",
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRateLimitKey_ForwardedHonouredFromTrustedPeer checks the
|
|
// other half: when the direct peer is a configured trusted proxy,
|
|
// the forwarded client address is what buckets are keyed on, so
|
|
// one sender behind the proxy cannot exhaust another's limit.
|
|
func TestRateLimitKey_ForwardedHonouredFromTrustedPeer(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
m := rateLimitMiddleware(t, &config.Config{
|
|
TrustedProxies: trustedProxies(trustedProxyCIDR),
|
|
})
|
|
handler := m.PasswordChangeRateLimit()(okHandler())
|
|
|
|
const peer = trustedPeer
|
|
|
|
first := map[string]string{headerXFF: clientIPv4}
|
|
|
|
for range middleware.PasswordChangeRateLimitConst {
|
|
postWithHeaders(handler, peer, limitedPath, first)
|
|
}
|
|
|
|
w := postWithHeaders(handler, peer, limitedPath, first)
|
|
assert.Equal(
|
|
t, http.StatusTooManyRequests, w.Code,
|
|
"the forwarded client's own bucket must fill up",
|
|
)
|
|
|
|
w = postWithHeaders(
|
|
handler, peer, limitedPath,
|
|
map[string]string{headerXFF: clientIPv4Alt},
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"a forwarded header from a trusted peer must be honoured",
|
|
)
|
|
}
|
|
|
|
// TestRateLimitKey_ChainWalkSkipsClientPrepended covers the
|
|
// residual spoofing route behind a trusted proxy: the client
|
|
// controls the leftmost X-Forwarded-For entries, so the key is the
|
|
// rightmost hop that is not itself trusted. Rotating the prepended
|
|
// entry must not create new buckets.
|
|
func TestRateLimitKey_ChainWalkSkipsClientPrepended(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assertSharedBucket(
|
|
t, trustedProxies(trustedProxyCIDR), trustedPeer,
|
|
func(i int) map[string]string {
|
|
return map[string]string{
|
|
headerXFF: fmt.Sprintf(
|
|
"9.9.9.%d, 198.51.100.7, 10.0.0.2", i+1,
|
|
),
|
|
}
|
|
},
|
|
"a client-prepended X-Forwarded-For entry must not "+
|
|
"mint a fresh bucket",
|
|
)
|
|
}
|
|
|
|
// TestRateLimitKey_LongChainCapsWalkAndFallsBackToPeer covers the
|
|
// hop-walk cap. A client behind the trusted proxy can pad
|
|
// X-Forwarded-For with tens of thousands of trusted-looking hops,
|
|
// which costs a walk proportional to the padding and, once the walk
|
|
// runs off the left end of the chain, reaches the entry the client
|
|
// put there. Capping the walk stops both: the key falls back to the
|
|
// peer address, so rotating the head of the chain mints no bucket,
|
|
// and the run does not scale with the chain length.
|
|
func TestRateLimitKey_LongChainCapsWalkAndFallsBackToPeer(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
// 50k hops is roughly 0.9 MB, within the default
|
|
// MaxHeaderBytes.
|
|
const hops = 50000
|
|
|
|
padding := strings.Repeat(", 10.0.0.2", hops-1)
|
|
|
|
start := time.Now()
|
|
|
|
assertSharedBucket(
|
|
t, trustedProxies(trustedProxyCIDR), trustedPeer,
|
|
func(i int) map[string]string {
|
|
return map[string]string{
|
|
headerXFF: fmt.Sprintf("9.9.9.%d%s", i+1, padding),
|
|
}
|
|
},
|
|
"a padded X-Forwarded-For chain must fall back to the "+
|
|
"peer address, not reach the client-controlled entry "+
|
|
"at the head of the chain",
|
|
)
|
|
|
|
assert.Less(
|
|
t, time.Since(start), 2*time.Second,
|
|
"the capped walk must not scale with the chain length",
|
|
)
|
|
}
|
|
|
|
// TestRateLimitKey_LongChainAllocationIsBounded is the allocation
|
|
// half of the hop cap. Capping the walk still left every request
|
|
// paying for the whole header the client sent, because the chain was
|
|
// split before it was capped: about 8 MB of []string for the 1 MB a
|
|
// default MaxHeaderBytes allows, on the unauthenticated receiver.
|
|
//
|
|
// Bytes are the measurement, not allocation count: strings.Split of a
|
|
// 1 MB chain is a single allocation, so testing.AllocsPerRun scores
|
|
// it as cheap. The test is deliberately sequential — it reads
|
|
// process-wide counters, and Go runs this package's parallel tests
|
|
// only after the sequential ones finish.
|
|
//
|
|
//nolint:paralleltest // reads process-wide allocation counters
|
|
func TestRateLimitKey_LongChainAllocationIsBounded(t *testing.T) {
|
|
// 100k hops of ", 10.0.0.2" is roughly 1 MB.
|
|
const (
|
|
hops = 100000
|
|
iterations = 50
|
|
maxBytesPerCall = 4096
|
|
)
|
|
|
|
m := rateLimitMiddleware(t, &config.Config{
|
|
TrustedProxies: trustedProxies(trustedProxyCIDR),
|
|
})
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, limitedPath, nil,
|
|
)
|
|
req.RemoteAddr = trustedPeer
|
|
req.Header.Set(
|
|
headerXFF, "9.9.9.9"+strings.Repeat(", 10.0.0.2", hops),
|
|
)
|
|
|
|
var before, after runtime.MemStats
|
|
|
|
var key string
|
|
|
|
runtime.ReadMemStats(&before)
|
|
|
|
for range iterations {
|
|
key = middleware.ClientKeyForTest(m, req)
|
|
}
|
|
|
|
runtime.ReadMemStats(&after)
|
|
|
|
perCall := (after.TotalAlloc - before.TotalAlloc) / iterations
|
|
|
|
assert.Less(
|
|
t, perCall, uint64(maxBytesPerCall),
|
|
"a %d-byte X-Forwarded-For must not allocate in proportion "+
|
|
"to its length, but cost %d bytes per call",
|
|
len(req.Header.Get(headerXFF)), perCall,
|
|
)
|
|
|
|
assert.Equal(
|
|
t, "10.0.0.1", key,
|
|
"the padded chain must still fall back to the peer address",
|
|
)
|
|
}
|
|
|
|
// TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths is the
|
|
// regression test for the per-path bucket key. The route pattern
|
|
// matches any single segment, so a client that never reuses a path
|
|
// never reuses a per-entrypoint bucket either, and its aggregate
|
|
// rate against the receiver is whatever it likes — with every
|
|
// request reaching an entrypoint lookup before it 404s. The IP-only
|
|
// aggregate limiter is what bounds that, so this must fail if the
|
|
// aggregate limiter is removed.
|
|
func TestReceiverRateLimit_LimitsAggregateAcrossInventedPaths(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
limit = 3
|
|
ip = "6.6.6.6:1234"
|
|
)
|
|
|
|
aggregate := limit * middleware.ReceiverAggregateMultiplierConst
|
|
|
|
handler := receiverLimitedHandler(t, limit)
|
|
|
|
// Every request goes to a path this client has never used, so
|
|
// none of them shares a per-entrypoint bucket with another.
|
|
for i := range aggregate {
|
|
w := receiverPost(
|
|
handler, ip, fmt.Sprintf("/webhook/invented-%d", i),
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"request %d to a distinct path should pass", i,
|
|
)
|
|
}
|
|
|
|
w := receiverPost(
|
|
handler, ip, fmt.Sprintf("/webhook/invented-%d", aggregate),
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusTooManyRequests, w.Code,
|
|
"a client must not be able to raise its aggregate rate "+
|
|
"against /webhook/* by varying the path",
|
|
)
|
|
|
|
// The aggregate limit is still per client IP: exhausting one
|
|
// address must not throttle another.
|
|
w = receiverPost(handler, "6.6.6.7:1234", "/webhook/invented-0")
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"a different client IP must not be affected",
|
|
)
|
|
}
|
|
|
|
// TestReceiverRateLimit_RejectedRequestsCountTowardAggregate pins the
|
|
// order the two limiters are chained in. The aggregate limiter has to
|
|
// be the outer one, so that it counts requests the per-entrypoint
|
|
// limiter rejects: those requests still arrive, and the aggregate
|
|
// limit exists to bound what one address can make the receiver do.
|
|
//
|
|
// One path is hammered past the per-entrypoint limit, which alone
|
|
// would leave the aggregate budget almost untouched; then a path the
|
|
// client has never used must be rejected, which only the aggregate
|
|
// limiter can do. Swap the two limiters and that last request is
|
|
// served, because the rejected ones never reached the aggregate
|
|
// limiter to be counted.
|
|
func TestReceiverRateLimit_RejectedRequestsCountTowardAggregate(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
limit = 3
|
|
ip = "6.6.6.8:1234"
|
|
)
|
|
|
|
aggregate := limit * middleware.ReceiverAggregateMultiplierConst
|
|
|
|
handler := receiverLimitedHandler(t, limit)
|
|
|
|
// Spend the whole aggregate budget on one path. Only the first
|
|
// limit requests are served; the rest are rejected by the
|
|
// per-entrypoint limiter but still count against the aggregate.
|
|
for i := range aggregate {
|
|
w := receiverPost(handler, ip, "/webhook/exhausted")
|
|
|
|
want := http.StatusTooManyRequests
|
|
if i < limit {
|
|
want = http.StatusOK
|
|
}
|
|
|
|
assert.Equal(
|
|
t, want, w.Code,
|
|
"request %d to the exhausted path", i,
|
|
)
|
|
}
|
|
|
|
w := receiverPost(handler, ip, "/webhook/never-used")
|
|
assert.Equal(
|
|
t, http.StatusTooManyRequests, w.Code,
|
|
"requests rejected per entrypoint must still count "+
|
|
"toward the aggregate limit, so the aggregate "+
|
|
"limiter has to run first",
|
|
)
|
|
}
|
|
|
|
// TestReceiverAggregateLimit_SaturatesOnOverflow covers the derived
|
|
// aggregate limit for a configured per-entrypoint limit large enough
|
|
// that multiplying it would wrap negative, which httprate would read
|
|
// as a limit that rejects every request.
|
|
func TestReceiverAggregateLimit_SaturatesOnOverflow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(
|
|
t, 1200,
|
|
middleware.ReceiverAggregateLimitForTest(120),
|
|
"the default limit scales by the multiplier",
|
|
)
|
|
assert.Equal(
|
|
t, math.MaxInt,
|
|
middleware.ReceiverAggregateLimitForTest(math.MaxInt),
|
|
"an overflowing limit saturates instead of wrapping",
|
|
)
|
|
}
|
|
|
|
// TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer proves
|
|
// the receiver limiter uses the same gated key function as the
|
|
// POST limiters.
|
|
func TestReceiverRateLimit_IgnoresForwardedFromUntrustedPeer(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
limit = 3
|
|
peer = "203.0.113.10:44444"
|
|
path = "/webhook/uuid-d"
|
|
)
|
|
|
|
handler := receiverLimitedHandler(t, limit)
|
|
|
|
for i := range limit {
|
|
w := postWithHeaders(
|
|
handler, peer, path,
|
|
map[string]string{
|
|
headerXFF: fmt.Sprintf(
|
|
"198.51.100.%d", i+1,
|
|
),
|
|
},
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"request %d should pass", i,
|
|
)
|
|
}
|
|
|
|
w := postWithHeaders(
|
|
handler, peer, path,
|
|
map[string]string{headerXFF: "198.51.100.200"},
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusTooManyRequests, w.Code,
|
|
"a spoofed X-Forwarded-For from an untrusted peer must "+
|
|
"not mint a fresh receiver bucket",
|
|
)
|
|
}
|
|
|
|
// clientKeyFor returns the bucket key m computes for a request whose
|
|
// direct peer is remoteAddr and which carries no forwarded headers.
|
|
func clientKeyFor(
|
|
t *testing.T, m *middleware.Middleware, remoteAddr string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, limitedPath, nil,
|
|
)
|
|
req.RemoteAddr = remoteAddr
|
|
|
|
return middleware.ClientKeyForTest(m, req)
|
|
}
|
|
|
|
// TestRateLimitKey_IPv6BucketsByPrefix pins the key function's
|
|
// address-family behaviour. IPv6 clients must bucket by /64 — a
|
|
// routed /64 is the normal residential and mobile allocation, so
|
|
// per-/128 keying lets one subscriber rotate source addresses and
|
|
// mint a fresh bucket per request — while IPv4 keeps keying on the
|
|
// full address and IPv4-mapped form is keyed as the IPv4 address it
|
|
// carries.
|
|
func TestRateLimitKey_IPv6BucketsByPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m := rateLimitMiddleware(t, &config.Config{})
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
peer string
|
|
want string
|
|
about string
|
|
}{{
|
|
name: "ipv6",
|
|
peer: "[" + clientIPv6 + "]:44444",
|
|
want: clientBucketV6,
|
|
about: "an IPv6 peer must key on its /64",
|
|
}, {
|
|
name: "ipv6-other-in-same-64",
|
|
peer: "[" + clientIPv6Same + "]:1",
|
|
want: clientBucketV6,
|
|
about: "another address in the same /64 must key the same",
|
|
}, {
|
|
name: "ipv6-different-64",
|
|
peer: "[" + clientIPv6Other + "]:44444",
|
|
want: clientOtherBucketV6,
|
|
about: "a different /64 must key differently",
|
|
}, {
|
|
name: "ipv4",
|
|
peer: clientIPv4 + ":44444",
|
|
want: clientIPv4,
|
|
about: "IPv4 must keep keying on the full address",
|
|
}, {
|
|
name: "ipv4-neighbour",
|
|
peer: clientIPv4Alt + ":44444",
|
|
want: clientIPv4Alt,
|
|
about: "adjacent IPv4 addresses must not share a bucket",
|
|
}, {
|
|
name: "ipv4-mapped",
|
|
peer: "[::ffff:" + clientIPv4 + "]:44444",
|
|
want: clientIPv4,
|
|
about: "IPv4-mapped form must key as the IPv4 address, " +
|
|
"not be masked to a /64: mapped addresses all share " +
|
|
"::ffff:0:0/96, so masking would collapse every IPv4 " +
|
|
"client behind a mapping proxy into one bucket",
|
|
}} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(
|
|
t, tc.want, clientKeyFor(t, m, tc.peer), tc.about,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRateLimitKey_FamiliesDoNotCollide pins the structure the
|
|
// no-collision property rests on, rather than one sample pair: every
|
|
// IPv4 key is a bare address and every IPv6 key is a /64 in CIDR
|
|
// form, so the two name spaces are disjoint by shape. Dropping the
|
|
// masking strips the suffix that guarantees it, which is why this
|
|
// asserts the form of each key and not just that two of them differ.
|
|
func TestRateLimitKey_FamiliesDoNotCollide(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Restated here rather than imported from the package under
|
|
// test, so that changing the production bucket width fails this
|
|
// test instead of silently moving with it.
|
|
const wantBits = 64
|
|
|
|
m := rateLimitMiddleware(t, &config.Config{})
|
|
|
|
v4Keys := map[string]bool{}
|
|
|
|
for _, peer := range []string{
|
|
clientIPv4 + ":44444",
|
|
clientIPv4Alt + ":44444",
|
|
"[::ffff:" + clientIPv4 + "]:44444",
|
|
} {
|
|
key := clientKeyFor(t, m, peer)
|
|
|
|
addr, err := netip.ParseAddr(key)
|
|
require.NoError(
|
|
t, err, "%s: an IPv4 key must be a bare address", peer,
|
|
)
|
|
assert.True(
|
|
t, addr.Is4(),
|
|
"%s: an IPv4 key must be a dotted quad, got %q", peer, key,
|
|
)
|
|
|
|
v4Keys[key] = true
|
|
}
|
|
|
|
for _, peer := range []string{
|
|
"[" + clientIPv6 + "]:44444",
|
|
"[" + clientIPv6Same + "]:44444",
|
|
"[" + clientIPv6Other + "]:44444",
|
|
"[2001:db8::" + clientIPv4 + "]:44444",
|
|
} {
|
|
key := clientKeyFor(t, m, peer)
|
|
|
|
prefix, err := netip.ParsePrefix(key)
|
|
require.NoError(
|
|
t, err, "%s: an IPv6 key must be a CIDR prefix", peer,
|
|
)
|
|
assert.Equal(
|
|
t, wantBits, prefix.Bits(),
|
|
"%s: an IPv6 key must name a /64", peer,
|
|
)
|
|
assert.False(
|
|
t, v4Keys[key],
|
|
"%s: an IPv6 key must never equal an IPv4 key", peer,
|
|
)
|
|
}
|
|
}
|
|
|
|
// TestRateLimitKey_UnparseablePeerKeepsDistinctBuckets covers the
|
|
// fallback path. A RemoteAddr that is not an address must not panic,
|
|
// and must not drop unrelated clients into one shared bucket by
|
|
// accident: the raw value is the most specific identity left, so
|
|
// distinct values stay in distinct buckets.
|
|
func TestRateLimitKey_UnparseablePeerKeepsDistinctBuckets(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
m := rateLimitMiddleware(t, &config.Config{})
|
|
|
|
first := clientKeyFor(t, m, "not-an-address")
|
|
second := clientKeyFor(t, m, "also-not-an-address:1234")
|
|
|
|
assert.NotEmpty(t, first)
|
|
assert.NotEqual(
|
|
t, first, second,
|
|
"unparseable peers must not collapse into one bucket",
|
|
)
|
|
}
|
|
|
|
// TestPostRateLimit_IPv6SharesBucketWithinSlash64 is the behavioural
|
|
// half, and the regression test for the bypass itself: a client that
|
|
// rotates source addresses inside its own routed /64 must stay in one
|
|
// bucket. Reverting the masking makes this test fail, because each
|
|
// rotated address would mint a fresh bucket and nothing would be
|
|
// rejected.
|
|
func TestPostRateLimit_IPv6SharesBucketWithinSlash64(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m := rateLimitMiddleware(t, &config.Config{})
|
|
handler := m.PasswordChangeRateLimit()(okHandler())
|
|
|
|
for i := range middleware.PasswordChangeRateLimitConst {
|
|
w := postWithHeaders(
|
|
handler,
|
|
fmt.Sprintf("[2001:db8:1:2::%d]:44444", i+1),
|
|
limitedPath, nil,
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code, "request %d should pass", i,
|
|
)
|
|
}
|
|
|
|
w := postWithHeaders(
|
|
handler, "[2001:db8:1:2::ffff]:44444", limitedPath, nil,
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusTooManyRequests, w.Code,
|
|
"rotating source addresses inside one routed /64 must not "+
|
|
"mint fresh buckets",
|
|
)
|
|
}
|
|
|
|
// TestPostRateLimit_IPv6IndependentAcrossSlash64 is the other side
|
|
// of the trade: bucketing by /64 must not merge separate allocations,
|
|
// so a client in a different /64 keeps its own limit.
|
|
func TestPostRateLimit_IPv6IndependentAcrossSlash64(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m := rateLimitMiddleware(t, &config.Config{})
|
|
handler := m.PasswordChangeRateLimit()(okHandler())
|
|
|
|
for range middleware.PasswordChangeRateLimitConst + 1 {
|
|
postWithHeaders(
|
|
handler, "[2001:db8:1:2::1]:44444", limitedPath, nil,
|
|
)
|
|
}
|
|
|
|
w := postWithHeaders(
|
|
handler, "[2001:db8:1:3::1]:44444", limitedPath, nil,
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"a different /64 must have its own bucket",
|
|
)
|
|
}
|
|
|
|
// TestPostRateLimit_IPv4IndependentPerAddress guards against the
|
|
// masking leaking into IPv4: two addresses one apart must still hold
|
|
// separate buckets.
|
|
func TestPostRateLimit_IPv4IndependentPerAddress(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m := rateLimitMiddleware(t, &config.Config{})
|
|
handler := m.PasswordChangeRateLimit()(okHandler())
|
|
|
|
for range middleware.PasswordChangeRateLimitConst + 1 {
|
|
postWithHeaders(
|
|
handler, clientIPv4+":44444", limitedPath, nil,
|
|
)
|
|
}
|
|
|
|
w := postWithHeaders(
|
|
handler, clientIPv4Alt+":44444", limitedPath, nil,
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"a second IPv4 address must have its own bucket",
|
|
)
|
|
}
|
|
|
|
// forwardedKeyFor returns the bucket key m computes for a request
|
|
// that arrives from trustedPeer — a configured trusted proxy — and
|
|
// names forwarded as its client in X-Forwarded-For. That is the
|
|
// production path: a deployment is required to run behind a reverse
|
|
// proxy with TRUSTED_PROXIES set, so the forwarded address, not the
|
|
// peer, is what the limiters bucket on there.
|
|
func forwardedKeyFor(
|
|
t *testing.T, m *middleware.Middleware, forwarded string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, limitedPath, nil,
|
|
)
|
|
req.RemoteAddr = trustedPeer
|
|
req.Header.Set(headerXFF, forwarded)
|
|
|
|
return middleware.ClientKeyForTest(m, req)
|
|
}
|
|
|
|
// TestRateLimitKey_ForwardedIPv6BucketsByPrefix pins the /64
|
|
// bucketing on the trusted-proxy branch. The direct-peer tests above
|
|
// cannot reach it, so without this the masking could be reverted for
|
|
// forwarded clients alone — the only shape a production deployment
|
|
// runs in — and the rest of the suite would stay green.
|
|
func TestRateLimitKey_ForwardedIPv6BucketsByPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m := rateLimitMiddleware(t, &config.Config{
|
|
TrustedProxies: trustedProxies(trustedProxyCIDR),
|
|
})
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
forwarded string
|
|
want string
|
|
about string
|
|
}{{
|
|
name: "ipv6",
|
|
forwarded: clientIPv6,
|
|
want: clientBucketV6,
|
|
about: "a forwarded IPv6 client must key on its /64",
|
|
}, {
|
|
name: "ipv6-other-in-same-64",
|
|
forwarded: clientIPv6Same,
|
|
want: clientBucketV6,
|
|
about: "another forwarded address in the same /64 must " +
|
|
"key the same",
|
|
}, {
|
|
name: "ipv6-different-64",
|
|
forwarded: clientIPv6Other,
|
|
want: clientOtherBucketV6,
|
|
about: "a forwarded address in another /64 must differ",
|
|
}, {
|
|
name: "ipv4",
|
|
forwarded: clientIPv4,
|
|
want: clientIPv4,
|
|
about: "a forwarded IPv4 client must key on the address",
|
|
}, {
|
|
name: "ipv4-mapped",
|
|
forwarded: "::ffff:" + clientIPv4,
|
|
want: clientIPv4,
|
|
about: "a proxy that forwards IPv4-mapped form must key as " +
|
|
"the IPv4 address it carries, not be masked to a /64: " +
|
|
"mapped addresses all share ::ffff:0:0/96",
|
|
}} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(
|
|
t, tc.want,
|
|
forwardedKeyFor(t, m, tc.forwarded), tc.about,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer covers the
|
|
// third bucketKey call site: the peer IS a trusted proxy, but the
|
|
// forwarded chain cannot name a client, so the key falls back to the
|
|
// peer address — and that fallback owes the same /64 masking every
|
|
// other key gets.
|
|
//
|
|
// Every existing test of this fallback uses an IPv4 proxy, where
|
|
// bucketKey is the identity function, so replacing the call with
|
|
// peer.String() leaves the whole suite green. Only operator-listed
|
|
// addresses reach this line and the fallback is fail-closed, so this
|
|
// pins behaviour rather than fixing a defect.
|
|
func TestRateLimitKey_TrustedPeerUnusableForwardedMasksPeer(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
proxyCIDR = "2001:db8:ffff::/48"
|
|
proxyPeer = "[2001:db8:ffff:1::5]:44444"
|
|
wantKey = "2001:db8:ffff:1::/64"
|
|
)
|
|
|
|
m := rateLimitMiddleware(t, &config.Config{
|
|
TrustedProxies: trustedProxies(proxyCIDR),
|
|
})
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
forwarded string
|
|
about string
|
|
}{{
|
|
name: "absent",
|
|
about: "no X-Forwarded-For at all falls back to the peer",
|
|
}, {
|
|
name: "unreadable-hop",
|
|
forwarded: "unknown",
|
|
about: "a hop that is not a bare address ends the walk " +
|
|
"and falls back to the peer",
|
|
}, {
|
|
name: "all-hops-trusted",
|
|
forwarded: "2001:db8:ffff:2::9",
|
|
about: "a chain naming only trusted proxies names no " +
|
|
"client, so the peer is used",
|
|
}} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost, limitedPath, nil,
|
|
)
|
|
req.RemoteAddr = proxyPeer
|
|
|
|
if tc.forwarded != "" {
|
|
req.Header.Set(headerXFF, tc.forwarded)
|
|
}
|
|
|
|
assert.Equal(
|
|
t, wantKey,
|
|
middleware.ClientKeyForTest(m, req),
|
|
"%s, masked to its /64", tc.about,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPostRateLimit_ForwardedIPv6SharesBucketWithinSlash64 is the
|
|
// behavioural half on the production path: behind a trusted proxy, a
|
|
// client rotating source addresses inside its own routed /64 must
|
|
// stay in one bucket.
|
|
func TestPostRateLimit_ForwardedIPv6SharesBucketWithinSlash64(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
assertSharedBucket(
|
|
t, trustedProxies(trustedProxyCIDR), trustedPeer,
|
|
func(i int) map[string]string {
|
|
return map[string]string{
|
|
headerXFF: fmt.Sprintf("2001:db8:1:2::%d", i+1),
|
|
}
|
|
},
|
|
"rotating forwarded source addresses inside one routed /64 "+
|
|
"must not mint fresh buckets",
|
|
)
|
|
}
|
|
|
|
// TestPostRateLimit_ForwardedIPv6IndependentAcrossSlash64 is the
|
|
// other side of that trade on the same path: bucketing by /64 must
|
|
// not merge two allocations reaching the proxy.
|
|
func TestPostRateLimit_ForwardedIPv6IndependentAcrossSlash64(
|
|
t *testing.T,
|
|
) {
|
|
t.Parallel()
|
|
|
|
m := rateLimitMiddleware(t, &config.Config{
|
|
TrustedProxies: trustedProxies(trustedProxyCIDR),
|
|
})
|
|
handler := m.PasswordChangeRateLimit()(okHandler())
|
|
|
|
spent := map[string]string{headerXFF: clientIPv6}
|
|
for range middleware.PasswordChangeRateLimitConst + 1 {
|
|
postWithHeaders(handler, trustedPeer, limitedPath, spent)
|
|
}
|
|
|
|
w := postWithHeaders(
|
|
handler, trustedPeer, limitedPath,
|
|
map[string]string{headerXFF: clientIPv6Other},
|
|
)
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"a forwarded client in a different /64 must have its own "+
|
|
"bucket",
|
|
)
|
|
}
|