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" "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_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", ) } // 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", ) }