Files
selfpost/internal/web/auth/handlers.go
T
mixandClaude Opus 5.5 32f6ed626d findings of the security review of stages 2-6
The review of 4433688..0808779 (read-only, a separate agent) found one
medium and some lows; all fixed here.

Medium, proven by the reviewer: A1's userKey put the typed username into
the limiter map whole, and the public forms had no body bound beyond
net/http's 10 MiB for a parsed form - each refused request kept up to
10 MiB in memory for fifteen minutes. userKey now holds a 96-bit hash of
the folded name, and sign-in, reset, a reset link's page and setup read at
most 64 KiB (limitForm, http.MaxBytesReader).
TestPublicFormsAreBoundedAndKeysShort.

Lows:
- Refund skipped the request that filled a bucket (count < max), so a
  right password at the ceiling left the shared address blocked; Refund
  now always undoes the Allow it follows.
- The reset form did not give the address its attempt back when the
  name's bucket refused (sign-in did); it does.
- A3's day of five links is spent only by a link that goes out, and the
  price of the pause - anyone knowing a username can pause that owner's
  reset for a day - is recorded in security.md.
- longHeaderLine counted a trailing CR on a header's first line.
- test.yml declares permissions: contents: read.

Not changed, as the review's nits: the store matches names exactly while
the limiter folds them (stricter, said in the comment now); a session from
a longer window is honoured until its next Touch (the next request).

go test ./internal/web/auth ./internal/milter.

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

298 lines
9.6 KiB
Go

