Rate-limit the public webhook receiver endpoint #64
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Part of the road to 1.0 (see #33).
The public receiver
/webhook/{uuid}(internal/handlers/webhook.go, wired ininternal/server/routes.gosetupWebhookRoutes()) has no rate limiting. Anyone who learns an entrypoint UUID can flood it, inflating the per-webhook database and the delivery queue. The login endpoint already demonstrates the pattern viaLoginRateLimit()(internal/middleware/ratelimit.go).Definition of done:
Retry-AfterheaderScope note for @sneak: is this a 1.0 blocker or post-1.0? It pairs naturally with the retention reaper as abuse/growth control on the one unauthenticated, internet-exposed surface.
Definition of done and implementation plan
Picking this up as the next 1.0.0 work unit.
Definition of done
/webhook/{uuid}is rate-limited per client IP per entrypoint path: the same sender hammering one entrypoint is throttled without affecting other senders of that entrypoint or the same sender's other entrypoints.RECEIVER_RATE_LIMITenvironment variable (requests per minute), default 120. A set-but-unparseable or non-positive value ABORTS startup with a clear error naming the variable and the bad value — no silent defaulting (theenvDuration/RETENTION_SWEEP_INTERVALfail-loud pattern, not theenvIntpattern tracked in #80).Retry-Afterheader (go-chi/httprate setsRetry-Afteron rejection per RFC 6585; verified in the vendored v0.15.0 source).X-Forwarded-For/X-Real-IP/True-Client-IP(samehttprate.KeyByRealIPthe login limiter uses), for reverse-proxy deployments.Implementation plan
internal/config/config.go: addReceiverRateLimit intloaded fromRECEIVER_RATE_LIMITwith a new strict integer parser that returns an error (failingconfig.New, aborting fx startup) when the variable is set but unparseable or less than 1. Default 120/minute.envIntitself is NOT touched here — converting its other callers is #80's scope.internal/middleware/ratelimit.go: addReceiverRateLimit()usinghttprate.Limitwith the configured per-minute limit, keyed byhttprate.WithKeyFuncs(httprate.KeyByRealIP, httprate.KeyByEndpoint)(composite key: client IP + request path, and the path contains the entrypoint UUID). Custom limit handler logs a warning and returns 429 with a clear message; httprate addsRetry-Afterand theX-RateLimit-*headers.internal/server/routes.go: wrap the/webhook/{uuid}route insetupWebhookRouteswith the new middleware. All methods on the route are counted (the receiver is POST-only and 405s the rest; scanners burning the budget on GETs is acceptable and desirable).internal/middleware/ratelimit_test.go: under-limit requests pass; the request over the limit gets 429 with a non-emptyRetry-After; a different entrypoint path from the same IP is unaffected; a different IP on the same path is unaffected.internal/config/config_test.go: default applies when unset; a valid value is used; unparseable and non-positive values makeconfig.Newfail with the variable name in the error.Validation gates
make fmtapplied;make testandmake fmt-checkgreen locally;docker build .green in CI (the authoritative gate, includes the pinned linter).issue-64-receiver-rate-limitfrom currentmain; PR titled ending with(closes #64), labelled needs-review and assigned to clawbot for independent review — not self-labelled merge-ready.On the scope question in the issue body: implementing now as part of 1.0.0 (it is already milestoned); it is the only unauthenticated internet-exposed surface and the retention reaper only cleans up after abuse rather than preventing it. Per-webhook configurable limits (the TODO "Future Steps" item) can layer on top later without conflicting with this env-level limit.