Rate-limit the public webhook receiver endpoint (closes #64)
All checks were successful
check / check (push) Successful in 5s
All checks were successful
check / check (push) Successful in 5s
The receiver was the one unauthenticated, internet-facing endpoint with no rate limit, so a misbehaving or hostile sender could flood a webhook without bound. RECEIVER_RATE_LIMIT (default 120/min) now caps it, keyed on client IP plus entrypoint path so one entrypoint cannot exhaust another's budget. Over-limit requests get 429 with Retry-After. The limiter deliberately does not reuse postRateLimit: that helper is POST-only and keys on IP alone, whereas the receiver must count every method. A test locks that property in. Config parsing follows the fail-loudly idiom: a set-but-unparseable or non-positive value aborts startup rather than falling back to the default. Known limitation, tracked in #88: the key still trusts forwarded headers unconditionally, so the limit is evadable by rotating X-Forwarded-For until trusted-proxy gating lands.
This commit was merged in pull request #87.
This commit is contained in:
@@ -24,6 +24,11 @@ const (
|
||||
// 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
|
||||
)
|
||||
|
||||
// LoginRateLimit returns middleware that enforces per-IP rate
|
||||
@@ -105,3 +110,37 @@ func (m *Middleware) postRateLimit(
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ReceiverRateLimit returns middleware that rate-limits the
|
||||
// public webhook receiver endpoint 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). The limit is Config.ReceiverRateLimit
|
||||
// requests per minute. Requests over the limit receive a 429;
|
||||
// httprate adds the Retry-After header (RFC 6585). IP
|
||||
// extraction honours X-Forwarded-For, X-Real-IP, and
|
||||
// True-Client-IP headers for reverse-proxy setups.
|
||||
func (m *Middleware) ReceiverRateLimit() func(http.Handler) http.Handler {
|
||||
return httprate.Limit(
|
||||
m.params.Config.ReceiverRateLimit,
|
||||
receiverRateInterval,
|
||||
httprate.WithKeyFuncs(
|
||||
httprate.KeyByRealIP,
|
||||
httprate.KeyByEndpoint,
|
||||
),
|
||||
httprate.WithLimitHandler(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
m.log.Warn(
|
||||
"webhook receiver rate limit exceeded",
|
||||
"path", r.URL.Path,
|
||||
)
|
||||
http.Error(
|
||||
w,
|
||||
"Too many requests. "+
|
||||
"Please slow down.",
|
||||
http.StatusTooManyRequests,
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ package middleware_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -179,3 +181,133 @@ func TestLoginRateLimit_IndependentPerIP(t *testing.T) {
|
||||
"different IP should not be affected",
|
||||
)
|
||||
}
|
||||
|
||||
// receiverLimitedHandler builds a ReceiverRateLimit-wrapped
|
||||
// handler with the given per-minute limit.
|
||||
func receiverLimitedHandler(
|
||||
t *testing.T, limit int,
|
||||
) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
log := slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
))
|
||||
|
||||
m := middleware.NewForTest(
|
||||
log,
|
||||
&config.Config{ReceiverRateLimit: limit},
|
||||
nil,
|
||||
)
|
||||
|
||||
return m.ReceiverRateLimit()(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
// 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",
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user