Files
palette/internal/api/ratelimit.go
T
fen a0b34378a4
CI / test (pull_request) Successful in 29s
CI / docker (pull_request) Skipped
fix #280: key rate-limit buckets on peer address, not client XFF
The deployed ingress does not append the peer to X-Forwarded-For, so the
rightmost XFF entry was fully client-controlled and rotating the header
gave a fresh rate-limit bucket per request (pentest: 8 creates, 6x201).

clientIP() now defaults to the peer (RemoteAddr) and never trusts
client-supplied headers. Per-client granularity is restored opt-in via
PALETTE_CLIENT_IP_HEADER, honored only when the immediate peer is inside
PALETTE_TRUSTED_PROXIES (default loopback + RFC1918) and the value
parses as an IP.
2026-09-17 20:34:59 -05:00

165 lines
4.7 KiB
Go

package api
import (
"net"
"net/http"
"os"
"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, #280).
//
// Trust boundary (revised in #280): the deployed ingress does NOT append the
// peer address to X-Forwarded-For, so every XFF entry (and X-Real-Ip) is
// client-controlled. Keying on any client-supplied header lets an attacker
// rotate the header per request and get a fresh rate-limit bucket every
// time. The bucket key therefore defaults to the PEER address (RemoteAddr),
// which the client cannot influence.
//
// Deployments that pin their proxy chain can restore per-client granularity:
// set PALETTE_CLIENT_IP_HEADER (e.g. "CF-Connecting-IP" once the proxy chain
// is pinned, e.g. Traefik forwardedHeaders.trustedIPs limited to Cloudflare
// ranges or an origin firewall locked to CF) and the header value is honored
// ONLY when the immediate peer (RemoteAddr) is inside PALETTE_TRUSTED_PROXIES
// (default: loopback + RFC1918 private ranges, i.e. the in-cluster Traefik
// hop). A public peer never triggers header trust, and the header value must
// parse as an IP.
func clientIP(r *http.Request) string {
peer := hostOnly(r.RemoteAddr)
if hdr := os.Getenv("PALETTE_CLIENT_IP_HEADER"); hdr != "" && trustedProxy(peer) {
if v := strings.TrimSpace(r.Header.Get(hdr)); net.ParseIP(v) != nil {
return v
}
}
return peer
}
// trustedProxy reports whether peer (an IP without port) falls inside any of
// the configured trusted proxy CIDRs (PALETTE_TRUSTED_PROXIES, default
// loopback + RFC1918 private ranges). Parsed once and cached.
func trustedProxy(peer string) bool {
trustedOnce.Do(func() {
spec := os.Getenv("PALETTE_TRUSTED_PROXIES")
if strings.TrimSpace(spec) == "" {
spec = "127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
}
for _, c := range strings.Split(spec, ",") {
if _, cidr, err := net.ParseCIDR(strings.TrimSpace(c)); err == nil {
trustedCIDRs = append(trustedCIDRs, cidr)
}
}
})
ip := net.ParseIP(peer)
if ip == nil {
return false
}
for _, cidr := range trustedCIDRs {
if cidr.Contains(ip) {
return true
}
}
return false
}
var (
trustedOnce sync.Once
trustedCIDRs []*net.IPNet
)
// hostOnly strips the port from a host:port address.
func hostOnly(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
return strings.TrimSpace(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))
}