package auth
import (
"errors"
"net/http"
"strings"
"sync"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"github.com/mixeme/selfpost/internal/web/view"
"golang.org/x/crypto/bcrypt"
)
// draft-ietf-httpbis-rfc6265bis § 4.1.3.2: a cookie named __Host- is accepted
// only with Secure, Path=/ and no Domain, from a secure origin - so no other
// host can set or overwrite it, and clearing it needs Secure as well.
const (
sessionCookieBase = "selfpost_session"
sessionCookiePrefixed = "__Host-" + sessionCookieBase
)
// BcryptCost is the work factor for every panel password hash — the setup
// account created here and the ones the settings and user pages write
// (security.md). bcrypt records the cost inside the hash, so raising it only
// affects passwords set from now on; existing hashes keep verifying at the
// cost they were written with.
const BcryptCost = 12
func (m *Module) sessionCookie() string {
if m.cfg.CookieSecure {
return sessionCookiePrefixed
}
return sessionCookieBase
}
func (m *Module) sessionToken(r *http.Request) (string, bool) {
name := m.sessionCookie()
var token string
var n int
for _, c := range r.Cookies() {
if c.Name == name {
n++
token = c.Value
}
}
switch n {
case 0:
return "", false
case 1:
return token, true
default:
// RFC 6265 § 5.4: the Cookie header carries every cookie that
// matches, a parent domain's of the same name included. The prefixed
// name cannot be planted that way (see sessionCookiePrefixed).
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
r.Method, LoggedPath(r.URL.Path), n, name)
return "", false
}
}
func (m *Module) clearSessionCookies(w http.ResponseWriter) {
for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: m.cfg.CookieSecure || name == sessionCookiePrefixed,
SameSite: http.SameSiteLaxMode,
})
}
}
// HandleLogin serves the login form (GET) and authenticates (POST).
func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) {
exists, err := m.store.UserExists()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !exists {
m.view.Render(w, http.StatusOK, "login", view.NewLoginSetupHint(m.cfg.Hostname))
return
}
switch r.Method {
case http.MethodGet:
m.view.Render(w, http.StatusOK, "login", view.NewLogin(m.cfg.Hostname, "").WithInfo(loginInfo(r)))
case http.MethodPost:
m.submitLogin(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (m *Module) renderLogin(w http.ResponseWriter, status int, formErr string) {
m.view.Render(w, status, "login", view.NewLogin(m.cfg.Hostname, formErr))
}
// submitLogin checks a sign-in. Its limits are per address and username,
// and per address alone at ten times that, and only a failure counts: one
// stranger behind the proxy everyone shares no longer locks every
// administrator out, nor does a day of signing in (security audit
// 2026-10-17, A1). A refusal and a lockout are logged, a few lines per
// address in a window (A8).
func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
m.proxyWatch.see(r, m.trustedProxies)
addr := limitAddr(r, m.trustedProxies)
if !m.loginAddrLimiter.Allow(addr) {
m.refusals.printf(addr, "panel: sign-in from %s refused: too many attempts from the address", addr)
m.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
return
}
limitForm(w, r)
if err := r.ParseForm(); err != nil {
m.renderLogin(w, http.StatusBadRequest, "Invalid form submission.")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
key := userKey(addr, username)
if !m.loginLimiter.Allow(key) {
m.loginAddrLimiter.Refund(addr) // counted once, against the name
m.refusals.printf(addr, "panel: sign-in as %q from %s refused: too many attempts", logName(username), addr)
m.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
return
}
refused := func() {
m.refusals.printf(addr, "panel: sign-in as %q from %s refused: wrong username or password", logName(username), addr)
m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.")
}
user, err := m.store.GetUserByUsername(username)
if err != nil {
if !errors.Is(err, store.ErrUserNotFound) {
logf("panel: login: get user failed: %v", err)
}
// A comparison anyway, so an unknown name is refused in the time
// a wrong password is: the answer's delay told which names exist
// (audit 2026-10-12, M8).
_ = bcrypt.CompareHashAndPassword(noSuchUserHash(), []byte(password))
refused()
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
refused()
return
}
// A right password is no guess: its attempts are given back.
m.loginLimiter.Refund(key)
m.loginAddrLimiter.Refund(addr)
// The session the browser presents, if any, ends here: a sign-in issues
// a new token and leaves no other behind it - one planted before the
// sign-in is worth nothing after (audit 2026-10-12, L16).
name := m.sessionCookie()
for _, c := range r.Cookies() {
if c.Name == name {
m.sessions.Destroy(c.Value)
}
}
token, err := m.sessions.Create(user.ID)
if err != nil {
logf("panel: login: create session failed: %v", err)
m.renderLogin(w, http.StatusInternalServerError, "Internal error. Please try again.")
return
}
m.setSessionCookie(w, token)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// noSuchUserHash is a hash of the panel's work factor, compared against when
// a sign-in names no user.
var noSuchUserHash = sync.OnceValue(func() []byte {
h, err := bcrypt.GenerateFromPassword([]byte("selfpost: no such user"), BcryptCost)
if err != nil {
panic(err)
}
return h
})
// setSessionCookie sets the session cookie. SameSite=Lax withholds it from a
// cross-site request unless that is a top-level navigation with a safe method
// (rfc6265bis § 5.8.3, § 5.6.7.1), so a cross-site POST arrives without it.
func (m *Module) setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: m.sessionCookie(),
Value: token,
Path: "/",
MaxAge: m.sessions.MaxAge(),
HttpOnly: true,
Secure: m.cfg.CookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
// HandleLogout destroys the session and clears the cookie.
func (m *Module) HandleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
name := m.sessionCookie()
for _, c := range r.Cookies() {
if c.Name == name {
m.sessions.Destroy(c.Value)
}
}
m.clearSessionCookies(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// HandleSetup serves the one-time administrator creation flow at
// /setup/<token> (security.md).
func (m *Module) HandleSetup(w http.ResponseWriter, r *http.Request) {
m.proxyWatch.see(r, m.trustedProxies)
if !m.setupLimiter.Allow(limitAddr(r, m.trustedProxies)) {
http.Error(w, "too many requests", http.StatusTooManyRequests)
return
}
token := strings.TrimPrefix(r.URL.Path, "/setup/")
if token == "" || strings.Contains(token, "/") {
http.NotFound(w, r)
return
}
if !m.setup.validate(token) {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
m.renderSetupForm(w, http.StatusOK, token, "")
case http.MethodPost:
m.submitSetup(w, r, token)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (m *Module) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) {
m.view.Render(w, status, "setup", view.NewSetup(token, formErr))
}
func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token string) {
limitForm(w, r)
if err := r.ParseForm(); err != nil {
m.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.")
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
confirm := r.PostFormValue("password_confirm")
if err := validate.Username(username); err != nil {
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
return
}
if password != confirm {
m.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.")
return
}
if err := validate.AdminPassword(password); err != nil {
m.renderSetupForm(w, http.StatusBadRequest, token, err.Error())
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
if err != nil {
logf("panel: setup: hashing password failed: %v", err)
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
return
}
if err := m.store.CreateGlobalUser(username, string(hash)); err != nil {
if exists, _ := m.store.UserExists(); exists {
m.setup.complete()
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
logf("panel: setup: create user failed: %v", err)
m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.")
return
}
m.setup.complete()
logf("panel: administrator %q created; setup link is now disabled", username)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}