All checks were successful
check / check (push) Successful in 2m54s
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. 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 no hash runs for it. 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. 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",
|
|
)
|
|
}
|