Files
pad/internal/server/middleware_ratelimit.go
T
xarmian 8aa6481421 PHASE-12: Security Hardening for Pad Cloud (#67)
* feat: enforce RBAC role checks on all mutation endpoints (TASK-150)

Add requireMinRole helper and role enforcement to 30+ mutation handlers.
Viewers are now blocked from all state-changing operations, editors can
mutate items/docs/comments/views but not collections/webhooks/workspace
settings, and only owners can perform administrative operations.

Includes 11 integration tests with real auth covering viewer/editor/owner
access across items, collections, documents, comments, agent roles,
item links, and workspace operations.

* fix: scope search results to user's workspaces (TASK-151)

Search without a ?workspace= param previously returned results from all
workspaces in the database. Now the handler resolves the authenticated
user's workspace memberships and passes their IDs to the store query,
ensuring results only include items from workspaces the user belongs to.

Fresh installs (no users) retain unscoped search for backward compat.
Includes integration test proving cross-workspace isolation.

* fix: add webhook URL validation and SSRF protection (TASK-152)

Webhook creation now validates URLs before accepting them: only HTTP(S)
schemes allowed, embedded credentials rejected, private/reserved IPs
blocked (loopback, RFC1918, link-local, cloud metadata 169.254.169.254),
and hostnames are DNS-resolved to verify they don't point to private IPs.

Defense-in-depth check also added to the dispatcher's deliver function
so existing webhooks with unsafe URLs are blocked at delivery time.

* feat: add CSRF protection with double-submit cookie pattern (TASK-153)

Implements CSRF middleware that validates X-CSRF-Token header matches
the pad_csrf cookie on all state-changing API requests. Bearer token
auth, auth endpoints, and fresh installs are exempt. The frontend
client reads the CSRF cookie and attaches the header on mutations.

* feat: add per-endpoint rate limiting middleware (TASK-154)

Adds IP-based rate limiting for auth endpoints (5/min login, 3/hr
password reset, 5/hr registration) and user-based limits for API
(100/min) and search (30/min). Uses golang.org/x/time/rate with
automatic stale-entry cleanup. Adds chi RealIP middleware for
correct client IP behind proxies. Returns 429 with Retry-After.

* fix: sanitize error responses and remove PII from logs (TASK-155)

Replace all writeError(500, err.Error()) calls with writeInternalError
that logs the real error server-side and returns a generic message to
clients. Remove email addresses, user IDs, and password reset tokens
from log output to prevent PII leakage.

* feat: add security headers, configurable CORS, and secure cookies (TASK-160)

Add SecurityHeaders middleware (CSP, X-Frame-Options, nosniff,
Referrer-Policy, Permissions-Policy). Make CORS origins configurable
via PAD_CORS_ORIGINS env var. Add PAD_SECURE_COOKIES for TLS
deployments (sets Secure flag on session/CSRF cookies and enables
HSTS). Also adds X-CSRF-Token to CORS allowed headers.

* fix: address PR review — lazy router init and trusted IP for rate limits

Fix two issues flagged by Codex:

1. CORS/HSTS config was ignored because setupRouter() ran in New()
   before SetCORSOrigins/SetSecureCookies were called. Now uses
   sync.Once to lazily build the router on first ServeHTTP/Listen.

2. Rate limiter read X-Real-IP directly from untrusted headers,
   allowing clients to spoof IPs. Now uses RemoteAddr only (which
   chimiddleware.RealIP already sanitizes from trusted proxy headers).
2026-04-05 10:26:00 -04: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":
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.")
}