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

@@ -16,6 +16,7 @@ import (
"fmt"
"io/fs"
"log/slog"
"net"
"net/http"
"git.wntrmute.dev/kyle/mcias/internal/auth"
@@ -56,10 +57,19 @@ func New(database *db.DB, cfg *config.Config, priv ed25519.PrivateKey, pub ed255
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// Security (DEF-03): parse the optional trusted-proxy address once here
// so RateLimit and audit-log helpers use consistent IP extraction.
// net.ParseIP returns nil for an empty string, which disables proxy
// trust and falls back to r.RemoteAddr.
var trustedProxy net.IP
if s.cfg.Server.TrustedProxy != "" {
trustedProxy = net.ParseIP(s.cfg.Server.TrustedProxy)
}
// Security: per-IP rate limiting on public auth endpoints to prevent
// brute-force login attempts and token-validation abuse. Parameters match
// the gRPC rate limiter (10 req/s sustained, burst 10).
loginRateLimit := middleware.RateLimit(10, 10)
loginRateLimit := middleware.RateLimit(10, 10, trustedProxy)
// Public endpoints (no authentication required).
mux.HandleFunc("GET /v1/health", s.handleHealth)
@@ -82,16 +92,20 @@ func (s *Server) Handler() http.Handler {
if err != nil {
panic(fmt.Sprintf("server: read openapi.yaml: %v", err))
}
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, _ *http.Request) {
// Security (DEF-09): apply defensive HTTP headers to the docs handlers.
// The Swagger UI page at /docs loads JavaScript from the same origin
// and renders untrusted content (API descriptions), so it benefits from
// CSP, X-Frame-Options, and the other headers applied to the UI sub-mux.
mux.Handle("GET /docs", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(docsHTML)
})
mux.HandleFunc("GET /docs/openapi.yaml", func(w http.ResponseWriter, _ *http.Request) {
})))
mux.Handle("GET /docs/openapi.yaml", docsSecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(specYAML)
})
})))
// Authenticated endpoints.
requireAuth := middleware.RequireAuth(s.pubKey, s.db, s.cfg.Tokens.Issuer)
@@ -251,13 +265,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
valid, err := auth.ValidateTOTP(secret, req.TOTPCode)
valid, totpCounter, err := auth.ValidateTOTP(secret, req.TOTPCode)
if err != nil || !valid {
s.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"wrong_totp"}`)
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
// Security (CRIT-01): reject replay of a code already used within
// its ±30-second validity window.
if err := s.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
s.writeAudit(r, model.EventLoginTOTPFail, &acct.ID, nil, `{"reason":"totp_replay"}`)
_ = s.db.RecordLoginFailure(acct.ID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
}
}
// Login succeeded: clear any outstanding failure counter.
@@ -764,11 +786,18 @@ func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) {
return
}
valid, err := auth.ValidateTOTP(secret, req.Code)
valid, totpCounter, err := auth.ValidateTOTP(secret, req.Code)
if err != nil || !valid {
middleware.WriteError(w, http.StatusUnauthorized, "invalid TOTP code", "unauthorized")
return
}
// Security (CRIT-01): record the counter even during enrollment
// confirmation so the same code cannot be replayed immediately after
// confirming.
if err := s.db.CheckAndUpdateTOTPCounter(acct.ID, totpCounter); err != nil {
middleware.WriteError(w, http.StatusUnauthorized, "invalid TOTP code", "unauthorized")
return
}
// Mark TOTP as confirmed and required.
if err := s.db.SetTOTP(acct.ID, acct.TOTPSecretEnc, acct.TOTPSecretNonce); err != nil {
@@ -1149,8 +1178,14 @@ func (s *Server) loadAccount(w http.ResponseWriter, r *http.Request) (*model.Acc
}
// writeAudit appends an audit log entry, logging errors but not failing the request.
// The logged IP honours the trusted-proxy setting so the real client address
// is recorded rather than the proxy's address (DEF-03).
func (s *Server) writeAudit(r *http.Request, eventType string, actorID, targetID *int64, details string) {
ip := r.RemoteAddr
var proxyIP net.IP
if s.cfg.Server.TrustedProxy != "" {
proxyIP = net.ParseIP(s.cfg.Server.TrustedProxy)
}
ip := middleware.ClientIP(r, proxyIP)
if err := s.db.WriteAuditEvent(eventType, actorID, targetID, ip, details); err != nil {
s.logger.Error("write audit event", "error", err, "event_type", eventType)
}
@@ -1191,6 +1226,25 @@ func extractBearerFromRequest(r *http.Request) (string, error) {
return auth[len(prefix):], nil
}
// docsSecurityHeaders adds the same defensive HTTP headers as the UI sub-mux
// to the /docs and /docs/openapi.yaml endpoints.
//
// Security (DEF-09): without these headers the Swagger UI HTML page is
// served without CSP, X-Frame-Options, or HSTS, leaving it susceptible
// to clickjacking and MIME-type confusion in browsers.
func docsSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
h.Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
// encodeBase64URL encodes bytes as base64url without padding.
func encodeBase64URL(b []byte) string {
const table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"

View File

@@ -376,7 +376,7 @@ func TestSetAndGetRoles(t *testing.T) {
// Set roles.
rr := doRequest(t, handler, "PUT", "/v1/accounts/"+target.UUID+"/roles", map[string][]string{
"roles": {"reader", "writer"},
"roles": {"admin", "user"},
}, adminToken)
if rr.Code != http.StatusNoContent {
t.Errorf("set roles status = %d, want 204; body: %s", rr.Code, rr.Body.String())