Rate-limit the public webhook receiver endpoint (closes #64)
All checks were successful
check / check (push) Successful in 2m37s
All checks were successful
check / check (push) Successful in 2m37s
The public receiver /webhook/{uuid} had no rate limiting: anyone
who learns an entrypoint UUID can flood it, inflating the
per-webhook database and the delivery queue.
Add a dedicated limit scoped to the receiver route, keyed per
client IP per request path (the path contains the entrypoint
UUID), so one misbehaving sender is throttled without affecting
other senders of the same entrypoint or other entrypoints.
Requests over the limit get a 429; httprate adds the Retry-After
header per RFC 6585. IP extraction honours X-Forwarded-For,
X-Real-IP, and True-Client-IP for reverse-proxy deployments.
The limit is RECEIVER_RATE_LIMIT requests per minute, default
120. A set-but-unparseable or non-positive value aborts startup
via the new envPositiveInt strict parser rather than silently
falling back to the default. envInt's other callers are
unchanged; converting them is tracked in #80.
Also update the README env table and Rate Limiting design
section, and sync TODO.md.
This commit is contained in:
@@ -14,6 +14,11 @@ const (
|
||||
|
||||
// loginRateInterval is the time window for the rate limit.
|
||||
loginRateInterval = 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
|
||||
@@ -62,3 +67,37 @@ func (m *Middleware) LoginRateLimit() func(http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
@@ -145,3 +147,94 @@ 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",
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user