auth: reset links pause for a day after five (audit A3)

Security audit 2026-10-17, finding A3 (low), stage 3. sendReset spaced
reset mails ten minutes apart and capped them at five an hour per account,
so a stranger who repeated a username kept a live 30-minute link arriving
in the administrator's mailbox five times an hour, for ever.

Now five reset links a day per account (resetDayLimiter); the sixth
request sends one notice instead - "Reset links paused for a day", no
link, saying further requests are ignored for 24 hours and that a global
administrator can set a password on Server > Users - and every later one
in the window sends nothing (resetNoticeLimiter), logged either way. In
memory, like every limiter: a restart clears them.

Test: TestResetLinksPauseForADayAfterFive. Docs: security.md, guide §
Account; CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-17 22:15:50 +03:00
co-authored by Claude Opus 5.5
parent 4433688500
commit 8aad5b6223
7 changed files with 78 additions and 6 deletions
+13 -2
View File
@@ -49,6 +49,11 @@ type Module struct {
linkLimiter *rateLimiter
linkAddrLimiter *rateLimiter
mailLimiter *rateLimiter
// resetDayLimiter counts the reset links mailed to one account in a
// day, resetNoticeLimiter the one notice that further requests are
// ignored (links.go, sendReset).
resetDayLimiter *rateLimiter
resetNoticeLimiter *rateLimiter
proxyWatch proxyWatch
refusals *refusalLog
@@ -88,8 +93,12 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
// 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.
mailLimiter: newRateLimiter(5, time.Hour),
trustedProxies: cfg.TrustedProxyCIDRs,
mailLimiter: newRateLimiter(5, time.Hour),
// Five reset links a day to one account, then one notice and
// silence for the rest of the day (security audit 2026-10-17, A3).
resetDayLimiter: newRateLimiter(resetsPerDay, 24*time.Hour),
resetNoticeLimiter: newRateLimiter(1, 24*time.Hour),
trustedProxies: cfg.TrustedProxyCIDRs,
}
m.setupLimiter.startSweeper()
m.loginLimiter.startSweeper()
@@ -101,6 +110,8 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
m.resetLimiter.startSweeper()
m.linkLimiter.startSweeper()
m.mailLimiter.startSweeper()
m.resetDayLimiter.startSweeper()
m.resetNoticeLimiter.startSweeper()
// Made now rather than at the first unknown name, which would otherwise
// pay for two bcrypt runs (review of audit stage 4).
go noSuchUserHash()