Rate-limit the public webhook receiver endpoint (closes #64) #87

Merged
clawbot merged 1 commits from issue-64-receiver-rate-limit into next 2026-08-11 14:47:22 +02:00
Collaborator

Implements the plan posted on #64: a dedicated abuse limit on the one unauthenticated, internet-exposed endpoint.

Behaviour

  • The /webhook/{uuid} receiver route is wrapped with a new ReceiverRateLimit middleware (route-scoped — no global middleware is touched, per the README design constraint that blanket limits must not apply to receiver endpoints).
  • The limit is keyed per client IP per request path via 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 honours X-Forwarded-For / X-Real-IP / True-Client-IP for reverse-proxy deployments, same as the login limiter.
  • Requests over the limit receive HTTP 429 with a clear message; httprate sets the Retry-After header (RFC 6585) and the X-RateLimit-* headers.
  • The limit is RECEIVER_RATE_LIMIT requests 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 the envPositiveInt strict parser that now lives on next.

Files

  • internal/config/config.goReceiverRateLimit field and default, parsed in loadFromEnv via envPositiveInt, startup log line.
  • internal/middleware/ratelimit.goReceiverRateLimit() middleware.
  • internal/server/routes.go — wraps the receiver route.
  • README.md — env table row, a note that RECEIVER_RATE_LIMIT must be at least 1 in the "Invalid values abort startup" section, and 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).

Tests

  • TestReceiverRateLimit_LimitsPerIPAndPath: under-limit requests pass; the request over the limit gets 429 with a non-empty Retry-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 fail config.New (fx startup aborts).

Validation

make fmt applied. make check green (tests, lint, fmt-check). script/cibuild green with the lint stage executing rather than cached (RUN make lint -> 0 issues., 64.6s) and RUN make test executing in-container.

Closes #64

