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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user