mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 11:03:41 +00:00
5606b22007
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
206 lines
5.3 KiB
Go
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.")
|
|
}
|