metrics: rate limit /metrics per client address before Basic Auth (closes #101)
check / check (push) Successful in 1m18s
check / check (push) Successful in 1m18s
/metrics is behind a password, and REPO_POLICIES.md requires rate limiting on password logins. Each client address may now send it 30 requests a minute, counted by httprate before Basic Auth, so failed logins use up the allowance and a request over it gets 429 without the password being checked. The address is the one the existing trusted-proxy logic in internal/middleware works out, with IPv6 addresses grouped by /64; an IPv4 address a proxy reports in IPv6-mapped form counts as the plain IPv4 address. A Prometheus server scraping every 15 seconds sends 4 requests a minute. Model: opus-5-5
This commit was merged in pull request #180.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/fx/fxtest"
|
||||
@@ -340,3 +341,144 @@ func TestDashboardRendersWithSecurityHeaders(t *testing.T) {
|
||||
t.Errorf("CSP would block %q: %q", stylesheetPath, csp)
|
||||
}
|
||||
}
|
||||
|
||||
// Addresses for the rate limit tests: a client connecting directly, a
|
||||
// trusted proxy, and a client behind that proxy as its X-Real-IP
|
||||
// header names it.
|
||||
const (
|
||||
directClient = "198.51.100.1:4000"
|
||||
trustedProxy = "10.0.0.1:4000"
|
||||
proxiedClient = "203.0.113.1"
|
||||
)
|
||||
|
||||
// statusFrom sends a GET through handler as if from remoteAddr, with
|
||||
// an X-Real-IP header when xRealIP is not empty, and returns the
|
||||
// response status.
|
||||
func statusFrom(
|
||||
t *testing.T,
|
||||
handler http.Handler,
|
||||
remoteAddr string,
|
||||
xRealIP string,
|
||||
) int {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/metrics", nil,
|
||||
)
|
||||
req.RemoteAddr = remoteAddr
|
||||
|
||||
if xRealIP != "" {
|
||||
req.Header.Set("X-Real-IP", xRealIP)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
// TestMetricsRateLimitAllowsScraping checks that one address can send,
|
||||
// within one window, what two Prometheus servers scraping every 5
|
||||
// seconds send in that time, without being turned away.
|
||||
func TestMetricsRateLimitAllowsScraping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const scrapeInterval = 5 * time.Second
|
||||
|
||||
scrapes := 2 * int(middleware.MetricsRequestWindow/scrapeInterval)
|
||||
limited := newTestMiddleware(t).MetricsRateLimit()(okHandler())
|
||||
|
||||
for i := range scrapes {
|
||||
got := statusFrom(t, limited, directClient, "")
|
||||
if got != http.StatusOK {
|
||||
t.Fatalf(
|
||||
"scrape %d of %d: status = %d, want 200",
|
||||
i+1, scrapes, got,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetricsRateLimitKeysOnClientAddress checks which requests share
|
||||
// an allowance. Each case uses up the allowance of one client, then
|
||||
// sends one more request.
|
||||
func TestMetricsRateLimitKeysOnClientAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
usedRemoteAddr string
|
||||
usedXRealIP string
|
||||
nextRemoteAddr string
|
||||
nextXRealIP string
|
||||
want int
|
||||
}{
|
||||
{
|
||||
"same address",
|
||||
directClient, "",
|
||||
directClient, "",
|
||||
http.StatusTooManyRequests,
|
||||
},
|
||||
{
|
||||
"another address",
|
||||
directClient, "",
|
||||
"198.51.100.2:4000", "",
|
||||
http.StatusOK,
|
||||
},
|
||||
{
|
||||
"own X-Real-IP from an untrusted address",
|
||||
directClient, "",
|
||||
directClient, "203.0.113.9",
|
||||
http.StatusTooManyRequests,
|
||||
},
|
||||
{
|
||||
"same client behind the proxy",
|
||||
trustedProxy, proxiedClient,
|
||||
trustedProxy, proxiedClient,
|
||||
http.StatusTooManyRequests,
|
||||
},
|
||||
{
|
||||
"another client behind the proxy",
|
||||
trustedProxy, proxiedClient,
|
||||
trustedProxy, "203.0.113.2",
|
||||
http.StatusOK,
|
||||
},
|
||||
{
|
||||
"another client behind the proxy, IPv6-mapped",
|
||||
trustedProxy, "::ffff:203.0.113.1",
|
||||
trustedProxy, "::ffff:203.0.113.2",
|
||||
http.StatusOK,
|
||||
},
|
||||
{
|
||||
"same IPv6 /64",
|
||||
"[2001:db8::1]:4000", "",
|
||||
"[2001:db8::2]:4000", "",
|
||||
http.StatusTooManyRequests,
|
||||
},
|
||||
{
|
||||
"another IPv6 /64",
|
||||
"[2001:db8::1]:4000", "",
|
||||
"[2001:db8:0:1::1]:4000", "",
|
||||
http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limited := newTestMiddleware(t).MetricsRateLimit()(okHandler())
|
||||
|
||||
for range middleware.MetricsRequestLimit {
|
||||
statusFrom(t, limited, tt.usedRemoteAddr, tt.usedXRealIP)
|
||||
}
|
||||
|
||||
got := statusFrom(
|
||||
t, limited, tt.nextRemoteAddr, tt.nextXRealIP,
|
||||
)
|
||||
if got != tt.want {
|
||||
t.Errorf("status = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user