Verified against main at 61f42e6. Covers the "per-IP rate limiting" P2 item in TODO.md, scoped to the place it matters most first.
handleLoginPost (internal/handlers/auth.go:37-67) compares the submitted key against config.SigningKey in constant time, logs a warning on mismatch, and re-renders the form. There is no rate limit, no lockout, no backoff, and no per-IP accounting anywhere in the codebase.
The credential being guessed is not a scoped password — it is the signing key, the master secret. It also derives the encrypted-URL key and the session key (internal/encurl, internal/seal, internal/session). Compromise means the attacker can mint arbitrary signed and encrypted URLs, i.e. use pixa as an open proxy against any host, permanently.
Config validation enforces a 32-character minimum (internal/config/config.go), so brute force is not the realistic threat against a well-chosen key. The realistic threats are a weak-but-long operator-chosen key, and the fact that an unthrottled endpoint gives an attacker unlimited free attempts with zero cost or visibility beyond log lines nobody is alerting on.
Definition of done
Per-IP rate limiting on POST / with a sane default (a few attempts per minute), returning 429 with Retry-After once tripped.
The limiter is shared infrastructure, not login-specific, so the remaining TODO.md rate-limiting items (per-IP on image routes, per-origin) can reuse it. Prefer a well-maintained library over hand-rolling; check ~/.claude/GO_PACKAGE_DEFAULTS.md first and record the decision there.
Client IP is derived correctly behind a reverse proxy — pixa is explicitly meant to sit behind nginx/caddy, so a naive RemoteAddr limiter would key every request to the proxy's IP and lock out all users at once. Trusted-proxy handling must be configurable, and must not trust X-Forwarded-For blindly from untrusted sources.
Limiter state is bounded — no unbounded per-IP map (see also the unbounded hostSems map issue).
Failing tests first: N+1 failed logins from one IP returns 429; a different IP is unaffected; a successful login is not blocked by another IP's failures.
make check green.
Follow-on (not in this issue)
Once the limiter exists, per-IP limiting on the image routes and per-origin limiting are separate commit-sized units.
Verified against `main` at `61f42e6`. Covers the "per-IP rate limiting" P2 item in `TODO.md`, scoped to the place it matters most first.
`handleLoginPost` (`internal/handlers/auth.go:37-67`) compares the submitted key against `config.SigningKey` in constant time, logs a warning on mismatch, and re-renders the form. There is no rate limit, no lockout, no backoff, and no per-IP accounting anywhere in the codebase.
The credential being guessed is not a scoped password — it is the **signing key**, the master secret. It also derives the encrypted-URL key and the session key (`internal/encurl`, `internal/seal`, `internal/session`). Compromise means the attacker can mint arbitrary signed and encrypted URLs, i.e. use pixa as an open proxy against any host, permanently.
Config validation enforces a 32-character minimum (`internal/config/config.go`), so brute force is not the realistic threat against a well-chosen key. The realistic threats are a weak-but-long operator-chosen key, and the fact that an unthrottled endpoint gives an attacker unlimited free attempts with zero cost or visibility beyond log lines nobody is alerting on.
## Definition of done
1. Per-IP rate limiting on `POST /` with a sane default (a few attempts per minute), returning 429 with `Retry-After` once tripped.
2. The limiter is shared infrastructure, not login-specific, so the remaining `TODO.md` rate-limiting items (per-IP on image routes, per-origin) can reuse it. Prefer a well-maintained library over hand-rolling; check `~/.claude/GO_PACKAGE_DEFAULTS.md` first and record the decision there.
3. Client IP is derived correctly behind a reverse proxy — pixa is explicitly meant to sit behind nginx/caddy, so a naive `RemoteAddr` limiter would key every request to the proxy's IP and lock out all users at once. Trusted-proxy handling must be configurable, and must not trust `X-Forwarded-For` blindly from untrusted sources.
4. Limiter state is bounded — no unbounded per-IP map (see also the unbounded `hostSems` map issue).
5. Failing tests first: N+1 failed logins from one IP returns 429; a different IP is unaffected; a successful login is not blocked by another IP's failures.
6. `make check` green.
## Follow-on (not in this issue)
Once the limiter exists, per-IP limiting on the image routes and per-origin limiting are separate commit-sized units.
clawbot
added this to the 1.0.0 milestone 2026-08-09 03:44:20 +02:00
Library: github.com/go-chi/httprate, the default already named for HTTP rate limiting in the Go package defaults, pinned in go.sum. No config key: a fixed limit of 5 login attempts per minute per client address.
A RateLimit(requests, window) middleware in internal/middleware, next to ClientIP. It counts by the client address that ClientIP resolved and stored in the request context (X-Forwarded-For believed only from a peer in trusted_proxies, from #94), never by RemoteAddr or a raw header. An IPv6 client is counted by its /64, as the library recommends, since one client usually holds a whole /64. Over the limit the answer is 429 with Retry-After. The library keeps counts only for the current and the previous minute, so its memory is bounded. The image routes can use the same middleware later; nothing is added for them now.
internal/server/routes.go: only POST / gets the limit, placed after the body-size and CSRF checks, so every request that reaches the key comparison is counted. GET / and POST /generate are unchanged.
Tests first, through the server's real routes: the attempt after the limit from one address gets 429 with Retry-After; a different address is unaffected and still logs in with the right key; two clients behind a trusted proxy are counted separately; X-Forwarded-For sent by an untrusted peer does not get around the limit; two IPv6 addresses in one /64 share a count.
README.md states the limit. TODO.md narrows its per-IP rate limiting item to the image routes.
Model: opus-5-5
Plan.
- Library: `github.com/go-chi/httprate`, the default already named for HTTP rate limiting in the Go package defaults, pinned in `go.sum`. No config key: a fixed limit of 5 login attempts per minute per client address.
- A `RateLimit(requests, window)` middleware in `internal/middleware`, next to `ClientIP`. It counts by the client address that `ClientIP` resolved and stored in the request context (`X-Forwarded-For` believed only from a peer in `trusted_proxies`, from https://git.eeqj.de/sneak/pixa/issues/94), never by `RemoteAddr` or a raw header. An IPv6 client is counted by its /64, as the library recommends, since one client usually holds a whole /64. Over the limit the answer is 429 with `Retry-After`. The library keeps counts only for the current and the previous minute, so its memory is bounded. The image routes can use the same middleware later; nothing is added for them now.
- `internal/server/routes.go`: only `POST /` gets the limit, placed after the body-size and CSRF checks, so every request that reaches the key comparison is counted. `GET /` and `POST /generate` are unchanged.
- Tests first, through the server's real routes: the attempt after the limit from one address gets 429 with `Retry-After`; a different address is unaffected and still logs in with the right key; two clients behind a trusted proxy are counted separately; `X-Forwarded-For` sent by an untrusted peer does not get around the limit; two IPv6 addresses in one /64 share a count.
- `README.md` states the limit. `TODO.md` narrows its per-IP rate limiting item to the image routes.
Model: opus-5-5
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.
Verified against
mainat61f42e6. Covers the "per-IP rate limiting" P2 item inTODO.md, scoped to the place it matters most first.handleLoginPost(internal/handlers/auth.go:37-67) compares the submitted key againstconfig.SigningKeyin constant time, logs a warning on mismatch, and re-renders the form. There is no rate limit, no lockout, no backoff, and no per-IP accounting anywhere in the codebase.The credential being guessed is not a scoped password — it is the signing key, the master secret. It also derives the encrypted-URL key and the session key (
internal/encurl,internal/seal,internal/session). Compromise means the attacker can mint arbitrary signed and encrypted URLs, i.e. use pixa as an open proxy against any host, permanently.Config validation enforces a 32-character minimum (
internal/config/config.go), so brute force is not the realistic threat against a well-chosen key. The realistic threats are a weak-but-long operator-chosen key, and the fact that an unthrottled endpoint gives an attacker unlimited free attempts with zero cost or visibility beyond log lines nobody is alerting on.Definition of done
POST /with a sane default (a few attempts per minute), returning 429 withRetry-Afteronce tripped.TODO.mdrate-limiting items (per-IP on image routes, per-origin) can reuse it. Prefer a well-maintained library over hand-rolling; check~/.claude/GO_PACKAGE_DEFAULTS.mdfirst and record the decision there.RemoteAddrlimiter would key every request to the proxy's IP and lock out all users at once. Trusted-proxy handling must be configurable, and must not trustX-Forwarded-Forblindly from untrusted sources.hostSemsmap issue).make checkgreen.Follow-on (not in this issue)
Once the limiter exists, per-IP limiting on the image routes and per-origin limiting are separate commit-sized units.
Plan.
github.com/go-chi/httprate, the default already named for HTTP rate limiting in the Go package defaults, pinned ingo.sum. No config key: a fixed limit of 5 login attempts per minute per client address.RateLimit(requests, window)middleware ininternal/middleware, next toClientIP. It counts by the client address thatClientIPresolved and stored in the request context (X-Forwarded-Forbelieved only from a peer intrusted_proxies, from #94), never byRemoteAddror a raw header. An IPv6 client is counted by its /64, as the library recommends, since one client usually holds a whole /64. Over the limit the answer is 429 withRetry-After. The library keeps counts only for the current and the previous minute, so its memory is bounded. The image routes can use the same middleware later; nothing is added for them now.internal/server/routes.go: onlyPOST /gets the limit, placed after the body-size and CSRF checks, so every request that reaches the key comparison is counted.GET /andPOST /generateare unchanged.Retry-After; a different address is unaffected and still logs in with the right key; two clients behind a trusted proxy are counted separately;X-Forwarded-Forsent by an untrusted peer does not get around the limit; two IPv6 addresses in one /64 share a count.README.mdstates the limit.TODO.mdnarrows its per-IP rate limiting item to the image routes.Model: opus-5-5
Built as planned above, in #143.
Model: opus-5-5