Files
pad/internal/server/middleware_ratelimit.go
T
xarmian 5606b22007 fix: address 6 security findings from Codex review of TOTP 2FA
HIGH fixes:
- Login-verify no longer accepts bare user_id. Now requires an
  HMAC-signed, IP-bound, 5-minute challenge token issued during login
  (prevents password bypass via known user ID + TOTP code)
- Recovery codes are SHA-256 hashed before storage; plaintext is
  returned to the user once and never persisted

MEDIUM fixes:
- ConsumeRecoveryCode uses a DB transaction to prevent concurrent
  double-consumption of the same recovery code
- EnableTOTP is atomic: WHERE clause requires totp_secret match to
  prevent TOCTOU race between setup and verify calls
- /auth/2fa/login-verify now uses the strict Auth rate limiter
  (5 req/min/IP) instead of the general API limiter
- CLI login detects requires_2fa response and prompts for TOTP code
  instead of silently saving empty credentials
2026-04-08 20:30:39 +00:00

206 lines
5.3 KiB
Go

package server
import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/time/rate"
)
// rateLimitConfig holds the rate and burst for a limiter.
type rateLimitConfig struct {
Rate rate.Limit // events per second
Burst int // max burst
}
// ipRateLimiter tracks per-key rate limiters with automatic cleanup.
type ipRateLimiter struct {
mu sync.Mutex
limiters map[string]*rateLimiterEntry
config rateLimitConfig
}
type rateLimiterEntry struct {
limiter *rate.Limiter
lastSeen time.Time
}
func newIPRateLimiter(cfg rateLimitConfig) *ipRateLimiter {
rl := &ipRateLimiter{
limiters: make(map[string]*rateLimiterEntry),
config: cfg,
}
// Background cleanup of stale entries every 5 minutes
go rl.cleanup()
return rl
}
func (rl *ipRateLimiter) getLimiter(key string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()
entry, exists := rl.limiters[key]
if !exists {
limiter := rate.NewLimiter(rl.config.Rate, rl.config.Burst)
rl.limiters[key] = &rateLimiterEntry{
limiter: limiter,
lastSeen: time.Now(),
}
return limiter
}
entry.lastSeen = time.Now()
return entry.limiter
}
func (rl *ipRateLimiter) cleanup() {
for {
time.Sleep(5 * time.Minute)
rl.mu.Lock()
for key, entry := range rl.limiters {
if time.Since(entry.lastSeen) > 30*time.Minute {
delete(rl.limiters, key)
}
}
rl.mu.Unlock()
}
}
// RateLimiters holds all the rate limiters used by the server.
type RateLimiters struct {
// Auth endpoints: strict limits per IP
Auth *ipRateLimiter
// Password reset: per-IP
PasswordReset *ipRateLimiter
// Registration: per-IP
Register *ipRateLimiter
// API: per-user (authenticated)
API *ipRateLimiter
// Search: per-user or per-IP
Search *ipRateLimiter
}
// NewRateLimiters creates rate limiters with sensible defaults.
func NewRateLimiters() *RateLimiters {
return &RateLimiters{
// Login: 5 attempts per minute per IP (= 5/60 per second, burst 5)
Auth: newIPRateLimiter(rateLimitConfig{
Rate: rate.Limit(5.0 / 60.0),
Burst: 5,
}),
// Password reset: 3 per hour per IP (= 3/3600 per second, burst 3)
PasswordReset: newIPRateLimiter(rateLimitConfig{
Rate: rate.Limit(3.0 / 3600.0),
Burst: 3,
}),
// Registration: 5 per hour per IP (= 5/3600 per second, burst 5)
Register: newIPRateLimiter(rateLimitConfig{
Rate: rate.Limit(5.0 / 3600.0),
Burst: 5,
}),
// API: 100 requests per minute per user/IP (= 100/60 per second, burst 20)
API: newIPRateLimiter(rateLimitConfig{
Rate: rate.Limit(100.0 / 60.0),
Burst: 20,
}),
// Search: 30 requests per minute per user/IP (= 30/60 per second, burst 10)
Search: newIPRateLimiter(rateLimitConfig{
Rate: rate.Limit(30.0 / 60.0),
Burst: 10,
}),
}
}
// RateLimit is the general-purpose rate limiting middleware.
// It applies different limits based on the endpoint being hit.
func (s *Server) RateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.rateLimiters == nil {
next.ServeHTTP(w, r)
return
}
path := r.URL.Path
// Only rate-limit API endpoints
if !strings.HasPrefix(path, "/api/") {
next.ServeHTTP(w, r)
return
}
ip := clientIP(r)
// Auth-specific rate limits
if strings.HasPrefix(path, "/api/v1/auth/") {
var limiter *ipRateLimiter
switch {
case path == "/api/v1/auth/login" || path == "/api/v1/auth/bootstrap" || path == "/api/v1/auth/2fa/login-verify":
limiter = s.rateLimiters.Auth
case path == "/api/v1/auth/forgot-password" || path == "/api/v1/auth/reset-password":
limiter = s.rateLimiters.PasswordReset
case path == "/api/v1/auth/register":
limiter = s.rateLimiters.Register
default:
// Other auth endpoints (session check, logout) — use general API limit
limiter = s.rateLimiters.API
}
if limiter != nil && !limiter.getLimiter(ip).Allow() {
writeTooManyRequests(w)
return
}
next.ServeHTTP(w, r)
return
}
// Search endpoint
if path == "/api/v1/search" {
key := rateLimitKey(r, ip)
if !s.rateLimiters.Search.getLimiter(key).Allow() {
writeTooManyRequests(w)
return
}
next.ServeHTTP(w, r)
return
}
// General API rate limit
key := rateLimitKey(r, ip)
if !s.rateLimiters.API.getLimiter(key).Allow() {
writeTooManyRequests(w)
return
}
next.ServeHTTP(w, r)
})
}
// rateLimitKey returns a key for rate limiting: user ID if authenticated, IP otherwise.
func rateLimitKey(r *http.Request, ip string) string {
if user := currentUser(r); user != nil {
return "user:" + user.ID
}
return "ip:" + ip
}
// clientIP extracts the client IP from RemoteAddr. This is safe because
// chimiddleware.RealIP runs earlier in the chain and overwrites RemoteAddr
// with the trusted value from X-Real-IP / X-Forwarded-For. We deliberately
// do NOT read proxy headers here to prevent clients from spoofing their IP
// to bypass rate limits.
func clientIP(r *http.Request) string {
host := r.RemoteAddr
if idx := strings.LastIndex(host, ":"); idx != -1 {
return host[:idx]
}
return host
}
// writeTooManyRequests sends a 429 response with a Retry-After header.
func writeTooManyRequests(w http.ResponseWriter) {
w.Header().Set("Retry-After", strconv.Itoa(60)) // suggest retry after 60s
writeError(w, http.StatusTooManyRequests, "rate_limited", "Too many requests. Please try again later.")
}