Implements the plan posted on [#64](https://git.eeqj.de/sneak/webhooker/issues/64): a dedicated abuse limit on the one unauthenticated, internet-exposed endpoint. ## Behaviour - The `/webhook/{uuid}` receiver route is wrapped with a new `ReceiverRateLimit` middleware (route-scoped — no global middleware is touched, per the README design constraint that blanket limits must not apply to receiver endpoints). - The limit is keyed per client IP per request path via `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 honours `X-Forwarded-For` / `X-Real-IP` / `True-Client-IP` for reverse-proxy deployments, same as the login limiter. - Requests over the limit receive HTTP 429 with a clear message; httprate sets the `Retry-After` header (RFC 6585) and the `X-RateLimit-*` headers. - The limit is `RECEIVER_RATE_LIMIT` requests 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 the `envPositiveInt` strict parser that now lives on `next`. ## Files - `internal/config/config.go` — `ReceiverRateLimit` field and default, parsed in `loadFromEnv` via `envPositiveInt`, startup log line. - `internal/middleware/ratelimit.go` — `ReceiverRateLimit()` middleware. - `internal/server/routes.go` — wraps the receiver route. - `README.md` — env table row, a note that `RECEIVER_RATE_LIMIT` must be at least 1 in the "Invalid values abort startup" section, and the Rate Limiting design section rewritten to describe the shipped behaviour (it previously said "no rate limit by default", which [#64](https://git.eeqj.de/sneak/webhooker/issues/64) supersedes; per-webhook limits remain future work layered on top). ## Tests - `TestReceiverRateLimit_LimitsPerIPAndPath`: under-limit requests pass; the request over the limit gets 429 with a non-empty `Retry-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 fail `config.New` (fx startup aborts). ## Validation `make fmt` applied. `make check` green (tests, lint, fmt-check). `script/cibuild` green with the lint stage executing rather than cached (`RUN make lint` -> `0 issues.`, 64.6s) and `RUN make test` executing in-container. Closes #64
clawbot added the needs-review label 2026-08-07 18:52:13 +02:00
clawbot self-assigned this 2026-08-07 18:52:13 +02:00
Author
Collaborator

Independent review of PR #87 (head f32284a)

Verdict: PASS

Definition of done (plan on #64)

  • Route-scoped limit only: setupWebhookRoutes wraps /webhook/{uuid} via s.router.With(s.mw.ReceiverRateLimit()); no global middleware touched. Verified in internal/server/routes.go.
  • Keyed per client IP per path: httprate.WithKeyFuncs(httprate.KeyByRealIP, httprate.KeyByEndpoint) composes ip:path; the path contains the entrypoint UUID. Verified against the pinned httprate v0.15.0 source.
  • 429 + Retry-After: httprate v0.15.0 OnLimit sets Retry-After (RFC 6585) before invoking the custom WithLimitHandler, so the header survives the custom handler. The middleware test asserts a non-empty Retry-After on the 429.
  • RECEIVER_RATE_LIMIT req/min, default 120: defaultReceiverRateLimit = 120, window receiverRateInterval = 1 * time.Minute.
  • Fail-loud config: envPositiveInt returns the default only when the variable is unset; a set-but-unparseable value errors with the key and value; < 1 errors via wrapped ErrNonPositiveValue. The error propagates out of config.New, aborting fx startup. No silent defaulting.
  • Tests: middleware test covers under-limit pass, over-limit 429 with 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). resolveEnvironment extraction is behavior-preserving.

Gates

  • CI green on head f32284a (check / check, 2m37s). Mergeable against current main (81413c5); branch is based on it.
  • Locally on the head: make test green (all packages), make fmt-check clean. make check fails 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.
  • Commit message: single commit, title ends (closes #64), body accurate, no attribution trailers.
  • README env table row and Rate Limiting section match shipped behavior; TODO.md sync is accurate. No scope creep beyond the declared resolveEnvironment/funlen extraction.

Advisory findings (non-blocking)

  1. internal/middleware/ratelimit.gohttprate.KeyByRealIP trusts True-Client-IP, X-Real-IP, and the FIRST X-Forwarded-For entry 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 existing LoginRateLimit pattern, so it is not blocking here — but REPO_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.
  2. internal/config/config_test.gotestReceiverRateLimitError asserts only assert.Error(t, app.Err()); the plan specified verifying the error names the variable. As written the test would also pass if config.New failed for an unrelated reason. Recommend tightening to require.ErrorContains(..., "RECEIVER_RATE_LIMIT") (and errors.Is(err, config.ErrNonPositiveValue) for the zero/negative cases) in a future touch of this file.
  3. internal/middleware/ratelimit.go — the 429 limit-handler bodies of LoginRateLimit and ReceiverRateLimit are 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.

## Independent review of PR #87 (head f32284a) **Verdict: PASS** ### Definition of done (plan on #64) - Route-scoped limit only: `setupWebhookRoutes` wraps `/webhook/{uuid}` via `s.router.With(s.mw.ReceiverRateLimit())`; no global middleware touched. Verified in `internal/server/routes.go`. - Keyed per client IP per path: `httprate.WithKeyFuncs(httprate.KeyByRealIP, httprate.KeyByEndpoint)` composes `ip:path`; the path contains the entrypoint UUID. Verified against the pinned httprate v0.15.0 source. - 429 + `Retry-After`: httprate v0.15.0 `OnLimit` sets `Retry-After` (RFC 6585) before invoking the custom `WithLimitHandler`, so the header survives the custom handler. The middleware test asserts a non-empty `Retry-After` on the 429. - `RECEIVER_RATE_LIMIT` req/min, default 120: `defaultReceiverRateLimit = 120`, window `receiverRateInterval = 1 * time.Minute`. - Fail-loud config: `envPositiveInt` returns the default only when the variable is unset; a set-but-unparseable value errors with the key and value; `<` 1 errors via wrapped `ErrNonPositiveValue`. The error propagates out of `config.New`, aborting fx startup. No silent defaulting. - Tests: middleware test covers under-limit pass, over-limit 429 with `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). `resolveEnvironment` extraction is behavior-preserving. ### Gates - CI green on head f32284a (`check / check`, 2m37s). Mergeable against current `main` (81413c5); branch is based on it. - Locally on the head: `make test` green (all packages), `make fmt-check` clean. `make check` fails 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. - Commit message: single commit, title ends `(closes #64)`, body accurate, no attribution trailers. - README env table row and Rate Limiting section match shipped behavior; TODO.md sync is accurate. No scope creep beyond the declared `resolveEnvironment`/`funlen` extraction. ### Advisory findings (non-blocking) 1. `internal/middleware/ratelimit.go` — `httprate.KeyByRealIP` trusts `True-Client-IP`, `X-Real-IP`, and the FIRST `X-Forwarded-For` entry 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 existing `LoginRateLimit` pattern, so it is not blocking here — but `REPO_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. 2. `internal/config/config_test.go` — `testReceiverRateLimitError` asserts only `assert.Error(t, app.Err())`; the plan specified verifying the error names the variable. As written the test would also pass if `config.New` failed for an unrelated reason. Recommend tightening to `require.ErrorContains(..., "RECEIVER_RATE_LIMIT")` (and `errors.Is(err, config.ErrNonPositiveValue)` for the zero/negative cases) in a future touch of this file. 3. `internal/middleware/ratelimit.go` — the 429 limit-handler bodies of `LoginRateLimit` and `ReceiverRateLimit` are 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.
clawbot removed their assignment 2026-08-07 19:11:29 +02:00
sneak was assigned by clawbot 2026-08-07 19:11:29 +02:00
clawbot added merge-ready and removed needs-review labels 2026-08-07 19:11:29 +02:00
Author
Collaborator

Manager note: independent review above (comment 43906) passed the PR — plan on #64 fully implemented, fail-loud envPositiveInt verified, CI green on head f32284a, mergeable against current main. Setting merge-ready and assigning to sneak for merge (protected main).

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.

Manager note: independent review above (comment 43906) passed the PR — plan on #64 fully implemented, fail-loud `envPositiveInt` verified, CI green on head f32284a, mergeable against current `main`. Setting `merge-ready` and assigning to sneak for merge (protected `main`). 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.
Author
Collaborator

Rebase hazard — please read before merging this PR

Not a review of this PR, and no labels or assignment changed. This is a heads-up from the independent review of PR #92 (#80), which is now also merge-ready.

PR #92 generalizes the fail-loud env parsing this PR introduced for RECEIVER_RATE_LIMIT. The two branches overlap in internal/config/config.go:

  • Both define envPositiveInt, ErrNonPositiveValue, and resolveEnvironment. These were deliberately written byte-for-byte identical on both branches (verified mechanically during the #92 review by extracting and diffing both copies), so the conflict is a delete-one-copy operation with no semantic merge needed.
  • The trap: this branch still carries the old envInt, the silent-fallback helper that returns the default when a set value fails to parse. PR #92 deletes it outright and routes PORT through a new envPort.

If #92 lands first, the rebase of this branch must delete both the duplicated helpers and envInt. A mechanical conflict resolution that keeps envInt would silently reinstate the exact defect #80 exists to fix, and nothing would fail — no test, no lint, no build. envInt would simply sit there unused until someone wired a new variable through it and quietly reintroduced silent defaulting.

If this PR lands first instead, #92 rebases onto it and the same requirement applies in the other direction: envInt must not survive.

Either ordering is fine; the only thing that matters is that envInt does not exist on main once both have landed. Grepping for envInt after the merge is a sufficient check.

## Rebase hazard — please read before merging this PR Not a review of this PR, and no labels or assignment changed. This is a heads-up from the independent review of PR #92 (#80), which is now also `merge-ready`. PR #92 generalizes the fail-loud env parsing this PR introduced for `RECEIVER_RATE_LIMIT`. The two branches overlap in `internal/config/config.go`: - Both define `envPositiveInt`, `ErrNonPositiveValue`, and `resolveEnvironment`. These were deliberately written **byte-for-byte identical** on both branches (verified mechanically during the #92 review by extracting and diffing both copies), so the conflict is a delete-one-copy operation with no semantic merge needed. - **The trap:** this branch still carries the old `envInt`, the silent-fallback helper that returns the default when a set value fails to parse. PR #92 deletes it outright and routes `PORT` through a new `envPort`. If #92 lands first, the rebase of this branch must delete **both** the duplicated helpers **and** `envInt`. A mechanical conflict resolution that keeps `envInt` would silently reinstate the exact defect #80 exists to fix, and **nothing would fail** — no test, no lint, no build. `envInt` would simply sit there unused until someone wired a new variable through it and quietly reintroduced silent defaulting. If this PR lands first instead, #92 rebases onto it and the same requirement applies in the other direction: `envInt` must not survive. Either ordering is fine; the only thing that matters is that `envInt` does not exist on `main` once both have landed. Grepping for `envInt` after the merge is a sufficient check.
clawbot changed title from Rate-limit the public webhook receiver endpoint (closes #64) to WIP: Rate-limit the public webhook receiver endpoint (closes #64) 2026-08-10 14:37:26 +02:00
sneak was unassigned by clawbot 2026-08-10 14:40:43 +02:00
clawbot self-assigned this 2026-08-10 14:40:43 +02:00
clawbot added needs-rebase and removed merge-ready labels 2026-08-10 14:40:49 +02:00
clawbot changed title from WIP: Rate-limit the public webhook receiver endpoint (closes #64) to Rate-limit the public webhook receiver endpoint (closes #64) 2026-08-10 15:42:05 +02:00
clawbot changed target branch from main to next 2026-08-10 15:42:05 +02:00
clawbot added 1 commit 2026-08-10 15:42:05 +02:00
Rate-limit the public webhook receiver endpoint (closes #64)
All checks were successful
check / check (push) Successful in 2m37s
f32284a39f
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.
clawbot force-pushed issue-64-receiver-rate-limit from f32284a39f to 595d352d6e 2026-08-11 14:22:51 +02:00 Compare
Author
Collaborator

Rebased onto current next (head now 595d352, single commit, base still next). Four files conflicted; resolution:

  • TODO.md — took next's version outright; this branch's TODO changes are dropped.
  • internal/config/config.go — adopted next's current idiom. #92 landed envPositiveInt, ErrNonPositiveValue and resolveEnvironment, so this branch's identical copies were deleted; RECEIVER_RATE_LIMIT is now parsed inside loadFromEnv alongside the other variables. The duplicated envPositiveInt merged in cleanly outside the conflict region and broke the build until removed, which is exactly the hazard flagged in comment 46213 — grep confirms one definition each and no envInt anywhere.
  • internal/middleware/ratelimit.go — conflict was the const block only; kept both next's passwordChangeRate* and this branch's receiverRateInterval. ReceiverRateLimit deliberately does not use next's new postRateLimit helper: that helper is POST-only and keyed on IP alone, while the receiver must count every method and key on IP+path.
  • README.md — kept next's session-timeout and "Invalid values abort startup" prose, added the RECEIVER_RATE_LIMIT table row into next's table, and noted there that the value must be at least 1 (the extra constraint beyond parseability, stated the same way as PORT's range). This branch's Rate Limiting design section rewrite applied outside the conflict and is unchanged.

Two follow-on fixes the resolution required: adding a third env-parsing test table tripped goconst on the three shared subtest names, so those are now consts shared by all three tables; and testReceiverRateLimitError was dropped in favour of next's identical expectStartupError helper.

No observable behaviour change from the resolution — same default 120/min, same per-IP-per-entrypoint keying, same 429 with Retry-After, same abort on a set-but-unparseable or non-positive value. Gates: make check green (0 lint issues, all packages pass), and script/cibuild green with the lint stage executing rather than cached (RUN make lint -> 0 issues. in 64.6s).

Rebased onto current `next` (head now 595d352, single commit, base still `next`). Four files conflicted; resolution: - `TODO.md` — took `next`'s version outright; this branch's TODO changes are dropped. - `internal/config/config.go` — adopted `next`'s current idiom. [#92](https://git.eeqj.de/sneak/webhooker/pulls/92) landed `envPositiveInt`, `ErrNonPositiveValue` and `resolveEnvironment`, so this branch's identical copies were deleted; `RECEIVER_RATE_LIMIT` is now parsed inside `loadFromEnv` alongside the other variables. The duplicated `envPositiveInt` merged in cleanly outside the conflict region and broke the build until removed, which is exactly the hazard flagged in comment 46213 — grep confirms one definition each and no `envInt` anywhere. - `internal/middleware/ratelimit.go` — conflict was the const block only; kept both `next`'s `passwordChangeRate*` and this branch's `receiverRateInterval`. `ReceiverRateLimit` deliberately does not use `next`'s new `postRateLimit` helper: that helper is POST-only and keyed on IP alone, while the receiver must count every method and key on IP+path. - `README.md` — kept `next`'s session-timeout and "Invalid values abort startup" prose, added the `RECEIVER_RATE_LIMIT` table row into `next`'s table, and noted there that the value must be at least 1 (the extra constraint beyond parseability, stated the same way as `PORT`'s range). This branch's Rate Limiting design section rewrite applied outside the conflict and is unchanged. Two follow-on fixes the resolution required: adding a third env-parsing test table tripped `goconst` on the three shared subtest names, so those are now `const`s shared by all three tables; and `testReceiverRateLimitError` was dropped in favour of `next`'s identical `expectStartupError` helper. No observable behaviour change from the resolution — same default 120/min, same per-IP-per-entrypoint keying, same 429 with `Retry-After`, same abort on a set-but-unparseable or non-positive value. Gates: `make check` green (0 lint issues, all packages pass), and `script/cibuild` green with the lint stage executing rather than cached (`RUN make lint` -> `0 issues.` in 64.6s).
clawbot added needs-review and removed needs-rebase labels 2026-08-11 14:24:23 +02:00
Author
Collaborator

Independent review of PR #87 (head 595d352) — post-rebase, reviewed as new code

Verdict: PASS. Definition of done on #64 met; rebase resolution verified claim-by-claim; CI green on 595d352; fast-forward onto next.

Anomalies and disclosures:

  1. internal/middleware/ratelimit_test.goreceiverPost sends only http.MethodPost, so the "counts every method" property is untested. That property is the sole stated justification for ReceiverRateLimit() not reusing postRateLimit. Probe: replacing the body of ReceiverRateLimit() with POST-only gating (i.e. what postRateLimit does) leaves the entire suite green. The behaviour is correct today — verified at both the middleware and the chi With().HandleFunc() router level, where all of POST/GET/PUT hit the limiter — but nothing guards it against a future refactor that "simplifies" it into the shared helper, which is exactly the class of silent regression this branch's own auto-merge already demonstrated. Non-blocking; one added GET-over-limit assertion closes it.
  2. internal/config/config_test.go:184expectStartupError asserts only assert.Error(t, app.Err()), so the receiver cases would pass on an unrelated startup failure and do not verify the error names RECEIVER_RATE_LIMIT as the DoD wording requires. This is next's existing helper, shared by all three env tables, so reusing it is idiom-consistent; flagged previously as advisory and unchanged.
  3. Forwarded-header trust is unchanged from the pre-rebase revision and remains tracked in #88; not re-reported here.

Rebase claims verified: envInt exists nowhere in the tree; exactly one definition each of envPositiveInt, ErrNonPositiveValue, resolveEnvironment, envPort, envDuration; no duplicated or dead helpers; testReceiverRateLimitError fully removed in favour of expectStartupError; RECEIVER_RATE_LIMIT parsed inside loadFromEnv via envPositiveInt with no silent-default path. Per-key counter memory is bounded — httprate hashes keys to uint64 and clear()s both window maps on rollover, so attacker-varied IP or path cannot grow state without bound across windows. Receiver, login, and password-change limiters each hold a separate localCounter; no shared or duplicated buckets.

Gate executed locally with the lint and builder stages cache-defeated (--no-cache-filter=lint,builder, no prune):

#19 [lint 8/8] RUN make lint
#19 69.94 0 issues.
#19 DONE 70.5s
...
#27 [builder 8/10] RUN make test
#27 89.95 --- PASS: TestReceiverRateLimit (0.03s)
#27 98.30 ok  sneak.berlin/go/webhooker/internal/config      1.158s
#27 98.30 ok  sneak.berlin/go/webhooker/internal/middleware  1.104s
DOCKER_BUILD_EXIT=0

No (cached) markers in go test output; every package reports a real duration. Single commit, title ends (closes #64), base next, gofmt clean, no scope creep, no attribution trailers or assistant references anywhere in the diff, commit message, or PR body.

Labels and assignment left to the caller.

## Independent review of PR [#87](https://git.eeqj.de/sneak/webhooker/pulls/87) (head 595d352) — post-rebase, reviewed as new code **Verdict: PASS.** Definition of done on [#64](https://git.eeqj.de/sneak/webhooker/issues/64) met; rebase resolution verified claim-by-claim; CI green on 595d352; fast-forward onto `next`. Anomalies and disclosures: 1. `internal/middleware/ratelimit_test.go` — `receiverPost` sends only `http.MethodPost`, so the "counts every method" property is untested. That property is the sole stated justification for `ReceiverRateLimit()` not reusing `postRateLimit`. Probe: replacing the body of `ReceiverRateLimit()` with POST-only gating (i.e. what `postRateLimit` does) leaves the entire suite green. The behaviour is correct today — verified at both the middleware and the chi `With().HandleFunc()` router level, where all of POST/GET/PUT hit the limiter — but nothing guards it against a future refactor that "simplifies" it into the shared helper, which is exactly the class of silent regression this branch's own auto-merge already demonstrated. Non-blocking; one added GET-over-limit assertion closes it. 2. `internal/config/config_test.go:184` — `expectStartupError` asserts only `assert.Error(t, app.Err())`, so the receiver cases would pass on an unrelated startup failure and do not verify the error names `RECEIVER_RATE_LIMIT` as the DoD wording requires. This is `next`'s existing helper, shared by all three env tables, so reusing it is idiom-consistent; flagged previously as advisory and unchanged. 3. Forwarded-header trust is unchanged from the pre-rebase revision and remains tracked in [#88](https://git.eeqj.de/sneak/webhooker/issues/88); not re-reported here. Rebase claims verified: `envInt` exists nowhere in the tree; exactly one definition each of `envPositiveInt`, `ErrNonPositiveValue`, `resolveEnvironment`, `envPort`, `envDuration`; no duplicated or dead helpers; `testReceiverRateLimitError` fully removed in favour of `expectStartupError`; `RECEIVER_RATE_LIMIT` parsed inside `loadFromEnv` via `envPositiveInt` with no silent-default path. Per-key counter memory is bounded — httprate hashes keys to `uint64` and `clear()`s both window maps on rollover, so attacker-varied IP or path cannot grow state without bound across windows. Receiver, login, and password-change limiters each hold a separate `localCounter`; no shared or duplicated buckets. Gate executed locally with the lint and builder stages cache-defeated (`--no-cache-filter=lint,builder`, no prune): ``` #19 [lint 8/8] RUN make lint #19 69.94 0 issues. #19 DONE 70.5s ... #27 [builder 8/10] RUN make test #27 89.95 --- PASS: TestReceiverRateLimit (0.03s) #27 98.30 ok sneak.berlin/go/webhooker/internal/config 1.158s #27 98.30 ok sneak.berlin/go/webhooker/internal/middleware 1.104s DOCKER_BUILD_EXIT=0 ``` No `(cached)` markers in `go test` output; every package reports a real duration. Single commit, title ends `(closes #64)`, base `next`, `gofmt` clean, no scope creep, no attribution trailers or assistant references anywhere in the diff, commit message, or PR body. Labels and assignment left to the caller.
clawbot force-pushed issue-64-receiver-rate-limit from 595d352d6e to d180b32f9b 2026-08-11 14:41:17 +02:00 Compare
clawbot force-pushed issue-64-receiver-rate-limit from d180b32f9b to 1828d99e0d 2026-08-11 14:45:46 +02:00 Compare
clawbot merged commit 84b758b785 into next 2026-08-11 14:47:22 +02:00
clawbot deleted branch issue-64-receiver-rate-limit 2026-08-11 14:47:22 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#87