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
+123
View File
@@ -0,0 +1,123 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
"net"
"net/http"
"strings"
"sync"
"time"
"unicode"
)
// The keys the public routes are limited by (security audit 2026-10-17, A1,
// A8, A9).
//
// A client's address is the one clientIP reads, an IPv6 address cut to its
// /64: a client with a /64 to itself rotates addresses inside it at will
// and never met a per-address limit (A9). Behind a reverse proxy the panel
// does not trust, every visitor has the proxy's address; so no limit that a
// stranger can fill is keyed on the address alone at a size that stops the
// others: sign-in counts per address and username, the address alone only
// at a ceiling ten times higher; a link page counts per link; and a right
// password gives its attempt back (A1). proxyWatch says in the log when the
// shared-address case is what the panel sees.
// limitAddr is the client's address as the limiters key it.
func limitAddr(r *http.Request, trusted []*net.IPNet) string {
host := clientIP(r, trusted)
ip := net.ParseIP(host)
if ip == nil || ip.To4() != nil {
return host
}
return (&net.IPNet{IP: ip.Mask(net.CIDRMask(64, 128)), Mask: net.CIDRMask(64, 128)}).String()
}
// userKey is a limiter key for one username tried from one address. The
// name is folded as the store folds it (usernames are matched without
// regard to case).
func userKey(addr, username string) string {
return addr + "|" + strings.ToLower(strings.TrimSpace(username))
}
// tokenKey is a limiter key for one link: its hash, so a limiter's map holds
// no token.
func tokenKey(token string) string {
sum := sha256.Sum256([]byte(token))
return "link:" + hex.EncodeToString(sum[:8])
}
// logName is a typed username as a log line may hold it: printable ASCII,
// cut. It is the stranger's text.
func logName(s string) string {
s = strings.Map(func(r rune) rune {
if r > unicode.MaxASCII || !unicode.IsPrint(r) {
return '?'
}
return r
}, s)
if len(s) > 64 {
s = s[:64] + "…"
}
return s
}
// proxyWatchRequests is how many requests in a row must come from one
// private peer with X-Forwarded-For, while no proxy is trusted, before the
// panel says so.
const proxyWatchRequests = 20
// proxyWatch looks at the first requests after the start. When they all
// come from one private (or loopback) peer that carries X-Forwarded-For and
// TRUSTED_PROXY_CIDR is empty, every visitor shares that peer's sign-in
// limits: it says so once, with the value to set. Any other request - no
// header, another peer, a public one - ends the watch silently.
type proxyWatch struct {
mu sync.Mutex
peer string
n int
done bool
}
func (w *proxyWatch) see(r *http.Request, trusted []*net.IPNet) {
if len(trusted) > 0 {
return
}
w.mu.Lock()
defer w.mu.Unlock()
if w.done {
return
}
peer, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
peer = r.RemoteAddr
}
ip := net.ParseIP(peer)
if ip == nil || !(ip.IsPrivate() || ip.IsLoopback()) || r.Header.Get("X-Forwarded-For") == "" ||
(w.peer != "" && w.peer != peer) {
w.done = true
return
}
w.peer = peer
if w.n++; w.n >= proxyWatchRequests {
w.done = true
logf("panel: the last %d requests all came from %s with X-Forwarded-For, which is ignored while TRUSTED_PROXY_CIDR is empty: "+
"every visitor shares one sign-in limit. If %s is your reverse proxy, set TRUSTED_PROXY_CIDR=%s (guide § Environment variables)",
w.n, peer, peer, peer)
}
}
// refusalLog lets a refused sign-in or a lockout be logged a few times per
// address in a window, so the log shows a brute force without becoming one.
type refusalLog struct{ limiter *rateLimiter }
func newRefusalLog() *refusalLog {
return &refusalLog{limiter: newRateLimiter(5, 15*time.Minute)}
}
func (l *refusalLog) printf(addr, format string, args ...any) {
if l.limiter.Allow(addr) {
logf(format, args...)
}
}