Tracking issue from the PR #87 review (advisory, non-blocking there because it matches the agreed plan and the existing LoginRateLimit pattern).
Problem
Both ReceiverRateLimit and LoginRateLimit (internal/middleware/ratelimit.go) key on httprate.KeyByRealIP, which trusts True-Client-IP, X-Real-IP, and the first X-Forwarded-For entry unconditionally. The first XFF entry is client-controlled even behind an appending reverse proxy, so a deliberate attacker can:
bypass the limit entirely by rotating spoofed XFF values (fresh bucket per request), or
starve a legitimate sender's bucket by spoofing that sender's IP.
REPO_POLICIES.md requires trusted-proxy-gated forwarded-header handling before 1.0.
Definition of done
Forwarded headers are honored only when the direct peer (RemoteAddr) is within a configured trusted-proxy set (env-configured CIDR list; set-but-unparseable value aborts startup, per repo policy). Otherwise the key is RemoteAddr.
Applies to BOTH limiters (receiver and login) via one shared key function.
Tests: spoofed XFF from an untrusted peer does not create a fresh bucket; honored from a trusted peer; unparseable CIDR config aborts startup.
Minor cleanups to fold in (same files, from the same review)
internal/config/config_test.go: the RECEIVER_RATE_LIMIT error-path tests assert only assert.Error; tighten to ErrorContains (variable name in the message) / errors.Is(config.ErrNonPositiveValue).
internal/middleware/ratelimit.go: the two 429 limit-handler bodies are near-duplicates; extract a shared helper.
Tracking issue from the PR #87 review (advisory, non-blocking there because it matches the agreed plan and the existing `LoginRateLimit` pattern).
## Problem
Both `ReceiverRateLimit` and `LoginRateLimit` (`internal/middleware/ratelimit.go`) key on `httprate.KeyByRealIP`, which trusts `True-Client-IP`, `X-Real-IP`, and the first `X-Forwarded-For` entry unconditionally. The first XFF entry is client-controlled even behind an appending reverse proxy, so a deliberate attacker can:
- bypass the limit entirely by rotating spoofed XFF values (fresh bucket per request), or
- starve a legitimate sender's bucket by spoofing that sender's IP.
`REPO_POLICIES.md` requires trusted-proxy-gated forwarded-header handling before 1.0.
## Definition of done
- Forwarded headers are honored only when the direct peer (`RemoteAddr`) is within a configured trusted-proxy set (env-configured CIDR list; set-but-unparseable value aborts startup, per repo policy). Otherwise the key is `RemoteAddr`.
- Applies to BOTH limiters (receiver and login) via one shared key function.
- Tests: spoofed XFF from an untrusted peer does not create a fresh bucket; honored from a trusted peer; unparseable CIDR config aborts startup.
## Minor cleanups to fold in (same files, from the same review)
- `internal/config/config_test.go`: the `RECEIVER_RATE_LIMIT` error-path tests assert only `assert.Error`; tighten to `ErrorContains` (variable name in the message) / `errors.Is(config.ErrNonPositiveValue)`.
- `internal/middleware/ratelimit.go`: the two 429 limit-handler bodies are near-duplicates; extract a shared helper.
Scope note: the PR #83 review found the same unconditional forwarded-header trust in the new PasswordChangeRateLimit (added in commit 8362ce9, shares the postRateLimit helper with LoginRateLimit). This issue covers ALL rate limiters — receiver, login, and password change — via the one shared key function described in the DoD.
Scope note: the PR #83 review found the same unconditional forwarded-header trust in the new `PasswordChangeRateLimit` (added in commit 8362ce9, shares the `postRateLimit` helper with `LoginRateLimit`). This issue covers ALL rate limiters — receiver, login, and password change — via the one shared key function described in the DoD.
All three limiters (receiver, login, password change) now share one key function: the connection's own address, unless the direct peer is inside a network listed in the new TRUSTED_PROXIES CIDR list, in which case the forwarded client address is used. The list is empty by default, so nothing is trusted until an operator names their proxy; a set-but-unparseable value aborts startup (config.ErrInvalidCIDR), matching the other parsed variables. Within a trusted request X-Forwarded-For is walked right to left and the first hop that is not itself a trusted proxy wins, so client-prepended entries cannot be selected. Documented in the README.
Both folded-in cleanups are in the same commit: the 429 responder is extracted into one helper, and the RECEIVER_RATE_LIMIT error-path tests now assert the failure names the variable and, for zero/negative, wraps ErrNonPositiveValue.
Verified: the bypass test bites — reverting the key function to plain httprate.KeyByRealIP makes TestRateLimitKey_SpoofedForwardedFromUntrustedPeer fail (expected 429, actual 200) for all three header names, along with the chain-walk and receiver variants; restoring it makes them pass. make check exit 0, and docker build --no-cache-filter=lint,builder ran make lint (0 issues) and make test in-container with no cached package results.
Implemented in https://git.eeqj.de/sneak/webhooker/pulls/122.
All three limiters (receiver, login, password change) now share one key function: the connection's own address, unless the direct peer is inside a network listed in the new `TRUSTED_PROXIES` CIDR list, in which case the forwarded client address is used. The list is empty by default, so nothing is trusted until an operator names their proxy; a set-but-unparseable value aborts startup (`config.ErrInvalidCIDR`), matching the other parsed variables. Within a trusted request `X-Forwarded-For` is walked right to left and the first hop that is not itself a trusted proxy wins, so client-prepended entries cannot be selected. Documented in the README.
Both folded-in cleanups are in the same commit: the 429 responder is extracted into one helper, and the `RECEIVER_RATE_LIMIT` error-path tests now assert the failure names the variable and, for zero/negative, wraps `ErrNonPositiveValue`.
Verified: the bypass test bites — reverting the key function to plain `httprate.KeyByRealIP` makes `TestRateLimitKey_SpoofedForwardedFromUntrustedPeer` fail (expected 429, actual 200) for all three header names, along with the chain-walk and receiver variants; restoring it makes them pass. `make check` exit 0, and `docker build --no-cache-filter=lint,builder` ran `make lint` (0 issues) and `make test` in-container with no cached package results.
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.
Tracking issue from the PR #87 review (advisory, non-blocking there because it matches the agreed plan and the existing
LoginRateLimitpattern).Problem
Both
ReceiverRateLimitandLoginRateLimit(internal/middleware/ratelimit.go) key onhttprate.KeyByRealIP, which trustsTrue-Client-IP,X-Real-IP, and the firstX-Forwarded-Forentry unconditionally. The first XFF entry is client-controlled even behind an appending reverse proxy, so a deliberate attacker can:REPO_POLICIES.mdrequires trusted-proxy-gated forwarded-header handling before 1.0.Definition of done
RemoteAddr) is within a configured trusted-proxy set (env-configured CIDR list; set-but-unparseable value aborts startup, per repo policy). Otherwise the key isRemoteAddr.Minor cleanups to fold in (same files, from the same review)
internal/config/config_test.go: theRECEIVER_RATE_LIMITerror-path tests assert onlyassert.Error; tighten toErrorContains(variable name in the message) /errors.Is(config.ErrNonPositiveValue).internal/middleware/ratelimit.go: the two 429 limit-handler bodies are near-duplicates; extract a shared helper.Scope note: the PR #83 review found the same unconditional forwarded-header trust in the new
PasswordChangeRateLimit(added in commit8362ce9, shares thepostRateLimithelper withLoginRateLimit). This issue covers ALL rate limiters — receiver, login, and password change — via the one shared key function described in the DoD.clawbot referenced this issue2026-08-11 14:48:08 +02:00
Implemented in #122.
All three limiters (receiver, login, password change) now share one key function: the connection's own address, unless the direct peer is inside a network listed in the new
TRUSTED_PROXIESCIDR list, in which case the forwarded client address is used. The list is empty by default, so nothing is trusted until an operator names their proxy; a set-but-unparseable value aborts startup (config.ErrInvalidCIDR), matching the other parsed variables. Within a trusted requestX-Forwarded-Foris walked right to left and the first hop that is not itself a trusted proxy wins, so client-prepended entries cannot be selected. Documented in the README.Both folded-in cleanups are in the same commit: the 429 responder is extracted into one helper, and the
RECEIVER_RATE_LIMITerror-path tests now assert the failure names the variable and, for zero/negative, wrapsErrNonPositiveValue.Verified: the bypass test bites — reverting the key function to plain
httprate.KeyByRealIPmakesTestRateLimitKey_SpoofedForwardedFromUntrustedPeerfail (expected 429, actual 200) for all three header names, along with the chain-walk and receiver variants; restoring it makes them pass.make checkexit 0, anddocker build --no-cache-filter=lint,builderranmake lint(0 issues) andmake testin-container with no cached package results.