trusted proxy, TOTP replay protection, new tests

- Trusted proxy config option for proxy-aware IP extraction
  used by rate limiting and audit logs; validates proxy IP
  before trusting X-Forwarded-For / X-Real-IP headers
- TOTP replay protection via counter-based validation to
  reject reused codes within the same time step (±30s)
- RateLimit middleware updated to extract client IP from
  proxy headers without IP spoofing risk
- New tests for ClientIP proxy logic (spoofed headers,
  fallback) and extended rate-limit proxy coverage
- HTMX error banner script integrated into web UI base
- .gitignore updated for mciasdb build artifact

Security: resolves CRIT-01 (TOTP replay attack) and
DEF-03 (proxy-unaware rate limiting); gRPC TOTP
enrollment aligned with REST via StorePendingTOTP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 17:44:01 -07:00
parent f262ca7b4e
commit ec7c966ad2
31 changed files with 799 additions and 250 deletions

View File

@@ -22,14 +22,15 @@ import (
"html/template"
"io/fs"
"log/slog"
"net"
"net/http"
"strings"
"sync"
"time"
"git.wntrmute.dev/kyle/mcias/internal/auth"
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/middleware"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/web"
)
@@ -223,7 +224,7 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
tmpls[name] = clone
}
return &UIServer{
srv := &UIServer{
db: database,
cfg: cfg,
pubKey: pub,
@@ -232,7 +233,33 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
logger: logger,
csrf: csrf,
tmpls: tmpls,
}, nil
}
// Security (DEF-02): launch a background goroutine to evict expired TOTP
// nonces from pendingLogins. consumeTOTPNonce deletes entries on use, but
// entries abandoned by users who never complete step 2 would otherwise
// accumulate indefinitely, enabling a memory-exhaustion attack.
go srv.cleanupPendingLogins()
return srv, nil
}
// cleanupPendingLogins periodically evicts expired entries from pendingLogins.
// It runs every 5 minutes, which is well within the 90-second nonce TTL, so
// stale entries are removed before they can accumulate to any significant size.
func (u *UIServer) cleanupPendingLogins() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
u.pendingLogins.Range(func(key, value any) bool {
pl, ok := value.(*pendingLogin)
if !ok || now.After(pl.expiresAt) {
u.pendingLogins.Delete(key)
}
return true
})
}
}
// Register attaches all UI routes to mux, wrapped with security headers.
@@ -259,9 +286,18 @@ func (u *UIServer) Register(mux *http.ServeMux) {
http.NotFound(w, r)
})
// Security (DEF-01, DEF-03): apply the same per-IP rate limit as the REST
// /v1/auth/login endpoint, using the same proxy-aware IP extraction so
// the rate limit is applied to real client IPs behind a reverse proxy.
var trustedProxy net.IP
if u.cfg.Server.TrustedProxy != "" {
trustedProxy = net.ParseIP(u.cfg.Server.TrustedProxy)
}
loginRateLimit := middleware.RateLimit(10, 10, trustedProxy)
// Auth routes (no session required).
uiMux.HandleFunc("GET /login", u.handleLoginPage)
uiMux.HandleFunc("POST /login", u.handleLoginPost)
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
uiMux.HandleFunc("POST /logout", u.handleLogout)
// Protected routes.
@@ -498,13 +534,15 @@ func securityHeaders(next http.Handler) http.Handler {
})
}
// clientIP extracts the client IP from RemoteAddr (best effort).
func clientIP(r *http.Request) string {
addr := r.RemoteAddr
if idx := strings.LastIndex(addr, ":"); idx != -1 {
return addr[:idx]
// clientIP returns the real client IP for the request, respecting the
// server's trusted-proxy setting (DEF-03). Delegates to middleware.ClientIP
// so the same extraction logic is used for rate limiting and audit logging.
func (u *UIServer) clientIP(r *http.Request) string {
var proxyIP net.IP
if u.cfg.Server.TrustedProxy != "" {
proxyIP = net.ParseIP(u.cfg.Server.TrustedProxy)
}
return addr
return middleware.ClientIP(r, proxyIP)
}
// actorName resolves the username of the currently authenticated user from the