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