mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Red review (fix-then-ship). Data-integrity cores unchanged and still green. DoS bounds (Red PoCs reproduced, now asserted bounded): - slots amplification: clamp startTime..endTime span to <=62 days AND cap generation at 5000 slots, both BEFORE generation; add per-IP + per-handle read throttle. 1000-year range: 203,164 slots/7.26MB -> 5000 slots/160KB. - holds map: real hard cap (prune-expired then evict-nearest-expiry) — was prune-only, which grew unbounded under fresh-reserve spam. 13,192 -> 8,192. - limiter maps: same hard cap (evict-oldest) under key rotation; add read/reserve rate limits (booking write already limited). 13,192 -> 8,192. Public handle decoupled from the IAM subject: eventType resolves by (handle, slug) via bound params; the internal IAM owner id is never placed in a URL or published in any DTO (public event, booking, reschedule). New handle field + partial unique (handle, slug) index on the (unshipped) scheduling migration. Deploy gate documented in book(): SQLite double-book guard holds only at replicas:1 + strategy:Recreate. Tests (-race, 20 pass): +TestComputeSlotsCapBounds, +TestHolds_HardCap, +TestLimiter_HardCap, +TestHTTP_SlotsAmplificationBounded, +TestHTTP_ReadRateLimited; PublicEventNoLeak/CreateBooking now assert the owner id never appears.
130 lines
4.1 KiB
Go
130 lines
4.1 KiB
Go
package calendar
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
)
|
|
|
|
// errSlotTaken signals a lost availability race inside the booking transaction.
|
|
var errSlotTaken = errors.New("slot taken")
|
|
|
|
// limiterMaxKeys is the HARD cap on distinct keys a limiter retains. allow() never
|
|
// grows the map past it: when full it prunes expired windows and, if still full,
|
|
// evicts the oldest before inserting. This bounds memory when an attacker rotates
|
|
// keys (X-Forwarded-For or owner) faster than the window expires — pruning expired
|
|
// windows alone does not (that was the bug).
|
|
const limiterMaxKeys = 8192
|
|
|
|
// limiter is a fixed-window per-key request counter — a minimal throttle bounding
|
|
// abuse of the unauthenticated routes (keyed per client IP, per host, per handle).
|
|
type limiter struct {
|
|
mu sync.Mutex
|
|
window time.Duration
|
|
max int
|
|
hits map[string]*window
|
|
}
|
|
|
|
type window struct {
|
|
n int
|
|
start time.Time
|
|
}
|
|
|
|
func newLimiter(w time.Duration, max int) *limiter {
|
|
return &limiter{window: w, max: max, hits: map[string]*window{}}
|
|
}
|
|
|
|
// allow records a hit for key and reports whether it is within the window budget.
|
|
// The key map is hard-bounded at limiterMaxKeys.
|
|
func (l *limiter) allow(key string, now time.Time) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if c := l.hits[key]; c != nil && now.Sub(c.start) < l.window {
|
|
if c.n >= l.max {
|
|
return false
|
|
}
|
|
c.n++
|
|
return true
|
|
}
|
|
// New key or a fresh window: hard-bound the map before inserting so key rotation
|
|
// can't grow it without limit.
|
|
if len(l.hits) >= limiterMaxKeys {
|
|
l.prune(now)
|
|
if len(l.hits) >= limiterMaxKeys {
|
|
l.evictOldest()
|
|
}
|
|
}
|
|
l.hits[key] = &window{n: 1, start: now}
|
|
return true
|
|
}
|
|
|
|
// prune drops expired windows so the map can't grow unbounded.
|
|
func (l *limiter) prune(now time.Time) {
|
|
for k, c := range l.hits {
|
|
if now.Sub(c.start) >= l.window {
|
|
delete(l.hits, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// evictOldest drops the single oldest window, keeping the map within its hard cap
|
|
// when it is full of non-expired keys (active key-rotation attack).
|
|
func (l *limiter) evictOldest() {
|
|
var victim string
|
|
var oldest time.Time
|
|
first := true
|
|
for k, c := range l.hits {
|
|
if first || c.start.Before(oldest) {
|
|
oldest, victim, first = c.start, k, false
|
|
}
|
|
}
|
|
if !first {
|
|
delete(l.hits, victim)
|
|
}
|
|
}
|
|
|
|
// clientIP returns the caller's address for rate-limit keying. Behind the ingress,
|
|
// e.RealIP() falls back to the direct socket peer (the ingress pod) unless Base's
|
|
// TrustedProxy headers are configured — which would key every booker to one IP and
|
|
// defeat the per-IP budget. So prefer the forwarded client address the ingress sets.
|
|
// This header is spoofable on its own, which is why the per-host budget is the hard
|
|
// bound; the per-IP budget is defense-in-depth for honest clients.
|
|
func clientIP(e *core.RequestEvent) string {
|
|
if xff := e.Request.Header.Get("X-Forwarded-For"); xff != "" {
|
|
if i := strings.IndexByte(xff, ','); i >= 0 {
|
|
xff = xff[:i]
|
|
}
|
|
if ip := strings.TrimSpace(xff); ip != "" {
|
|
return ip
|
|
}
|
|
}
|
|
if xr := strings.TrimSpace(e.Request.Header.Get("X-Real-IP")); xr != "" {
|
|
return xr
|
|
}
|
|
return e.RealIP()
|
|
}
|
|
|
|
// allow gates a booking request: within both the per-IP and per-host budgets.
|
|
func (p *plugin) allow(e *core.RequestEvent, hostKey string) bool {
|
|
now := time.Now()
|
|
return p.ipLimit.allow("ip:"+clientIP(e), now) && p.hostLimit.allow(hostKey, now)
|
|
}
|
|
|
|
// allowRead gates a public read/reserve by a lightweight per-IP budget. The per-IP
|
|
// key is spoofable via X-Forwarded-For, so it is defense-in-depth for honest
|
|
// clients; the hard bounds on the read path are the clamped slot window, the capped
|
|
// slot generation and the hard-bounded maps.
|
|
func (p *plugin) allowRead(e *core.RequestEvent) bool {
|
|
return p.readIPLimit.allow("ip:"+clientIP(e), time.Now())
|
|
}
|
|
|
|
// allowReadHandle additionally applies a non-spoofable per-handle budget, used by
|
|
// the handle-scoped reads (public event, slots).
|
|
func (p *plugin) allowReadHandle(e *core.RequestEvent, handle string) bool {
|
|
now := time.Now()
|
|
return p.readIPLimit.allow("ip:"+clientIP(e), now) && p.readHandleLimit.allow("handle:"+handle, now)
|
|
}
|