Default config allows a remote stranger to lock the operator out of the admin UI, and nothing warns about it #149

Closed
opened 2026-08-12 13:32:06 +02:00 by clawbot · 1 comment
Collaborator

Found by the final integration review of #111. Blocks the 1.0.0 tag.

internal/middleware/ratelimit.go:151-172,219-226. LoginRateLimit allows 5 POST/min per bucket. Since #88 the bucket key is the peer address unless the peer is listed in TRUSTED_PROXIES, which defaults to empty.

README.md:79-80 requires a TLS-terminating reverse proxy in production, and REPO_POLICIES.md:328 mandates it. So in the deployment shape the project itself prescribes, every login POST from every client keys on the PROXY's address and shares a single 5/min bucket.

Consequence: a stranger sending roughly 0.08 requests per second — 5 per minute, from anywhere — keeps that bucket permanently full. The operator's own login returns 429 indefinitely, and there is no second administrative path. PasswordChangeRateLimit has the same shape.

Restarting the service clears the in-memory buckets, so it is recoverable, but a sustained trickle re-locks it immediately.

The #88 default is correct and should not change — trusting forwarded headers from arbitrary peers is worse. The defect is that this consequence is invisible:

  • README.md:1120-1122 asserts the opposite: "5 POST attempts per minute per IP". That is only true when TRUSTED_PROXIES is configured.
  • README.md:891-895 calls the shared bucket "the safe direction to be wrong in". True for the receiver, where sharing costs throughput. Inverted for login, where sharing costs availability of the only admin path.
  • README.md:101-146, the section an operator actually configures from, states no downside at all.

Definition of done

  • The service logs a loud warning at startup, in production mode, when TRUSTED_PROXIES is empty: name the variable, say that all clients will share one rate-limit bucket, and say that this makes the admin login remotely deniable. An operator must not have to read the whole README to discover this.
  • README.md:1120-1122 corrected — the per-IP claim is conditional on TRUSTED_PROXIES.
  • README.md:891-895 distinguishes the receiver case (sharing costs throughput, safe) from the login case (sharing costs availability of the only admin path, not safe).
  • The configuration section an operator reads first states the consequence and points at TRUSTED_PROXIES as the remedy.
  • A test asserting the warning fires when TRUSTED_PROXIES is empty in production mode, and does not fire when it is set.

Explicitly out of scope

Do NOT change the rate-limit keying, the limits, or the TRUSTED_PROXIES default. Whether login should additionally be limited per-username, or whether TRUSTED_PROXIES should be mandatory in production, is a design question for the owner and is filed separately as #150.

