package api import ( "net/http" "strconv" "strings" "sync" "time" ) // Per-IP token bucket rate limiting (#2). Goroutine-safe via mutex. type bucket struct { tokens float64 last time.Time rate float64 // tokens per second burst float64 } type limiter struct { mu sync.Mutex buckets map[string]*bucket } func newLimiter() *limiter { return &limiter{buckets: make(map[string]*bucket)} } func (l *limiter) allow(key string, rate, burst float64) bool { l.mu.Lock() defer l.mu.Unlock() now := time.Now() b, ok := l.buckets[key] if !ok { b = &bucket{tokens: burst, last: now, rate: rate, burst: burst} l.buckets[key] = b } elapsed := now.Sub(b.last).Seconds() b.tokens += elapsed * b.rate if b.tokens > b.burst { b.tokens = b.burst } b.last = now if b.tokens < 1 { return false } b.tokens-- return true } // clientIP extracts the client IP for rate-limit keying (#85). // // Trust boundary: palette runs behind exactly ONE trusted reverse proxy // (Traefik in the k3s pod network). Traefik APPENDS the real client IP to // X-Forwarded-For, so the RIGHTMOST entry is the last value the trusted // proxy observed and is unspoofable by the client (a client-supplied fake // entry only lands on the LEFT and is ignored). This matches chi's // middleware.RealIP semantics for a single trusted proxy hop. // // Direct connections (no XFF header) fall back to RemoteAddr. Directly // reachable deployments must NOT expose the app to untrusted networks // without a proxy in front, or attackers could forge the rightmost entry. func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if i := strings.LastIndex(xff, ","); i >= 0 { return strings.TrimSpace(xff[i+1:]) } return strings.TrimSpace(xff) } if xr := r.Header.Get("X-Real-Ip"); xr != "" { return strings.TrimSpace(xr) } host := r.RemoteAddr if i := strings.LastIndex(host, ":"); i > 0 { host = host[:i] } return host } var globalLimiter = newLimiter() // globalSettingsFn is set at startup; tests can point it at fixed settings. var globalSettingsFn func() Settings func globalSettings() Settings { if globalSettingsFn != nil { return globalSettingsFn() } return defaultSettings(Config{}) } // rateLimitCreate uses the admin-tunable burst and per-minute refill (#40). func rateLimitCreate(r *http.Request, s Settings) bool { return globalLimiter.allow("create:"+clientIP(r), s.RateLimitPerMinute/60.0, s.RateLimitBurst) } // rateLimitGuess: 1 req/sec refill, burst 5, per IP. func rateLimitGuess(r *http.Request) bool { return globalLimiter.allow("guess:"+clientIP(r), 1, 5) } // rateLimitUnlock: 5 per minute per IP+paste. func rateLimitUnlock(id string, r *http.Request) bool { return globalLimiter.allow("unlock:"+id+":"+clientIP(r), 5.0/60.0, 5) } // rateLimitAdmin: 5 attempts per minute per IP on the admin key check (#66), // same pattern as the unlock limiter (#34). func rateLimitAdmin(r *http.Request) bool { return globalLimiter.allow("admin:"+clientIP(r), 5.0/60.0, 5) } // writeRateLimited responds 429 with Retry-After based on refill rate. func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) { w.Header().Set("Retry-After", strconv.Itoa(retryAfterSecs)) writeErrCode(w, 429, "rate_limited", "rate limit exceeded") } // setRateLimitHeaders sets informational X-RateLimit headers for create/guess. func setRateLimitHeaders(w http.ResponseWriter, limit, burst int) { w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit)) w.Header().Set("X-RateLimit-Burst", strconv.Itoa(burst)) }