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.
165 lines
4.7 KiB
Go
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))
|
|
}
|