auth: sign-in limits per address and name; successes free (audit A1, A8, A9)

Security audit 2026-10-17, findings A1 (medium), A8, A9 (low), stage 3.
Under the shipped Apache-on-host deployment with TRUSTED_PROXY_CIDR empty,
every visitor has the Docker gateway's address, and every public limit was
keyed on the address alone: ten POSTs to /login per 15 minutes (any body,
successes counted) locked out every administrator; likewise /login/reset,
the link pages and /setup/. IPv6 was keyed per /128.

Now (internal/web/auth/limitkeys.go):
- sign-in counts per address and username (10 / 15 min) and per address
  alone at 100; a right password refunds both (rateLimiter.Refund);
- a reset request counts per address and username (5) and per address
  alone (50); a link's page per link, by token hash (30), and per address
  alone (300); setup per address as before;
- an IPv6 address is keyed by its /64 (limitAddr);
- proxyWatch: when the first 20 requests after a start all come from one
  private or loopback peer with X-Forwarded-For and no proxy is trusted,
  the log says so once with the TRUSTED_PROXY_CIDR value to set;
- a refused sign-in or a lockout is logged, five lines per address in 15
  minutes, the typed name printable and cut (refusalLog, logName).
The shipped compose cannot know the gateway, so the guide's install step
prints it (docker network inspect) and says to set it.

Tests: TestLoginBehindOneUntrustedProxyKeepsOthersIn (the reader's case),
TestLoginSuccessesDoNotCount, TestIPv6IsLimitedPerSlash64,
TestProxyWatchAndRefusalsAreLogged; the existing lockout tests pass.

Docs: security.md § First-run setup, § Authentication; guide § Full
deployment; CHANGELOG; plan. Also the I1 plan line's Done note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-17 22:10:47 +03:00
co-authored by Claude Opus 5.5
parent 1bafd78364
commit 3a8e19b71c
9 changed files with 388 additions and 38 deletions
+18 -4
View File
@@ -89,7 +89,8 @@ The panel is exposed to the internet — the items below are **not optional**.
- Token ≥128 bits (`crypto/rand`); mirrored to `/data/setup-token`.
- Token lifetime — **10 minutes**; after expiry, or after a restart with setup
unfinished, it is regenerated and logged again.
- Rate limiting on `/setup/<token>` per IP, separate from login.
- Rate limiting on `/setup/<token>` per client address (an IPv6 one per
/64), separate from login.
- Token comparison is **constant-time** (`subtle.ConstantTimeCompare`).
- Failed attempts do **not** invalidate the token early (protects setup from
being DoS-ed).
@@ -123,7 +124,18 @@ The panel is exposed to the internet — the items below are **not optional**.
### Authentication and sessions
- Rate limiting on login (per IP, with lockout or delay). The Account form's
- Rate limiting on login: ten failures in 15 minutes per client address and
username, and a hundred per address alone; a right password gives its
attempt back, so only failures count. An IPv6 client is one address per
/64. Behind a reverse proxy the panel does not trust (`TRUSTED_PROXY_CIDR`
empty) every visitor has the proxy's address: a stranger there can then
lock one name, or spray a hundred, but not lock everyone out at the
per-name size (until `2.14.4` the one key was the address, successes
counted, and ten requests of any body locked every administrator out -
security audit 2026-10-17, A1, A9). When the first requests after a start
all come from one private peer with `X-Forwarded-For` and no proxy is
trusted, the log says so with the value to set. A refused sign-in and a
lockout are logged, five lines per address in 15 minutes (A8). The Account form's
current-password check has a limit of its own, ten in 15 minutes per user,
so guesses there do not lock the address out of signing in (`2.5.4`).
- An unknown username is refused in the time a wrong password is: the sign-in
@@ -163,8 +175,10 @@ The panel is exposed to the internet — the items below are **not optional**.
token. The request form answers the same for every name and sends the mail
after answering, so neither the words nor the time tell whether a name
exists or is confirmed; requests are limited to five in 15 minutes per
client address, mails to five an hour per account, and a link's page to 30
requests in 15 minutes per client address. A link's page changes nothing
client address and username (fifty per address alone), mails to five an
hour per account, and a link's page to 30 requests in 15 minutes per link
(300 per address alone), so one stranger behind a shared proxy cannot stop
the others asking for or opening a link (`2.14.4`; audit A1). A link's page changes nothing
on `GET` (a mail scanner opens links): the button does. Setting a password
from a link ends every session of the account and every other reset link.
Changing the account e-mail asks for the current password, as the password