All checks were successful
check / check (push) Successful in 3m20s
The 64-hop cap bounded the walk but not the allocation: the chain was split before it was capped, so a padded header cost a []string proportional to its whole length on every request to the unauthenticated receiver. Cut hops off the right end of each header value in place with strings.LastIndexByte instead, and walk multiple header values in reverse rather than joining them. Bucket assignment is unchanged: the walk still counts every comma-separated entry against the cap, skips empty ones, stops at the first hop that is not a trusted proxy, and falls back to the peer on an unreadable hop or an exhausted cap. Measured over a 1 MB chain: 1,606,043 bytes allocated per call before, 16 bytes after.
714 lines
18 KiB
Go
714 lines
18 KiB
Go
package middleware_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"os"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"sneak.berlin/go/webhooker/internal/config"
|
|
"sneak.berlin/go/webhooker/internal/middleware"
|
|
)
|
|
|
|
func TestLoginRateLimit_AllowsGET(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
var callCount int
|
|
|
|
handler := m.LoginRateLimit()(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, "/pages/login", 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 TestLoginRateLimit_LimitsPOST(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
runPostLimitTest(
|
|
t,
|
|
m.LoginRateLimit(),
|
|
middleware.LoginRateLimitConst,
|
|
"/pages/login",
|
|
"10.0.0.1:12345",
|
|
)
|
|
}
|
|
|
|
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 TestLoginRateLimit_IndependentPerIP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
|
|
handler := m.LoginRateLimit()(http.HandlerFunc(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
},
|
|
))
|
|
|
|
// Exhaust limit for IP1
|
|
for range middleware.LoginRateLimitConst {
|
|
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 (
|
|
loginPath = "/pages/login"
|
|
headerXFF = "X-Forwarded-For"
|
|
headerReal = "X-Real-IP"
|
|
headerTrue = "True-Client-IP"
|
|
)
|
|
|
|
// 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.LoginRateLimit()(okHandler())
|
|
|
|
for i := range middleware.LoginRateLimitConst {
|
|
w := postWithHeaders(handler, peer, loginPath, headers(i))
|
|
assert.Equal(
|
|
t, http.StatusOK, w.Code,
|
|
"request %d should pass", i,
|
|
)
|
|
}
|
|
|
|
w := postWithHeaders(
|
|
handler, peer, loginPath,
|
|
headers(middleware.LoginRateLimitConst),
|
|
)
|
|
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("10.0.0.0/8"),
|
|
"10.0.0.1:44444",
|
|
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("10.0.0.0/8"),
|
|
"10.0.0.1:44444",
|
|
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("10.0.0.0/8"),
|
|
})
|
|
handler := m.LoginRateLimit()(okHandler())
|
|
|
|
const peer = "10.0.0.1:44444"
|
|
|
|
first := map[string]string{headerXFF: "198.51.100.7"}
|
|
|
|
for range middleware.LoginRateLimitConst {
|
|
postWithHeaders(handler, peer, loginPath, first)
|
|
}
|
|
|
|
w := postWithHeaders(handler, peer, loginPath, first)
|
|
assert.Equal(
|
|
t, http.StatusTooManyRequests, w.Code,
|
|
"the forwarded client's own bucket must fill up",
|
|
)
|
|
|
|
w = postWithHeaders(
|
|
handler, peer, loginPath,
|
|
map[string]string{headerXFF: "198.51.100.8"},
|
|
)
|
|
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("10.0.0.0/8"), "10.0.0.1:44444",
|
|
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("10.0.0.0/8"), "10.0.0.1:44444",
|
|
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("10.0.0.0/8"),
|
|
})
|
|
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodPost, loginPath, nil,
|
|
)
|
|
req.RemoteAddr = "10.0.0.1:44444"
|
|
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_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",
|
|
)
|
|
}
|