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
+13
View File
@@ -183,6 +183,19 @@ docker compose up -d
The nginx/Caddy/Traefik fragments already include `docker compose up -d` —
skip this if you ran one of those.
With Apache on the host, the panel sees every visitor at the Docker
network's gateway. Tell it that address is your proxy, so that each visitor
has a sign-in limit of their own, and recreate the container:
```sh
docker network inspect "$(basename "$PWD")_default" -f '{{(index .IPAM.Config 0).Gateway}}'
```
Put what it prints into `.env` as `TRUSTED_PROXY_CIDR=` and run `docker
compose up -d` again. Until then the panel's log says, after the first
requests, that every visitor shares one limit and what to set; Server ›
Health shows the same under *Your address*.
**Get the setup URL** — open it in a browser to create the admin account (see
[Initial setup](#initial-setup)):
+16 -6
View File
@@ -762,21 +762,31 @@ the argument.
cannot read `/etc/postfix/tls/privkey.pem`; security.md § Output and
process lists the three groups and who is in each. Rollout: `chgrp 10997
/root/selfpost-deploy/certs/privkey.pem` and the same in
`cert-deploy-hook.sh` on the test server. — **Opus**, 2.5 h.
`cert-deploy-hook.sh` on the test server. — **Opus**, 2.5 h. *Done
2026-10-17:* the e2e stage gives its key to 10997 at 0640 when the runner
is root and `testFullBackupRestore` checks both uids; the test server's
key at the rollout.
### Stage 3 — limits and defaults (~7 h)
- [ ] **A1.** (a) Compose and guide set `TRUSTED_PROXY_CIDR` for the
- [x] **A1.** (a) Compose and guide set `TRUSTED_PROXY_CIDR` for the
Apache-on-host scenario (the Docker gateway, with the command that prints
it) and the panel logs a warning at start when the first N requests all
come from one private peer carrying XFF and the list is empty; (b) the
login limiter also keyed by username with its own window, the link-page
limiters by token hash; (c) successful sign-ins not counted. security.md
records (a)-(c). Done when the reader's test passes with two clients
behind one untrusted peer. — **Opus**, 4 h.
- [ ] **A8.** One log line per refused sign-in or lockout, rate-limited per
key. — **Sonnet**, 0.5 h.
- [ ] **A9.** IPv6 keyed by /64. — **Sonnet**, 0.5 h.
behind one untrusted peer. — **Opus**, 4 h. *Done 2026-10-17:* (a) as a
runtime warning (`proxyWatch`) and the guide's install step with the
command - the shipped compose cannot know the gateway, so it is not set
there; (b) per address and username, per link, the address alone at ten
times that; (c) a right password gives its attempt back.
TestLoginBehindOneUntrustedProxyKeepsOthersIn, TestLoginSuccessesDoNotCount,
TestProxyWatchAndRefusalsAreLogged.
- [x] **A8.** One log line per refused sign-in or lockout, rate-limited per
key. — **Sonnet**, 0.5 h. *Done 2026-10-17* (`refusalLog`).
- [x] **A9.** IPv6 keyed by /64. — **Sonnet**, 0.5 h. *Done 2026-10-17:*
TestIPv6IsLimitedPerSlash64.
- [ ] **A3.** After five unsolicited reset links in a day, one mail saying
further requests are ignored for 24 h, then silence. — **Opus**, 1.5 h.
- [ ] **I6.** `/healthz` answered only to loopback and trusted-proxy peers;
+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