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:
@@ -132,8 +132,16 @@ func (m *Module) HandleReset(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodGet:
|
||||
m.view.Render(w, http.StatusOK, "login-reset", view.NewReset(m.cfg.Hostname, ""))
|
||||
case http.MethodPost:
|
||||
if !m.resetLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
||||
// Per address and username, and per address alone at ten times
|
||||
// that: one stranger behind a shared proxy cannot stop everyone
|
||||
// asking for a link (security audit 2026-10-17, A1).
|
||||
m.proxyWatch.see(r, m.trustedProxies)
|
||||
addr := limitAddr(r, m.trustedProxies)
|
||||
tooMany := func() {
|
||||
m.view.Render(w, http.StatusTooManyRequests, "login-reset", view.NewReset(m.cfg.Hostname, "Too many attempts. Please wait and try again."))
|
||||
}
|
||||
if !m.resetAddrLimiter.Allow(addr) {
|
||||
tooMany()
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
@@ -141,6 +149,10 @@ func (m *Module) HandleReset(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
if !m.resetLimiter.Allow(userKey(addr, username)) {
|
||||
tooMany()
|
||||
return
|
||||
}
|
||||
m.later(func() { m.sendReset(username) })
|
||||
http.Redirect(w, r, "/login?reset=sent", http.StatusSeeOther)
|
||||
default:
|
||||
@@ -201,15 +213,26 @@ func linkToken(path, prefix string) string {
|
||||
|
||||
const deadLink = "This link has expired or was already used."
|
||||
|
||||
// allowLink is the limit of a link's page: per link, so that nobody can use up
|
||||
// another's, and per address at a ceiling high enough for everyone behind one
|
||||
// proxy (security audit 2026-10-17, A1).
|
||||
func (m *Module) allowLink(r *http.Request, token string) bool {
|
||||
m.proxyWatch.see(r, m.trustedProxies)
|
||||
if !m.linkAddrLimiter.Allow(limitAddr(r, m.trustedProxies)) {
|
||||
return false
|
||||
}
|
||||
return token == "" || m.linkLimiter.Allow(tokenKey(token))
|
||||
}
|
||||
|
||||
// HandleResetLink serves /login/reset/<token>: the new password's form (GET)
|
||||
// and setting it (POST). Setting it takes the link, ends every session of the
|
||||
// account and every other reset link, and leaves the person at sign-in.
|
||||
func (m *Module) HandleResetLink(w http.ResponseWriter, r *http.Request) {
|
||||
if !m.linkLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
||||
token := linkToken(r.URL.Path, "/login/reset/")
|
||||
if !m.allowLink(r, token) {
|
||||
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
token := linkToken(r.URL.Path, "/login/reset/")
|
||||
if token == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -305,11 +328,11 @@ func (m *Module) setPasswordFromLink(w http.ResponseWriter, r *http.Request, tok
|
||||
// a mistyped address would hand the account to whoever reads it, by a reset
|
||||
// link (security review, 2026-10-17).
|
||||
func (m *Module) HandleConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
if !m.linkLimiter.Allow(clientIP(r, m.trustedProxies)) {
|
||||
token := linkToken(r.URL.Path, "/confirm/")
|
||||
if !m.allowLink(r, token) {
|
||||
http.Error(w, "too many requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
token := linkToken(r.URL.Path, "/confirm/")
|
||||
if token == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user