mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
- 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.
355 lines
9.8 KiB
Go
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[:])
|
|
}
|