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

@@ -6,6 +6,7 @@ package config
import (
"errors"
"fmt"
"net"
"os"
"time"
@@ -30,6 +31,17 @@ type ServerConfig struct {
GRPCAddr string `toml:"grpc_addr"`
TLSCert string `toml:"tls_cert"`
TLSKey string `toml:"tls_key"`
// TrustedProxy is the IP address (not a range) of a reverse proxy that
// sits in front of the server and sets X-Forwarded-For or X-Real-IP
// headers. When set, the rate limiter and audit log extract the real
// client IP from these headers instead of r.RemoteAddr.
//
// Security: only requests whose r.RemoteAddr matches TrustedProxy are
// trusted to carry a valid forwarded-IP header. All other requests use
// r.RemoteAddr directly, so this field cannot be exploited for IP
// spoofing by external clients. Omit or leave empty when running
// without a reverse proxy.
TrustedProxy string `toml:"trusted_proxy"`
}
// DatabaseConfig holds SQLite database settings.
@@ -137,6 +149,14 @@ func (c *Config) validate() error {
if c.Server.TLSKey == "" {
errs = append(errs, errors.New("server.tls_key is required"))
}
// Security (DEF-03): if trusted_proxy is set it must be a valid IP address
// (not a hostname or CIDR) so the middleware can compare it to the parsed
// host part of r.RemoteAddr using a reliable byte-level equality check.
if c.Server.TrustedProxy != "" {
if net.ParseIP(c.Server.TrustedProxy) == nil {
errs = append(errs, fmt.Errorf("server.trusted_proxy %q is not a valid IP address", c.Server.TrustedProxy))
}
}
// Database
if c.Database.Path == "" {
@@ -147,14 +167,31 @@ func (c *Config) validate() error {
if c.Tokens.Issuer == "" {
errs = append(errs, errors.New("tokens.issuer is required"))
}
// Security (DEF-05): enforce both lower and upper bounds on token expiry
// durations. An operator misconfiguration could otherwise produce tokens
// valid for centuries, which would be irrevocable (bar explicit JTI
// revocation) if a token were stolen. Upper bounds are intentionally
// generous to accommodate a range of legitimate deployments while
// catching obvious typos (e.g. "876000h" instead of "8760h").
const (
maxDefaultExpiry = 30 * 24 * time.Hour // 30 days
maxAdminExpiry = 24 * time.Hour // 24 hours
maxServiceExpiry = 5 * 365 * 24 * time.Hour // 5 years
)
if c.Tokens.DefaultExpiry.Duration <= 0 {
errs = append(errs, errors.New("tokens.default_expiry must be positive"))
} else if c.Tokens.DefaultExpiry.Duration > maxDefaultExpiry {
errs = append(errs, fmt.Errorf("tokens.default_expiry must be <= %s (got %s)", maxDefaultExpiry, c.Tokens.DefaultExpiry.Duration))
}
if c.Tokens.AdminExpiry.Duration <= 0 {
errs = append(errs, errors.New("tokens.admin_expiry must be positive"))
} else if c.Tokens.AdminExpiry.Duration > maxAdminExpiry {
errs = append(errs, fmt.Errorf("tokens.admin_expiry must be <= %s (got %s)", maxAdminExpiry, c.Tokens.AdminExpiry.Duration))
}
if c.Tokens.ServiceExpiry.Duration <= 0 {
errs = append(errs, errors.New("tokens.service_expiry must be positive"))
} else if c.Tokens.ServiceExpiry.Duration > maxServiceExpiry {
errs = append(errs, fmt.Errorf("tokens.service_expiry must be <= %s (got %s)", maxServiceExpiry, c.Tokens.ServiceExpiry.Duration))
}
// Argon2 — enforce OWASP 2023 minimums (time=2, memory=65536 KiB).