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:
@@ -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)):
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user