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
+39 -19
View File
@@ -31,16 +31,28 @@ type Module struct {
sessions *sessionStore
setup *setupManager
loginLimiter *rateLimiter
setupLimiter *rateLimiter
passwordLimiter *rateLimiter
// resetLimiter counts the requests for a reset link per client address,
// linkLimiter the requests a link's page gets per client address, and
// mailLimiter the links mailed to one user's address (links.go).
resetLimiter *rateLimiter
linkLimiter *rateLimiter
mailLimiter *rateLimiter
mailer Mailer
// loginLimiter counts failed sign-ins per address and username,
// loginAddrLimiter per address alone at ten times that; setupLimiter
// counts setup requests per address. Neither address bucket is one a
// stranger behind the same proxy fills for everyone at the per-user size
// (limitkeys.go).
loginLimiter *rateLimiter
loginAddrLimiter *rateLimiter
setupLimiter *rateLimiter
passwordLimiter *rateLimiter
// resetLimiter counts the requests for a reset link per address and
// username, resetAddrLimiter per address alone; linkLimiter the requests
// a link's page gets per link, linkAddrLimiter per address at a high
// ceiling; mailLimiter the links mailed to one user's address (links.go).
resetLimiter *rateLimiter
resetAddrLimiter *rateLimiter
linkLimiter *rateLimiter
linkAddrLimiter *rateLimiter
mailLimiter *rateLimiter
proxyWatch proxyWatch
refusals *refusalLog
mailer Mailer
// mailing counts the links being mailed after their answer, so a stop
// waits for them (Wait) before the database closes.
mailing sync.WaitGroup
@@ -56,19 +68,23 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
idleDays = 7
}
m := &Module{
store: st,
cfg: cfg,
view: v,
sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour),
setupLimiter: newRateLimiter(10, time.Minute),
loginLimiter: newRateLimiter(10, 15*time.Minute),
store: st,
cfg: cfg,
view: v,
sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour),
setupLimiter: newRateLimiter(10, time.Minute),
loginLimiter: newRateLimiter(10, 15*time.Minute),
loginAddrLimiter: newRateLimiter(100, 15*time.Minute),
// The Account form's current-password check has a bucket of its
// own, per user: ten wrong guesses there used to lock the address
// out of signing in (audit 2026-10-12, L15), and the one guessing
// is whoever holds that user's session, wherever from.
passwordLimiter: newRateLimiter(10, 15*time.Minute),
resetLimiter: newRateLimiter(5, 15*time.Minute),
linkLimiter: newRateLimiter(30, 15*time.Minute),
passwordLimiter: newRateLimiter(10, 15*time.Minute),
resetLimiter: newRateLimiter(5, 15*time.Minute),
resetAddrLimiter: newRateLimiter(50, 15*time.Minute),
linkLimiter: newRateLimiter(30, 15*time.Minute),
linkAddrLimiter: newRateLimiter(300, 15*time.Minute),
refusals: newRefusalLog(),
// Five links an hour to one address, of either kind: three resets
// asked for and a couple of confirmations is ordinary use, a mailbox
// full of them is not.
@@ -77,6 +93,10 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
}
m.setupLimiter.startSweeper()
m.loginLimiter.startSweeper()
m.loginAddrLimiter.startSweeper()
m.resetAddrLimiter.startSweeper()
m.linkAddrLimiter.startSweeper()
m.refusals.limiter.startSweeper()
m.passwordLimiter.startSweeper()
m.resetLimiter.startSweeper()
m.linkLimiter.startSweeper()