Implementation requirements

  • Branch from next, PR based on next, single commit, title ending (closes #N).
  • Gate on make check plus the Docker lint path with the cache defeated.
Found by the final integration review of https://git.eeqj.de/sneak/webhooker/pulls/111. Blocks the 1.0.0 tag. `internal/middleware/ratelimit.go:151-172,219-226`. `LoginRateLimit` allows 5 POST/min per bucket. Since https://git.eeqj.de/sneak/webhooker/issues/88 the bucket key is the peer address unless the peer is listed in `TRUSTED_PROXIES`, which defaults to empty. `README.md:79-80` requires a TLS-terminating reverse proxy in production, and `REPO_POLICIES.md:328` mandates it. So in the deployment shape the project itself prescribes, every login POST from every client keys on the PROXY's address and shares a single 5/min bucket. Consequence: a stranger sending roughly 0.08 requests per second — 5 per minute, from anywhere — keeps that bucket permanently full. The operator's own login returns 429 indefinitely, and there is no second administrative path. `PasswordChangeRateLimit` has the same shape. Restarting the service clears the in-memory buckets, so it is recoverable, but a sustained trickle re-locks it immediately. **The https://git.eeqj.de/sneak/webhooker/issues/88 default is correct and should not change** — trusting forwarded headers from arbitrary peers is worse. The defect is that this consequence is invisible: - `README.md:1120-1122` asserts the opposite: "5 POST attempts per minute per IP". That is only true when `TRUSTED_PROXIES` is configured. - `README.md:891-895` calls the shared bucket "the safe direction to be wrong in". True for the receiver, where sharing costs throughput. Inverted for login, where sharing costs availability of the only admin path. - `README.md:101-146`, the section an operator actually configures from, states no downside at all. ## Definition of done - The service logs a loud warning at startup, in production mode, when `TRUSTED_PROXIES` is empty: name the variable, say that all clients will share one rate-limit bucket, and say that this makes the admin login remotely deniable. An operator must not have to read the whole README to discover this. - `README.md:1120-1122` corrected — the per-IP claim is conditional on `TRUSTED_PROXIES`. - `README.md:891-895` distinguishes the receiver case (sharing costs throughput, safe) from the login case (sharing costs availability of the only admin path, not safe). - The configuration section an operator reads first states the consequence and points at `TRUSTED_PROXIES` as the remedy. - A test asserting the warning fires when `TRUSTED_PROXIES` is empty in production mode, and does not fire when it is set. ## Explicitly out of scope Do NOT change the rate-limit keying, the limits, or the `TRUSTED_PROXIES` default. Whether login should additionally be limited per-username, or whether `TRUSTED_PROXIES` should be mandatory in production, is a design question for the owner and is filed separately as https://git.eeqj.de/sneak/webhooker/issues/150. ## Implementation requirements - Branch from `next`, PR based on `next`, single commit, title ending ` (closes #N)`. - Gate on `make check` plus the Docker lint path with the cache defeated.
clawbot added this to the 1.0.0 milestone 2026-08-12 13:32:06 +02:00
clawbot self-assigned this 2026-08-12 13:32:06 +02:00
Author
Collaborator

PR: #153

Visibility only — keying, limits and the TRUSTED_PROXIES default are unchanged.

  • internal/config/config.go: warnSharedRateLimitBucket, called from New, logs at WARN when IsProd() and TRUSTED_PROXIES is empty: "TRUSTED_PROXIES is empty: rate limits key on the connecting peer, so behind the reverse proxy a production deployment runs behind, every client shares one bucket per limit. Any remote client can then keep the login limit full and deny the admin login, the only administrative path, until restart. Set TRUSTED_PROXIES to your reverse proxy's address."
  • Security-features bullet: the per-IP login claim is now conditional on TRUSTED_PROXIES.
  • Rate Limiting section: receiver case (sharing costs throughput, safe direction) and login/password-change case (sharing costs availability of the only admin path) split explicitly; the aggregate-ceiling text is preserved.
  • Trusted-proxies config section and the env-var table state the consequence and name the remedy.

Verified: make check exit 0; docker build --no-cache-filter=lint,builder exit 0 with in-container lint at 0 issues. and uncached internal/config tests. Mutation-checked both directions — removing the warning fails the prod-empty case, removing the condition fails both quiet cases.

PR: https://git.eeqj.de/sneak/webhooker/pulls/153 Visibility only — keying, limits and the `TRUSTED_PROXIES` default are unchanged. - `internal/config/config.go`: `warnSharedRateLimitBucket`, called from `New`, logs at `WARN` when `IsProd()` and `TRUSTED_PROXIES` is empty: "TRUSTED_PROXIES is empty: rate limits key on the connecting peer, so behind the reverse proxy a production deployment runs behind, every client shares one bucket per limit. Any remote client can then keep the login limit full and deny the admin login, the only administrative path, until restart. Set TRUSTED_PROXIES to your reverse proxy's address." - Security-features bullet: the per-IP login claim is now conditional on `TRUSTED_PROXIES`. - Rate Limiting section: receiver case (sharing costs throughput, safe direction) and login/password-change case (sharing costs availability of the only admin path) split explicitly; the aggregate-ceiling text is preserved. - Trusted-proxies config section and the env-var table state the consequence and name the remedy. Verified: `make check` exit 0; `docker build --no-cache-filter=lint,builder` exit 0 with in-container lint at `0 issues.` and uncached `internal/config` tests. Mutation-checked both directions — removing the warning fails the prod-empty case, removing the condition fails both quiet cases.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#149