The public receiver /webhook/{uuid} (internal/handlers/webhook.go, wired in internal/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 via LoginRateLimit() (internal/middleware/ratelimit.go).
Definition of done:
per-entrypoint (or per-IP) rate limiting on the receiver, with a configurable limit and sane default
requests over the limit receive HTTP 429 with a Retry-After header
a test exercises the limit (allowed under, rejected over)
Scope 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.
Part of the road to 1.0 (see #33).
The public receiver `/webhook/{uuid}` (`internal/handlers/webhook.go`, wired in `internal/server/routes.go` `setupWebhookRoutes()`) 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 via `LoginRateLimit()` (`internal/middleware/ratelimit.go`).
Definition of done:
- per-entrypoint (or per-IP) rate limiting on the receiver, with a configurable limit and sane default
- requests over the limit receive HTTP 429 with a `Retry-After` header
- a test exercises the limit (allowed under, rejected over)
Scope 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.
clawbot
added this to the 1.0.0 milestone 2026-08-07 13:11:08 +02:00
Every request to the public receiver /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.
The limit is configurable via a RECEIVER_RATE_LIMIT environment 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 (the envDuration/RETENTION_SWEEP_INTERVAL fail-loud pattern, not the envInt pattern tracked in #80).
Requests over the limit receive HTTP 429 with a Retry-After header (go-chi/httprate sets Retry-After on rejection per RFC 6585; verified in the vendored v0.15.0 source).
IP extraction honours X-Forwarded-For / X-Real-IP / True-Client-IP (same httprate.KeyByRealIP the login limiter uses), for reverse-proxy deployments.
Global/auth middleware behaviour is unchanged; the limit applies only to the receiver route.
Implementation plan
internal/config/config.go: add ReceiverRateLimit int loaded from RECEIVER_RATE_LIMIT with a new strict integer parser that returns an error (failing config.New, aborting fx startup) when the variable is set but unparseable or less than 1. Default 120/minute. envInt itself is NOT touched here — converting its other callers is #80's scope.
internal/middleware/ratelimit.go: add ReceiverRateLimit() using httprate.Limit with the configured per-minute limit, keyed by httprate.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 adds Retry-After and the X-RateLimit-* headers.
internal/server/routes.go: wrap the /webhook/{uuid} route in setupWebhookRoutes with 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).
Tests:
internal/middleware/ratelimit_test.go: under-limit requests pass; the request over the limit gets 429 with a non-empty Retry-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 make config.New fail with the variable name in the error.
Validation gates
make fmt applied; make test and make fmt-check green locally; docker build . green in CI (the authoritative gate, includes the pinned linter).
Branch issue-64-receiver-rate-limit from current main; 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.
## Definition of done and implementation plan
Picking this up as the next 1.0.0 work unit.
### Definition of done
- Every request to the public receiver `/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.
- The limit is configurable via a `RECEIVER_RATE_LIMIT` environment 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 (the `envDuration`/`RETENTION_SWEEP_INTERVAL` fail-loud pattern, not the `envInt` pattern tracked in #80).
- Requests over the limit receive HTTP 429 with a `Retry-After` header (go-chi/httprate sets `Retry-After` on rejection per RFC 6585; verified in the vendored v0.15.0 source).
- IP extraction honours `X-Forwarded-For` / `X-Real-IP` / `True-Client-IP` (same `httprate.KeyByRealIP` the login limiter uses), for reverse-proxy deployments.
- Global/auth middleware behaviour is unchanged; the limit applies only to the receiver route.
### Implementation plan
- `internal/config/config.go`: add `ReceiverRateLimit int` loaded from `RECEIVER_RATE_LIMIT` with a new strict integer parser that returns an error (failing `config.New`, aborting fx startup) when the variable is set but unparseable or less than 1. Default 120/minute. `envInt` itself is NOT touched here — converting its other callers is #80's scope.
- `internal/middleware/ratelimit.go`: add `ReceiverRateLimit()` using `httprate.Limit` with the configured per-minute limit, keyed by `httprate.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 adds `Retry-After` and the `X-RateLimit-*` headers.
- `internal/server/routes.go`: wrap the `/webhook/{uuid}` route in `setupWebhookRoutes` with 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).
- Tests:
- `internal/middleware/ratelimit_test.go`: under-limit requests pass; the request over the limit gets 429 with a non-empty `Retry-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 make `config.New` fail with the variable name in the error.
### Validation gates
- `make fmt` applied; `make test` and `make fmt-check` green locally; `docker build .` green in CI (the authoritative gate, includes the pinned linter).
- Branch `issue-64-receiver-rate-limit` from current `main`; 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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.
clawbot referenced this issue2026-08-11 14:48:08 +02:00