390 lines
14 KiB
Go
390 lines
14 KiB
Go
package middleware
|
|
|
|
import (
|
|
"math"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/httprate"
|
|
"sneak.berlin/go/webhooker/internal/logfield"
|
|
)
|
|
|
|
const (
|
|
// loginRateLimit is the maximum number of FAILED login attempts
|
|
// one client may make against one submitted username per
|
|
// interval before further failures are answered 429. Successful
|
|
// attempts are never counted and never throttled — see
|
|
// loginGuard.
|
|
loginRateLimit = 5
|
|
|
|
// loginRateInterval is the time window for the login failure
|
|
// limit.
|
|
loginRateInterval = 1 * time.Minute
|
|
|
|
// passwordChangeRateLimit is the maximum number of password
|
|
// change attempts per interval. Each attempt verifies the
|
|
// current password, so the endpoint must be rate-limited
|
|
// like any other password-based authentication endpoint.
|
|
passwordChangeRateLimit = 5
|
|
|
|
// passwordChangeRateInterval is the time window for the
|
|
// password change rate limit.
|
|
passwordChangeRateInterval = 1 * time.Minute
|
|
|
|
// receiverRateInterval is the time window for the webhook
|
|
// receiver rate limit. The configured limit is expressed in
|
|
// requests per minute.
|
|
receiverRateInterval = 1 * time.Minute
|
|
|
|
// receiverAggregateMultiplier scales the configured
|
|
// per-entrypoint receiver limit into the aggregate limit one
|
|
// client IP may spend across the whole /webhook/* route. Ten
|
|
// entrypoints' worth lets a single sender address drive several
|
|
// entrypoints at their full rate, while still capping what one
|
|
// address costs the unauthenticated receiver.
|
|
receiverAggregateMultiplier = 10
|
|
|
|
// maxForwardedHops bounds how many X-Forwarded-For entries the
|
|
// chain walk examines. Real chains are one to three hops, but a
|
|
// client can pad the header up to MaxHeaderBytes, so without a
|
|
// bound every request pays a walk proportional to whatever the
|
|
// client sent.
|
|
maxForwardedHops = 64
|
|
|
|
// ipv6BucketBits is the prefix length IPv6 clients are bucketed
|
|
// on. A routed /64 is the normal residential and mobile
|
|
// allocation, so it is the unit an attacker gets addresses in
|
|
// and therefore the unit worth limiting.
|
|
ipv6BucketBits = 64
|
|
)
|
|
|
|
// normalizeAddr strips the IPv4-in-IPv6 wrapper and any zone from
|
|
// addr so that comparisons and bucket keys are canonical.
|
|
func normalizeAddr(addr netip.Addr) netip.Addr {
|
|
return addr.Unmap().WithZone("")
|
|
}
|
|
|
|
// bucketKey is the rate-limit bucket identity of a client address.
|
|
// IPv4 keys on the full address; IPv6 keys on its /64 prefix,
|
|
// because keying IPv6 per /128 lets one ordinary subscriber rotate
|
|
// source addresses inside its own routed /64 and mint a fresh bucket
|
|
// per request — evading every limiter here at the network layer,
|
|
// with no spoofing and nothing to detect.
|
|
//
|
|
// An IPv4-mapped address (::ffff:1.2.3.4) is keyed as the IPv4
|
|
// address it carries, never masked to a /64: mapped form all shares
|
|
// the ::ffff:0:0/96 prefix, so masking would collapse every IPv4
|
|
// client reaching a proxy that emits it into one bucket. Callers
|
|
// pass addresses through normalizeAddr, which already unmaps; the
|
|
// unmap here keeps the property true of the key function itself.
|
|
//
|
|
// The two families cannot collide: an IPv4 key is a bare dotted
|
|
// quad, and an IPv6 key always carries a "/64" suffix.
|
|
func bucketKey(addr netip.Addr) string {
|
|
addr = addr.Unmap()
|
|
|
|
if addr.Is4() {
|
|
return addr.String()
|
|
}
|
|
|
|
// Prefix errors only on a negative bit count, on over 32 bits
|
|
// for an IPv4 address, or on over 128 for IPv6. The count here
|
|
// is the constant 64 and the IPv4 case returned above, so the
|
|
// error is unreachable. (The zero Addr does not error either: it
|
|
// yields the zero Prefix. Neither call site can produce one,
|
|
// since both parse the address first.)
|
|
prefix, _ := addr.Prefix(ipv6BucketBits)
|
|
|
|
return prefix.String()
|
|
}
|
|
|
|
// isTrustedProxy reports whether addr belongs to a network the
|
|
// operator listed in TRUSTED_PROXIES. The list is empty by default,
|
|
// so by default nothing is trusted.
|
|
func (m *Middleware) isTrustedProxy(addr netip.Addr) bool {
|
|
for _, prefix := range m.params.Config.TrustedProxies {
|
|
if prefix.Contains(addr) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// forwardedClientAddr returns the client address named by this
|
|
// request's X-Forwarded-For chain. It is consulted only for requests
|
|
// whose direct peer is a trusted proxy.
|
|
//
|
|
// X-Forwarded-For is the only header read. X-Real-IP and
|
|
// True-Client-IP are deliberately ignored: the reverse proxies in
|
|
// common use append to X-Forwarded-For and pass any other header the
|
|
// client sent through untouched, so believing a single-valued header
|
|
// would let a client behind the trusted proxy name its own bucket —
|
|
// the very bypass this gating exists to close.
|
|
//
|
|
// The chain is walked right to left, because the rightmost entry is
|
|
// the one the nearest proxy appended and everything to its left may
|
|
// have been written by the client. The first hop that is not itself
|
|
// a trusted proxy is the client. A hop that cannot be read as a bare
|
|
// address ends the walk: past it the chain is not the shape assumed
|
|
// here, so the caller falls back to the peer address.
|
|
//
|
|
// Only the last maxForwardedHops entries are examined. A longer chain
|
|
// is padding, and running out of hops falls back to the peer address
|
|
// the same way an unreadable hop does.
|
|
//
|
|
// The entries are cut off the right end of each header value in place
|
|
// rather than split out of it: the receiver is unauthenticated and a
|
|
// client can pad the header up to MaxHeaderBytes, so splitting would
|
|
// allocate in proportion to the padding (about 8 MB for a 1 MB
|
|
// header) before the cap could discard any of it. Multiple header
|
|
// values are walked in reverse for the same reason, since joining
|
|
// them copies the whole chain.
|
|
func (m *Middleware) forwardedClientAddr(
|
|
r *http.Request,
|
|
) (netip.Addr, bool) {
|
|
seen := 0
|
|
|
|
for _, value := range slices.Backward(
|
|
r.Header.Values("X-Forwarded-For"),
|
|
) {
|
|
for last := false; !last && seen < maxForwardedHops; seen++ {
|
|
hop := value
|
|
|
|
comma := strings.LastIndexByte(value, ',')
|
|
if comma < 0 {
|
|
last = true
|
|
} else {
|
|
hop, value = value[comma+1:], value[:comma]
|
|
}
|
|
|
|
hop = strings.TrimSpace(hop)
|
|
if hop == "" {
|
|
continue
|
|
}
|
|
|
|
addr, err := netip.ParseAddr(hop)
|
|
if err != nil {
|
|
return netip.Addr{}, false
|
|
}
|
|
|
|
if addr = normalizeAddr(addr); !m.isTrustedProxy(addr) {
|
|
return addr, true
|
|
}
|
|
}
|
|
}
|
|
|
|
return netip.Addr{}, false
|
|
}
|
|
|
|
// rateLimitKey is the client identity every rate limiter in this
|
|
// package buckets on. Forwarded headers are honoured only when the
|
|
// direct peer (RemoteAddr) is inside the configured trusted-proxy
|
|
// set; otherwise the peer address itself is the key. Without that
|
|
// gate any client could mint a fresh bucket per request, or starve
|
|
// another client's bucket, by picking an X-Forwarded-For value —
|
|
// which makes every limit here decorative against a deliberate
|
|
// attacker.
|
|
//
|
|
// The address that identifies the client is then reduced to a bucket
|
|
// by bucketKey: full address for IPv4, /64 prefix for IPv6.
|
|
func (m *Middleware) rateLimitKey(r *http.Request) (string, error) {
|
|
return m.clientKey(r), nil
|
|
}
|
|
|
|
// clientKey computes the bucket key described on rateLimitKey.
|
|
func (m *Middleware) clientKey(r *http.Request) string {
|
|
peer, err := netip.ParseAddr(ipFromHostPort(r.RemoteAddr))
|
|
if err != nil {
|
|
// Not an address we can reason about; key on the raw
|
|
// value, the most specific identity left. Distinct
|
|
// RemoteAddr values stay in distinct buckets, so this
|
|
// path cannot silently collapse unrelated clients
|
|
// together. On a Unix-socket listener every peer
|
|
// carries the same RemoteAddr and so shares one bucket,
|
|
// which is the fail-closed direction.
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
peer = normalizeAddr(peer)
|
|
if !m.isTrustedProxy(peer) {
|
|
return bucketKey(peer)
|
|
}
|
|
|
|
if addr, ok := m.forwardedClientAddr(r); ok {
|
|
return bucketKey(addr)
|
|
}
|
|
|
|
return bucketKey(peer)
|
|
}
|
|
|
|
// tooManyRequests returns the 429 handler used by the
|
|
// password-change and per-entrypoint receiver limiters: it logs the
|
|
// rejection with logMessage and answers with responseMessage.
|
|
// httprate adds the Retry-After header (RFC 6585). The aggregate
|
|
// receiver limiter uses floodTooManyRequests instead.
|
|
//
|
|
// The path is capped against the same budget as the access log's url
|
|
// field. The per-entrypoint receiver limiter is unauthenticated and
|
|
// its path is a client-chosen segment of client-chosen length, so at
|
|
// WARN an uncapped path would let a sender pick the size of the line
|
|
// it writes — the same defect the access log capping closed.
|
|
func (m *Middleware) tooManyRequests(
|
|
logMessage, responseMessage string,
|
|
) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
m.log.Warn(
|
|
logMessage,
|
|
"path", logfield.Truncate(
|
|
r.URL.Path, logfield.MaxBytes,
|
|
),
|
|
)
|
|
http.Error(w, responseMessage, http.StatusTooManyRequests)
|
|
}
|
|
}
|
|
|
|
// floodTooManyRequests returns the 429 handler for a limiter whose
|
|
// rejections are themselves the flood: it logs at DEBUG and without
|
|
// the path, then answers with responseMessage.
|
|
//
|
|
// The aggregate receiver limiter trips exactly when one address is
|
|
// sending faster than the receiver wants to serve, so its rejection
|
|
// log is one line per request of that flood. At WARN with "path" that
|
|
// hands a client a way to write its own text into the operator's log,
|
|
// at a level that trips alerting, once per request — the log-volume
|
|
// problem this limiter exists to bound. DEBUG is off in production by
|
|
// default, so a flood costs nothing here; the path is dropped so that
|
|
// turning DEBUG on to diagnose one does not restore the problem.
|
|
//
|
|
// This limiter bounds the database work an invented path costs, not
|
|
// the number of log lines it produces: the access log in
|
|
// middleware.go still records every request, served or rejected.
|
|
func (m *Middleware) floodTooManyRequests(
|
|
logMessage, responseMessage string,
|
|
) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
m.log.Debug(logMessage)
|
|
http.Error(w, responseMessage, http.StatusTooManyRequests)
|
|
}
|
|
}
|
|
|
|
// PasswordChangeRateLimit returns middleware that enforces
|
|
// per-IP rate limiting on password change attempts. The change
|
|
// endpoint verifies the current password, so without a limit a
|
|
// stolen session could be used to brute-force it.
|
|
//
|
|
// Unlike the login POST this limit is still spent on arrival, which
|
|
// is safe here: RequireAuth runs ahead of it, so only a request
|
|
// already carrying a valid session can reach the bucket, and an
|
|
// operator locked out of changing a password can still log in.
|
|
func (m *Middleware) PasswordChangeRateLimit() func(http.Handler) http.Handler {
|
|
return m.postRateLimit(
|
|
passwordChangeRateLimit,
|
|
passwordChangeRateInterval,
|
|
"password change rate limit exceeded",
|
|
"Too many password change attempts. "+
|
|
"Please try again later.",
|
|
)
|
|
}
|
|
|
|
// postRateLimit builds middleware that enforces a per-IP rate
|
|
// limit on POST requests only; all other methods pass through
|
|
// unaffected. Requests over the limit receive a 429 with the
|
|
// given response message, and each rejection is logged with the
|
|
// given log message. Clients are identified by rateLimitKey.
|
|
func (m *Middleware) postRateLimit(
|
|
limit int,
|
|
interval time.Duration,
|
|
logMessage, responseMessage string,
|
|
) func(http.Handler) http.Handler {
|
|
limiter := httprate.Limit(
|
|
limit,
|
|
interval,
|
|
httprate.WithKeyFuncs(m.rateLimitKey),
|
|
httprate.WithLimitHandler(
|
|
m.tooManyRequests(logMessage, responseMessage),
|
|
),
|
|
)
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
limited := limiter(next)
|
|
|
|
return http.HandlerFunc(func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
// Only rate-limit POST requests.
|
|
if r.Method != http.MethodPost {
|
|
next.ServeHTTP(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
limited.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ReceiverRateLimit returns middleware that rate-limits the public
|
|
// webhook receiver endpoint with two limits in series.
|
|
//
|
|
// The inner limit is per client IP per request path: the path
|
|
// contains the entrypoint UUID, so each sender is limited per
|
|
// entrypoint without affecting other senders or other entrypoints.
|
|
// It is Config.ReceiverRateLimit requests per minute.
|
|
//
|
|
// That limit alone bounds nothing in aggregate. The route pattern
|
|
// /webhook/{uuid} matches any single segment, so a client that
|
|
// invents a fresh path per request mints a fresh bucket per request
|
|
// and never refills one — and every such request still reaches the
|
|
// handler's entrypoint lookup before it 404s. The outer limit is
|
|
// therefore keyed on the client IP alone, capping what one address
|
|
// can spend across the whole route however it varies the path.
|
|
//
|
|
// Requests over either limit receive a 429. Clients are identified
|
|
// by rateLimitKey.
|
|
func (m *Middleware) ReceiverRateLimit() func(http.Handler) http.Handler {
|
|
perEntrypoint := httprate.Limit(
|
|
m.params.Config.ReceiverRateLimit,
|
|
receiverRateInterval,
|
|
httprate.WithKeyFuncs(
|
|
m.rateLimitKey,
|
|
httprate.KeyByEndpoint,
|
|
),
|
|
httprate.WithLimitHandler(m.tooManyRequests(
|
|
"webhook receiver rate limit exceeded",
|
|
"Too many requests. Please slow down.",
|
|
)),
|
|
)
|
|
|
|
aggregate := httprate.Limit(
|
|
receiverAggregateLimit(m.params.Config.ReceiverRateLimit),
|
|
receiverRateInterval,
|
|
httprate.WithKeyFuncs(m.rateLimitKey),
|
|
httprate.WithLimitHandler(m.floodTooManyRequests(
|
|
"webhook receiver aggregate rate limit exceeded",
|
|
"Too many requests. Please slow down.",
|
|
)),
|
|
)
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return aggregate(perEntrypoint(next))
|
|
}
|
|
}
|
|
|
|
// receiverAggregateLimit is the per-IP aggregate limit derived from
|
|
// the configured per-entrypoint limit. The operator sets the latter
|
|
// and nothing bounds it from above, so the multiplication is
|
|
// saturated rather than allowed to wrap into a negative limit that
|
|
// would reject every request.
|
|
func receiverAggregateLimit(perEntrypoint int) int {
|
|
if perEntrypoint > math.MaxInt/receiverAggregateMultiplier {
|
|
return math.MaxInt
|
|
}
|
|
|
|
return perEntrypoint * receiverAggregateMultiplier
|
|
}
|