Files
wweir b16325b413 fix: correct three regressions from the hardening pass
- Config override validation now accepts bracketed IPv6 remote addresses
  ([2001:db8::1]:8443): the host part is checked as an IP literal before
  falling back to hostname rules, so a valid IPv6 remote no longer fails
  validation.
- Directory fake sites serve local relays even when serve_ip is a
  concrete non-loopback address: the local check now recognizes any
  address bound to a local interface, not just loopback, so fallback
  traffic is not wrongly 307-redirected to https.
- The config view's restart handler ignores failures after the component
  is destroyed, so a late 401 from the in-flight restart request can no
  longer trigger a global logout from a dead page.
- The login throttle bounds its failure map (purges expired entries when
  full) so rotating source IPs cannot grow it without bound.
2026-08-12 17:43:24 +08:00

355 lines
9.8 KiB
Go

package admin
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// loadSessions restores persisted sessions at startup, dropping any that
// already expired while the process was down.
func (s *Server) loadSessions() {
if s.sessionFile == "" {
return
}
data, err := os.ReadFile(s.sessionFile)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
slog.Warn("read session file", "error", err)
}
return
}
var stored map[string]time.Time
if err := json.Unmarshal(data, &stored); err != nil {
slog.Warn("parse session file", "error", err)
return
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
for token, exp := range stored {
if now.Before(exp) {
s.sessions[token] = exp
}
}
if len(s.sessions) > 0 {
slog.Info("restored admin sessions", "count", len(s.sessions), "file", s.sessionFile)
}
}
// persistLocked writes the supplied session snapshot to disk atomically with
// 0600 permissions. It must be called with s.mu held. IO errors are returned
// so callers can keep memory and durable state consistent.
func (s *Server) persistLocked(sessions map[string]time.Time) error {
if s.sessionFile == "" {
return nil
}
data, err := json.Marshal(sessions)
if err != nil {
return fmt.Errorf("marshal sessions for %s: %w", s.sessionFile, err)
}
dir := filepath.Dir(s.sessionFile)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create session directory %s: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, ".sessions-*.tmp")
if err != nil {
return fmt.Errorf("create session temp file in %s: %w", dir, err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write session temp file %s: %w", tmpName, err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("fsync session temp file %s: %w", tmpName, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close session temp file %s: %w", tmpName, err)
}
if err := os.Chmod(tmpName, 0o600); err != nil {
return fmt.Errorf("chmod session temp file %s: %w", tmpName, err)
}
if err := os.Rename(tmpName, s.sessionFile); err != nil {
return fmt.Errorf("rename session file %s: %w", s.sessionFile, err)
}
if d, err := os.Open(dir); err == nil {
_ = d.Sync()
_ = d.Close()
}
return nil
}
func purgeExpired(sessions map[string]time.Time, now time.Time) {
for token, exp := range sessions {
if now.After(exp) {
delete(sessions, token)
}
}
}
// remoteIP extracts the host part of a remote address for login throttling.
// X-Forwarded-For is not trusted: the admin listener is typically loopback,
// and a reverse-proxied deployment throttles per-proxy instead.
func remoteIP(remoteAddr string) string {
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
return host
}
return remoteAddr
}
// Login throttling bounds password guessing when the admin listener is not
// loopback-only (the console can be bound to a LAN address or share port 80).
// Consecutive failures from one remote IP are rejected with 429 for a backoff
// window; a success clears the entry.
const (
loginMaxFails = 5
loginLockDuration = 15 * time.Minute
// loginThrottleMaxEntries bounds the failure map so rotating or spoofed
// source IPs cannot grow it without bound; stale entries are purged when
// the map is full.
loginThrottleMaxEntries = 4096
)
type loginFail struct {
count int
until time.Time
}
type loginThrottle struct {
mu sync.Mutex
fails map[string]loginFail
}
func newLoginThrottle() *loginThrottle {
return &loginThrottle{fails: make(map[string]loginFail)}
}
func (t *loginThrottle) allow(ip string) bool {
t.mu.Lock()
defer t.mu.Unlock()
f, ok := t.fails[ip]
return !ok || time.Now().After(f.until)
}
func (t *loginThrottle) fail(ip string) {
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now()
// Bound the map: when full, drop entries whose lock window already
// expired. If it is still full, stop tracking the new IP (it cannot be
// locked, but the map stays bounded).
if len(t.fails) >= loginThrottleMaxEntries {
for k, f := range t.fails {
if now.After(f.until) {
delete(t.fails, k)
}
}
}
if len(t.fails) >= loginThrottleMaxEntries {
return
}
f := t.fails[ip]
f.count++
if f.count >= loginMaxFails {
f.until = now.Add(loginLockDuration)
f.count = 0
}
t.fails[ip] = f
}
func (t *loginThrottle) success(ip string) {
t.mu.Lock()
defer t.mu.Unlock()
delete(t.fails, ip)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := remoteIP(r.RemoteAddr)
if !s.throttle.allow(ip) {
writeError(w, http.StatusTooManyRequests, "too many login attempts, try again later")
return
}
var body struct {
Password string `json:"password"`
}
if !decodeJSON(w, r, &body) {
return
}
if !secureEqual(body.Password, s.opts.Password) {
s.throttle.fail(ip)
writeError(w, http.StatusUnauthorized, "invalid password")
return
}
s.throttle.success(ip)
if !s.issueSession(w, r) {
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleSession(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookieName); err == nil {
s.mu.Lock()
delete(s.sessions, c.Value)
if err := s.persistLocked(s.sessions); err != nil {
slog.Error("persist admin logout session", "error", err)
}
s.mu.Unlock()
}
http.SetCookie(w, s.sessionCookie("", -1, s.cookieSecure(r)))
w.WriteHeader(http.StatusNoContent)
}
// sessionCookie builds the session cookie. The Secure flag is set when the
// listener uses TLS or when an explicitly configured TLS-terminating proxy
// protects the admin origin.
func (s *Server) sessionCookie(value string, maxAge int, secure bool) *http.Cookie {
return &http.Cookie{
Name: sessionCookieName,
Value: value,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: secure,
MaxAge: maxAge,
}
}
func (s *Server) cookieSecure(r *http.Request) bool {
return r.TLS != nil || s.opts.CookieSecure
}
func (s *Server) issueSession(w http.ResponseWriter, r *http.Request) bool {
var token [32]byte
if _, err := rand.Read(token[:]); err != nil {
slog.Error("generate session token", "error", err)
writeError(w, http.StatusInternalServerError, "failed to issue session")
return false
}
value := hex.EncodeToString(token[:])
s.mu.Lock()
if len(s.sessions) >= maxSessionPurge {
purgeExpired(s.sessions, time.Now())
}
if len(s.sessions) >= maxSessionPurge {
// Every retained session is still live; issuing another would grow
// the map unboundedly. Refuse instead of evicting a valid session.
s.mu.Unlock()
slog.Warn("admin session limit reached, login rejected")
writeError(w, http.StatusServiceUnavailable, "session limit reached, try later")
return false
}
s.sessions[value] = time.Now().Add(sessionTTL)
if err := s.persistLocked(s.sessions); err != nil {
slog.Error("persist admin login session", "error", err)
}
s.mu.Unlock()
http.SetCookie(w, s.sessionCookie(value, int(sessionCookieMaxAge.Seconds()), s.cookieSecure(r)))
return true
}
// validSession returns the token, whether it is valid, and whether its
// server-side expiration was renewed. Persistence failures retain the prior
// expiry so a later request can retry the renewal.
func (s *Server) validSession(r *http.Request) (string, bool, bool) {
c, err := r.Cookie(sessionCookieName)
if err != nil {
return "", false, false
}
s.mu.Lock()
defer s.mu.Unlock()
exp, ok := s.sessions[c.Value]
if !ok {
return "", false, false
}
now := time.Now()
if now.After(exp) {
delete(s.sessions, c.Value)
if err := s.persistLocked(s.sessions); err != nil {
slog.Warn("persist expired admin session cleanup", "error", err)
}
return "", false, false
}
if exp.Sub(now) < sessionTTL/2 {
s.sessions[c.Value] = now.Add(sessionTTL)
if err := s.persistLocked(s.sessions); err != nil {
slog.Warn("renew admin session", "error", err)
}
return c.Value, true, true
}
return c.Value, true, false
}
// auth rejects requests without a valid session cookie and renews the
// browser's Max-Age for every authenticated HTTP response.
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, valid, _ := s.validSession(r)
if !valid {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
http.SetCookie(w, s.sessionCookie(token, int(sessionCookieMaxAge.Seconds()), s.cookieSecure(r)))
next(w, r)
}
}
// mutateGuard rejects cross-origin state-changing requests as CSRF defense.
func (s *Server) mutateGuard(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" {
u, err := url.Parse(origin)
if err != nil || !strings.EqualFold(u.Host, r.Host) {
writeError(w, http.StatusForbidden, "cross-origin request rejected")
return
}
}
next(w, r)
}
}
// secureEqual compares two secrets in constant time regardless of length.
func secureEqual(a, b string) bool {
ha := sha256.Sum256([]byte(a))
hb := sha256.Sum256([]byte(b))
return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1
}
// GeneratePassword returns a cryptographically random 128-bit password
// (hex-encoded). cmd/sower uses it when the admin console is enabled
// without a configured password, so the listener never runs with an empty
// credential. The generated value is printed once in the startup log and
// changes on every restart.
func GeneratePassword() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
panic(fmt.Sprintf("read crypto/rand: %v", err))
}
return hex.EncodeToString(b[:])
}