All checks were successful
check / check (push) Successful in 7s
The walk now keeps only the rightmost 64 hops, so an attacker-supplied chain cannot burn unbounded CPU in the rate-limit key function. Running off the end of the truncated slice falls back to the peer address, the same fail-closed direction the rest of the function takes. Also corrects the unparseable-RemoteAddr comment, which overclaimed about Unix-socket peers.
653 lines
16 KiB
Go
653 lines
16 KiB
Go
package middleware_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"os"
|
|
"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",
|
|
)
|
|
}
|
|
|
|
// 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",
|
|
)
|
|
}
|