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()
+22 -1
View File
@@ -29,6 +29,12 @@ const (
// spend the account's links an hour in a minute (security review,
// 2026-10-17).
resetSpacing = 10 * time.Minute
// resetsPerDay is how many reset links one account is mailed in a day.
// Anyone who knows a username can ask for them; past this the owner is
// told once that further requests are ignored for a day, and then nothing
// more is sent - rather than a live link in the mailbox five times an
// hour for ever (security audit 2026-10-17, A3).
resetsPerDay = 5
)
// later runs f after the answer, counted for Wait.
@@ -182,7 +188,22 @@ func (m *Module) sendReset(username string) {
logf("panel: user %d: a reset link went less than %s ago; none was sent", u.ID, resetSpacing)
return
}
if !m.mailLimiter.Allow("reset:" + strconv.FormatInt(u.ID, 10)) {
key := "reset:" + strconv.FormatInt(u.ID, 10)
if !m.resetDayLimiter.Allow(key) {
if !m.resetNoticeLimiter.Allow(key) {
logf("panel: user %d: reset links asked for again after today's %d; nothing was sent", u.ID, resetsPerDay)
return
}
logf("panel: user %d: reset links asked for more than %d times today; the owner is told further requests are ignored for 24 hours", u.ID, resetsPerDay)
body := "Reset links for the SelfPost account " + u.Username + " at " + m.cfg.Hostname + " have been asked for more than " +
strconv.Itoa(resetsPerDay) + " times today. Further requests are ignored for 24 hours, and no more mail of this kind will come before then.\n\n" +
"If it was not you, nothing needs doing: the password stays as it is. A global administrator can set a new password on Server > Users at any time.\n"
if err := m.mailer.Send(u.Email, "[SelfPost] Reset links paused for a day", body); err != nil {
logf("panel: user %d: notice not sent: %v", u.ID, err)
}
return
}
if !m.mailLimiter.Allow(key) {
logf("panel: user %d: reset links asked for too often; none was sent", u.ID)
return
}
+31
View File
@@ -1,6 +1,7 @@
package auth
import (
"github.com/mixeme/selfpost/internal/store"
"net/http"
"net/http/httptest"
"net/url"
@@ -299,3 +300,33 @@ func TestLinkPathsWithoutAToken(t *testing.T) {
}
}
}
// Five reset links a day to one account, then one notice that further
// requests are ignored for a day, then nothing (security audit 2026-10-17,
// A3). The ten-minute spacing is stepped over by removing the last link.
func TestResetLinksPauseForADayAfterFive(t *testing.T) {
m, ml := linkModule(t, true)
u, _ := m.store.GetUserByUsername("admin")
for i := 0; i < resetsPerDay+3; i++ {
if err := m.store.DeleteUserLinks(u.ID, store.LinkReset); err != nil {
t.Fatal(err)
}
m.sendReset("admin")
got, sent := ml.next(t)
switch {
case i < resetsPerDay:
if !sent || !resetLinkRe.MatchString(got.body) {
t.Fatalf("link %d: sent %v, %+v", i+1, sent, got)
}
case i == resetsPerDay:
if !sent || got.subject != "[SelfPost] Reset links paused for a day" || resetLinkRe.MatchString(got.body) ||
!strings.Contains(got.body, "ignored for 24 hours") {
t.Fatalf("the notice: sent %v, %+v", sent, got)
}
default:
if sent {
t.Fatalf("request %d after the notice sent %+v", i+1, got)
}
}
}
}