Rate-limit the public webhook receiver endpoint (closes #64) #87
Reference in New Issue
Block a user
Delete Branch "issue-64-receiver-rate-limit"
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?
Implements the plan posted on #64: a dedicated abuse limit on the one unauthenticated, internet-exposed endpoint.
Behaviour
/webhook/{uuid}receiver route is wrapped with a newReceiverRateLimitmiddleware (route-scoped — no global middleware is touched, per the README design constraint that blanket limits must not apply to receiver endpoints).httprate.WithKeyFuncs(httprate.KeyByRealIP, httprate.KeyByEndpoint): one misbehaving sender is throttled without affecting other senders of the same entrypoint or the same sender's other entrypoints. IP extraction honoursX-Forwarded-For/X-Real-IP/True-Client-IPfor reverse-proxy deployments, same as the login limiter.Retry-Afterheader (RFC 6585) and theX-RateLimit-*headers.RECEIVER_RATE_LIMITrequests per minute, default 120. A set-but-unparseable or non-positive value ABORTS startup with an error naming the variable and the bad value, via a new strictenvPositiveIntparser (theenvDurationfail-loud pattern).envInt's existing callers are unchanged — converting them is #80's scope.config.New's environment validation was extracted intoresolveEnvironmentto keep the function within thefunlenlimit after the new parse block.Files
internal/config/config.go—ReceiverRateLimitfield,envPositiveInt,ErrNonPositiveValue,resolveEnvironmentextraction, startup log line.internal/middleware/ratelimit.go—ReceiverRateLimit()middleware.internal/server/routes.go— wraps the receiver route.README.md— env table row; the Rate Limiting design section rewritten to describe the shipped behaviour (it previously said "no rate limit by default", which #64 supersedes; per-webhook limits remain future work layered on top).TODO.md— synced (stale Next Step was #63, already merged).Tests
TestReceiverRateLimit_LimitsPerIPAndPath: under-limit requests pass; the request over the limit gets 429 with a non-emptyRetry-After; the same IP on a different entrypoint path and a different IP on the same path are both unaffected.TestReceiverRateLimit(config): default 120 when unset; valid value parsed; unparseable, zero, and negative values failconfig.New(fx startup aborts).Validation
make fmtapplied;make test(all 8 packages) andmake fmt-checkgreen locally.make linton the host shows only the 17 pre-existinggoconstfindings in files this PR does not touch (host golangci-lint is newer than the pinned CI image); none in this diff. CI (docker build .viascript/cibuild) on the PR head is the authoritative gate.Closes #64
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.Independent review of PR #87 (head
f32284a)Verdict: PASS
Definition of done (plan on #64)
setupWebhookRouteswraps/webhook/{uuid}vias.router.With(s.mw.ReceiverRateLimit()); no global middleware touched. Verified ininternal/server/routes.go.httprate.WithKeyFuncs(httprate.KeyByRealIP, httprate.KeyByEndpoint)composesip:path; the path contains the entrypoint UUID. Verified against the pinned httprate v0.15.0 source.Retry-After: httprate v0.15.0OnLimitsetsRetry-After(RFC 6585) before invoking the customWithLimitHandler, so the header survives the custom handler. The middleware test asserts a non-emptyRetry-Afteron the 429.RECEIVER_RATE_LIMITreq/min, default 120:defaultReceiverRateLimit = 120, windowreceiverRateInterval = 1 * time.Minute.envPositiveIntreturns the default only when the variable is unset; a set-but-unparseable value errors with the key and value;<1 errors via wrappedErrNonPositiveValue. The error propagates out ofconfig.New, aborting fx startup. No silent defaulting.Retry-After, same-IP/different-path unaffected, different-IP/same-path unaffected. Config tests cover default-when-unset, valid value, and abort on unparseable/zero/negative.envInt's existing callers untouched (correctly left to #80).resolveEnvironmentextraction is behavior-preserving.Gates
f32284a(check / check, 2m37s). Mergeable against currentmain(81413c5); branch is based on it.make testgreen (all packages),make fmt-checkclean.make checkfails only on the 17 known pre-existing goconst findings from host golangci-lint version skew, all in files this PR does not touch; the docker-pinned CI lint is authoritative and green.(closes #64), body accurate, no attribution trailers.resolveEnvironment/funlenextraction.Advisory findings (non-blocking)
internal/middleware/ratelimit.go—httprate.KeyByRealIPtrustsTrue-Client-IP,X-Real-IP, and the FIRSTX-Forwarded-Forentry unconditionally. The first XFF entry remains client-controlled even behind a correctly appending reverse proxy, so a deliberate attacker can (a) bypass the limit entirely by rotating a random XFF value per request (each request gets a fresh bucket) and (b) starve a legitimate sender by spoofing that sender's IP to exhaust its bucket. This matches the agreed plan on #64 and the existingLoginRateLimitpattern, so it is not blocking here — butREPO_POLICIES.md(reverse proxy awareness) requires forwarded headers be accepted only from configured trusted proxies before tagging 1.0. Recommend a tracking issue covering both limiters (trusted-proxy-gated real-IP resolution), and noting that attacker-minted keys also grow the in-memory counter within a window.internal/config/config_test.go—testReceiverRateLimitErrorasserts onlyassert.Error(t, app.Err()); the plan specified verifying the error names the variable. As written the test would also pass ifconfig.Newfailed for an unrelated reason. Recommend tightening torequire.ErrorContains(..., "RECEIVER_RATE_LIMIT")(anderrors.Is(err, config.ErrNonPositiveValue)for the zero/negative cases) in a future touch of this file.internal/middleware/ratelimit.go— the 429 limit-handler bodies ofLoginRateLimitandReceiverRateLimitare near-duplicates; a shared helper could remove the duplication next time this file is edited.No blocking defects found. PR #87 satisfies the definition of done on #64 and repo policy gates.
Manager note: independent review above (comment 43906) passed the PR — plan on #64 fully implemented, fail-loud
envPositiveIntverified, CI green on headf32284a, mergeable against currentmain. Settingmerge-readyand assigning to sneak for merge (protectedmain).The advisory findings (unconditional forwarded-header trust in
KeyByRealIP; looser-than-planned error assertions in the config tests; duplicated 429 handler bodies) are tracked as #88 and do not block this PR.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.