6fce522016196331ef8817d92bfa358b953ee579
5 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 6fce522016 |
Verify login credentials before spending rate-limit budget (closes #150)
All checks were successful
check / check (push) Successful in 2m45s
With TRUSTED_PROXIES empty behind the reverse proxy production is required to run behind, every login POST keyed on the proxy's address and shared one 5/minute bucket. A stranger sending five POSTs a minute -- 0.08 requests per second, from anywhere -- kept that bucket permanently full, and the operator's own correct password was answered 429 indefinitely with no second administrative path. The login POST no longer has a pre-emptive limiter. The handler verifies credentials first and spends budget only on a FAILED attempt, so a correct password is never throttled whatever the counters hold. Three things follow, and are implemented together because the first is unsafe without the other two: - Failures are counted per (client bucket, submitted username), five per minute, after which further failures get 429 with a Retry-After. A successful login clears the counter, so mistyping and then succeeding does not leave the operator throttled. - Both key sets are capped at 1024 entries. The submitted username is attacker-controlled, so past the first cap failures fall back to a counter keyed on the client alone, and past both caps a failure is answered as throttled without being recorded. Tracked state stays under half a megabyte and does not grow with invented usernames. - Concurrent Argon2id verifications are capped at two, a 128 MB ceiling at 64 MB per hash, and the queue for those slots is capped at 16 waiters. Every password-hashing endpoint takes a slot, including the password-change endpoint, which holds one across both its hashes. A request that waits five seconds without a slot is answered 503, and one that arrives with the queue already full is shed with 503 immediately rather than joining it. Bounding the wait alone would not bound memory, and the queue depth is sized from what a parked waiter measurably retains rather than from the 1 MB body cap, which bounds only the raw body read. The body-cap, CSRF and form-parsing middleware all run before the guard, so a waiter holds its parsed form plus its request header block for the whole wait. Measured on the pinned go1.26.1 toolchain as the HeapAlloc delta across two GCs with 64 waiters parked in the handler: an ordinary two-field login form retains ~0 MB, a 1 MB urlencoded body at Go's 10,000-parameter parse cap retains 2.82 MB (3.09 MB with %41 escapes), and the ~0.9 MB of headers the 1 MB header cap allows takes it to 4.18 MB. The retained parse and the header block dominate, not the raw body. So 16 waiters: 16 x 4.18 MB is about 67 MB of committed queue memory, and two slots drain a full 16-deep queue in about 0.6 s, far inside the deadline. Peak commitment for the endpoint is about 203 MB — 128 MB of Argon2id plus the 18 requests holding a parsed form, 16 queued and the 2 being hashed, at about 75 MB. That 203 MB is live commitment, not resident size: the Go collector lets the heap reach roughly twice the live set before collecting, and the review measured a peak HeapAlloc of 392 MB under 18 adversarial requests, so the README says to provision on the order of 400 MB. An unknown username is verified against a dummy hash instead of returning early, so a nonexistent account costs the same time as a real one and the response cannot be used to enumerate usernames. The password-change limiter is unchanged: RequireAuth runs ahead of it, so only a request already carrying a valid session reaches its bucket. Two consequences are documented rather than fixed, because they follow from the shape the issue asks for. Online guessing throughput rises from 5 a minute to roughly 27 a second, about 2.3 million a day: the credential check always precedes the counter, so the 429 is a label on the response rather than a gate in front of the hash, and what bounds brute force is the semaphore. And under a sustained flood the residual exposure is a loss of login availability, not merely of latency -- above about 27 requests a second most attempts are shed with 503, so a determined flood still denies login for as long as it runs. It costs roughly 400x more to run, nothing accumulates, and the first attempt after it stops succeeds. Restarting the service does not help: the counters a restart clears are not what is saturated. Also adds the missing test for the third bucketKey call site, where the peer is a trusted proxy but the forwarded chain names no client. Every existing test of that fallback uses an IPv4 proxy, where bucketKey is the identity function, so dropping the /64 masking there left the suite green. README and the TRUSTED_PROXIES startup warning updated: a shared bucket now costs precision, not the availability of the admin path. |
|||
| 95161c7768 |
Bound the receiver rate limit per client IP across /webhook/* (closes #139)
Some checks failed
check / check (push) Superseded by a newer commit; never tested
The receiver limiter keyed on the request path, and /webhook/{uuid} matches any single segment, so a client minted a fresh bucket per invented path and had unlimited aggregate rate against the only unauthenticated endpoint. An outer limiter keyed on the client address alone now bounds that, chained in front of the unchanged per-entrypoint limiter. Its rejections log at DEBUG without the path, and the README states what each limit does and does not bound.
|
|||
| 9bfd033a29 |
Bound X-Forwarded-For scanning allocation to the hop cap (closes #133)
Some checks failed
check / check (push) Superseded by a newer commit; never tested
forwardedClientAddr now walks the header values in reverse with strings.LastIndexByte instead of joining and splitting, so allocation is bounded by the 64-hop cap rather than by header length: 1.6 MB per call becomes 16 bytes for a 1 MB chain. Semantics are unchanged, verified by differential testing against the previous implementation. |
|||
| 4f5ecb18e5 |
Add admin password change flow (closes #65) (#83)
All checks were successful
check / check (push) Superseded by a newer commit; never tested
Adds an authenticated, CSRF-protected flow that lets a user change their own password from the profile page.
## Route
- New `POST /password` under the `/user/{username}` group in `setupUserRoutes` (`internal/server/routes.go`). That group already applies `CSRF`, `NoCache`, and `RequireAuth`, so the new endpoint inherits all three.
## Handler (`internal/handlers/profile.go`)
- `HandlePasswordChange` enforces own-user access: the `{username}` path parameter must equal the session username (same 403 rule `HandleProfile` uses). This check plus the session lookup is factored into a shared `profileOwnerOrDeny` helper now used by both handlers.
- Parses `current_password`, `new_password`, and `confirm_password` (body size limited via `http.MaxBytesReader`).
- Verifies the current password with `database.VerifyPassword` against the stored hash.
- Requires the new password to be non-empty and equal to the confirmation.
- Hashes the new password with `database.HashPassword` — the same Argon2id helper used to bootstrap the admin user — and persists it on the user row. No new crypto.
- Re-renders the profile page with a clear success or error message. Wrong current password, empty new password, and mismatched confirmation are each rejected with their own message and leave the stored hash unchanged.
## Template (`templates/profile.html`)
- Adds a "Change Password" card with current / new / confirm password fields plus the hidden `csrf_token` (matching the login form's CSRF embedding).
- Renders success/error alerts using the existing `alert-success` / `alert-error` styles. No new CSS classes, so no Tailwind rebuild is required.
## Tests (`internal/handlers/profile_test.go`)
- `TestHandlePasswordChange_Success`: seeds a user, posts a valid change, asserts success message and that the stored hash changed and verifies against the new password.
- `TestHandlePasswordChange_WrongCurrentPassword`: posts a wrong current password, asserts the rejection message and that the stored hash is unchanged.
Validated with `docker build .` (fmt-check, lint, test, build) — exit 0.
Closes #65
Co-authored-by: sneak <sneak@sneak.berlin>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #83
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
|
|||
| afe88c601a |
refactor: use pinned golangci-lint Docker image for linting (#55)
All checks were successful
check / check (push) Successful in 5s
Closes [issue #50](#50) ## Summary Refactors the Dockerfile to use a separate lint stage with a pinned golangci-lint Docker image, following the pattern used by [sneak/pixa](https://git.eeqj.de/sneak/pixa). This replaces the previous approach of installing golangci-lint via curl in the builder stage. ## Changes ### Dockerfile - **New `lint` stage** using `golangci/golangci-lint:v2.11.3` (Debian-based, pinned by sha256 digest) as a separate build stage - **Builder stage** depends on lint via `COPY --from=lint /src/go.sum /dev/null` — build won't proceed unless linting passes - **Go bumped** from 1.24 to 1.26.1 (`golang:1.26.1-bookworm`, pinned by sha256) - **golangci-lint bumped** from v1.64.8 to v2.11.3 - All three Docker images (golangci-lint, golang, alpine) pinned by sha256 digest - Debian-based golangci-lint image used (not Alpine) because mattn/go-sqlite3 CGO does not compile on musl (off64_t) ### Linter Config (.golangci.yml) - Migrated from v1 to v2 format (`version: "2"` added) - Removed linters no longer available in v2: `gofmt` (handled by `make fmt-check`), `gosimple` (merged into `staticcheck`), `typecheck` (always-on in v2) - Same set of linters enabled — no rules weakened ### Code Fixes (all lint issues from v2 upgrade) - Added package comments to all packages - Added doc comments to all exported types, functions, and methods - Fixed unchecked errors flagged by `errcheck` (sqlDB.Close, os.Setenv in tests, resp.Body.Close, fmt.Fprint) - Fixed unused parameters flagged by `revive` (renamed to `_`) - Fixed `gosec` G120 warnings: added `http.MaxBytesReader` before `r.ParseForm()` calls - Fixed `staticcheck` QF1012: replaced `WriteString(fmt.Sprintf(...))` with `fmt.Fprintf` - Fixed `staticcheck` QF1003: converted if/else chain to tagged switch - Renamed `DeliveryTask` → `Task` to avoid package stutter (`delivery.Task` instead of `delivery.DeliveryTask`) - Renamed shadowed builtin `max` parameter to `upperBound` in `cryptoRandInt` - Used `t.Setenv` instead of `os.Setenv` in tests (auto-restores) ### README.md - Updated version requirements: Go 1.26+, golangci-lint v2.11+ - Updated Dockerfile description in project structure ## Verification `docker build .` passes cleanly — formatting check, linting, all tests, and build all succeed. Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de> Reviewed-on: #55 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org> |