Some checks failed
check / check (push) Failing after 2m4s
MaxBodySize logged r.URL.Path untruncated at WARN, and routes.go registers it ahead of RequireAuth, so an unauthenticated POST /source/<8 KB>/edit with an oversize declared Content-Length wrote attacker-chosen text of attacker-chosen length into the operator's log, for the cost of a request with no body. The 2,560-byte per-line budget from #146 did not reach it: that budget lives in the access log's field capping and this is a separate slog call. The capping mechanism moves out of internal/middleware into internal/logfield so there is one budget and one implementation rather than a second ad-hoc truncation. Truncate and EncodedBytes are unchanged; the access log now spends logfield.MaxBytes where it spent maxLogFieldBytes. The sweep the issue asked for found five more call sites of the same shape, all reachable unauthenticated, all now capped: the CSRF 403 (also registered ahead of RequireAuth), the rate limiters' 429 (the per-entrypoint receiver limiter is unauthenticated), RequireAuth's own DEBUG line, the unknown-entrypoint DEBUG line on the receiver, and the failed-login DEBUG lines. DEBUG being off by default is not a bound: an operator turning it on to diagnose a flood must not thereby hand the flood an unbounded write. Every other slog call in the tree was read and judged; the PR body lists all of them, including the ones left alone and why. Two further sites arrived in next with #171 after the first sweep was written and are capped here as well: "login failure limit exceeded" in loginguard.go and "password verification capacity exhausted" in handlers/auth.go, both WARN on the unauthenticated login POST. Neither was ever wide — chi routes that POST on a static pattern, so r.URL.Path is the 12-byte constant /pages/login and each line lands near 120 bytes, and removing either cap breaks no test. They are capped because RecordLoginFailure is exported and takes any *http.Request, so the bound rests on a routing invariant nobody wrote down, and because the same message at handlers/profile.go logs no path at all. MaxBodySize stays ahead of RequireAuth. An oversize body should be refused before the request buys a cookie decrypt and a session load, and rejecting first is what keeps an unauthenticated flood from choosing how much session work the process does. The ordering and what it costs are now written at the registration, on maxFormBodySize. MaxAccessLogLineBytes is restated as the ceiling on every slog line carrying text an UNAUTHENTICATED client supplies, not just the access log's: each of these lines carries strictly fewer client-supplied fields than the access log does, so none can be wider. That is asserted per line under both handlers rather than argued. The claim is qualified rather than universal because three kinds of writer are outside it, and the README and the constant now name all three: lines carrying an authenticated operator's own input, which are not truncated at all (the webhook name on "webhook created" reaches 600 KB on one line from a 100 KB form field, measured; the SSRF-rejection url and the target_name lines are the same shape) and are left uncapped deliberately, since truncating the operator's own configuration echoed back costs debuggability against no adversary; the log delivery target, which exists to emit the whole event; and GORM's default logger, which prints the interpolated SQL to stdout on a record-not-found and is unbounded on the receiver and login lookups. That last one is a real defect this audit turned up and is filed separately as #178, not fixed here. Tests drive 8 KB of client-chosen text at all six sites, through both handlers internal/logger can install and through each character they escape — including a bare C0 control, which costs six bytes on the line against the one it cost to send and is the case a raw-byte budget breaks on first. Each holds the encoded line to the ceiling, holds the whole flood's output to what that ceiling allows, and asserts the markers at the far end of the input are absent, so a value that merely happened to be short cannot pass. The two login lines past the username lookup, capped for uniformity rather than need, are pinned too. internal/logfield gains a test that measures the per-rune charge against what the handlers really emit over roughly 3,000 code points on each, so an undercharged rune fails a test instead of quietly falsifying the ceiling. Verified by mutation: reverting the MaxBodySize cap alone fails 28 subtests with a 16,583-byte line against the 2,560 ceiling; reverting the other five fails 70; uncapping either of the two login lines past the username lookup fails both handlers on its own, so those two are independently pinned rather than jointly; budgeting raw bytes instead of encoded ones fails 23 across three packages. The two login-throttle WARN caps are the exception and are recorded as such: reverting them fails nothing, because the constant path gives the mutation nothing to widen.
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
|
|
}
|