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,12 +5,14 @@ import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/basicauth-go"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/go-chi/httprate"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
@@ -21,6 +23,17 @@ import (
|
||||
// corsMaxAge is the maximum age for CORS preflight responses.
|
||||
const corsMaxAge = 300
|
||||
|
||||
// Rate limit for /metrics: each client address may send
|
||||
// metricsRequestLimit requests per metricsRequestWindow. Every request
|
||||
// counts, so password guessing gets at most 30 tries a minute per
|
||||
// address. One Prometheus server scraping every 15 seconds sends 4
|
||||
// requests a minute, and two scraping every 5 seconds from one address
|
||||
// send 24, so normal scraping stays under the limit.
|
||||
const (
|
||||
metricsRequestLimit = 30
|
||||
metricsRequestWindow = time.Minute
|
||||
)
|
||||
|
||||
// Security response header values applied to every response.
|
||||
//
|
||||
// The CSP is as strict as the dashboard allows: the template ships no
|
||||
@@ -268,6 +281,32 @@ func (m *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// MetricsRateLimit returns middleware for /metrics that answers 429
|
||||
// Too Many Requests to a client address over the rate limit. The
|
||||
// address is the one realIP works out, so a client that is not a
|
||||
// trusted proxy cannot get a fresh allowance by sending its own
|
||||
// X-Real-IP or X-Forwarded-For. CanonicalizeIP counts all IPv6
|
||||
// addresses in one /64 as one client, since a client usually holds a
|
||||
// whole /64. An IPv4 address a proxy reports in IPv6-mapped form
|
||||
// (::ffff:203.0.113.1) is turned back into plain IPv4 first, as every
|
||||
// such address is in the same /64.
|
||||
func (m *Middleware) MetricsRateLimit() func(http.Handler) http.Handler {
|
||||
return httprate.LimitBy(
|
||||
metricsRequestLimit,
|
||||
metricsRequestWindow,
|
||||
func(request *http.Request) (string, error) {
|
||||
ip := realIP(request)
|
||||
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err == nil {
|
||||
ip = addr.Unmap().String()
|
||||
}
|
||||
|
||||
return httprate.CanonicalizeIP(ip), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// MetricsAuth returns basic auth middleware for /metrics.
|
||||
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||
if m.params.Config.MetricsUsername == "" {
|
||||
|
||||
Reference in New Issue
Block a user