Files
selfpost/internal/web/auth/auth.go
T
mixandClaude Opus 5.5 e9d2cbbe28 findings of the security review of stage 0
The review of 2c27275..2c8e981 (read-only, a separate agent) found no high
or medium; three small ones, fixed here:

- reauth took a limiter attempt before the compare, right password
  included: with A2 asking on every user save, ten saves in fifteen
  minutes locked the administrator out of Account's password change, the
  backup and the export. A right password now gives its attempt back
  (rateLimiter.Refund, Module.PasswordCheckPassed; a bucket already at its
  limit stays blocked). TestRightAccountPasswordsDoNotUseUpTheLimit.
- LoggedPath hid the tokens of /login/reset/ and /confirm/ but not of the
  first administrator's /setup/<token>; now it does (test case added).
- The entrypoint's own `[ -e ] || : >` lines (sender_login_maps, the four
  inbound maps, mail.log) get the `[ -L ]` guard postfix-config.sh got for
  I2. Safe before too - drop_links runs first on those paths and the panel
  is not running yet - this is the belt and braces.

The review also confirmed, with a probe of the regex against Postfix's
formats, that a port-25 client can place only its own envelope in an
Inbound log row, and that every path to a password, e-mail or role asks
for the signed-in password.

go test ./internal/web/handlers ./internal/web/auth; sh -n entrypoint.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:58:38 +03:00

190 lines
6.1 KiB
Go

// Package auth implements the panel's login sessions, one-time setup flow,
// and authentication middleware.
package auth
import (
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/view"
)
// Config holds auth-specific panel configuration.
type Config struct {
CookieSecure bool
Hostname string
SessionIdleDays int
TrustedProxyCIDRs []*net.IPNet
}
// Module handles login, logout, setup, and session middleware.
type Module struct {
store *store.Store
cfg Config
view *view.Engine
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
// mailing counts the links being mailed after their answer, so a stop
// waits for them (Wait) before the database closes.
mailing sync.WaitGroup
trustedProxies []*net.IPNet
}
// New builds the auth module. setupTokenPath is where the current setup token
// is mirrored on disk (security.md).
func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Module {
idleDays := cfg.SessionIdleDays
if idleDays <= 0 {
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),
// 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),
// 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,
}
m.setupLimiter.startSweeper()
m.loginLimiter.startSweeper()
m.passwordLimiter.startSweeper()
m.resetLimiter.startSweeper()
m.linkLimiter.startSweeper()
m.mailLimiter.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()
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
return m
}
// Bootstrap runs once at startup. If setup is not complete it mints and
// announces the first setup token (security.md).
func (m *Module) Bootstrap() error {
return m.setup.bootstrap()
}
// AllowPasswordChange reports whether another current-password check on the
// Account form is within the user's rate limit (security.md).
func (m *Module) AllowPasswordChange(userID int64) bool {
return m.passwordLimiter.Allow("user:" + strconv.FormatInt(userID, 10))
}
// PasswordCheckPassed gives back the attempt AllowPasswordChange took for a
// check that found the right password: only wrong guesses count, so an
// administrator who adds ten users in a quarter of an hour is not locked out
// of the next backup (review of audit 2026-10-17 stage 0).
func (m *Module) PasswordCheckPassed(userID int64) {
m.passwordLimiter.Refund("user:" + strconv.FormatInt(userID, 10))
}
// SessionToken returns the session token the request carries, if exactly one
// cookie of that name is present.
func (m *Module) SessionToken(r *http.Request) (string, bool) {
return m.sessionToken(r)
}
// DestroyOtherSessions invalidates every session of the user except keep.
func (m *Module) DestroyOtherSessions(userID int64, keep string) {
m.sessions.DestroyOthers(userID, keep)
}
// DestroyUserSessions invalidates every session of the user.
func (m *Module) DestroyUserSessions(userID int64) {
m.sessions.DestroyAll(userID)
}
func logf(format string, args ...any) {
log.Printf(format, args...)
}
// clientIP extracts the peer IP for rate-limiting. By default it is the
// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches
// one of trustedProxies, the last entry of X-Forwarded-For is used instead —
// that is the address the trusted proxy itself appended, so a client can't
// forge it by sending its own XFF header.
func clientIP(r *http.Request, trustedProxies []*net.IPNet) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
if len(trustedProxies) > 0 {
if peer := net.ParseIP(host); peer != nil && ipInAny(peer, trustedProxies) {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
if ip := net.ParseIP(strings.TrimSpace(parts[len(parts)-1])); ip != nil {
return ip.String()
}
}
}
}
return host
}
// SeenAddress is how the panel sees a request's address, for Health: the
// address it counts sign-ins against (Client), the transport peer (Peer),
// and whether Client came from X-Forwarded-For (FromHeader) or the header
// was there and left unread because the peer is not a trusted proxy
// (HeaderIgnored) - the case where every visitor shares the proxy's address
// and one sign-in limit.
type SeenAddress struct {
Client string
Peer string
FromHeader bool
HeaderIgnored bool
}
// Seen reads a request's address as clientIP does.
func (m *Module) Seen(r *http.Request) SeenAddress {
peer, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
peer = r.RemoteAddr
}
s := SeenAddress{Client: clientIP(r, m.trustedProxies), Peer: peer}
hasHeader := r.Header.Get("X-Forwarded-For") != ""
s.FromHeader = hasHeader && s.Client != peer
s.HeaderIgnored = hasHeader && !s.FromHeader
return s
}
func ipInAny(ip net.IP, nets []*net.IPNet) bool {
for _, n := range nets {
if n.Contains(ip) {
return true
}
}
return false
}