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)) }