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.
123 lines
3.4 KiB
Go
123 lines
3.4 KiB
Go
package calendar
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/tools/security"
|
|
)
|
|
|
|
// holdsMaxEntries is the HARD cap on live holds. reserve() never grows the map past
|
|
// it: when full it prunes expired entries and, if still full, evicts the entry
|
|
// nearest expiry before inserting. This bounds memory even under unauthenticated
|
|
// reserve spam faster than the TTL (pruning expired entries alone does not — that
|
|
// was the bug).
|
|
const holdsMaxEntries = 8192
|
|
|
|
// holds is a small TTL registry of advisory slot reservations. A booker "reserves"
|
|
// a slot the moment they pick it (Cal's useReserveSlot) so it disappears from other
|
|
// bookers' availability listings while they fill in the form. Holds are process-
|
|
// local and best-effort UX only: the authoritative anti-double-book is the
|
|
// transactional isOpenSlot re-check plus the partial unique index in book(), so a
|
|
// missed, expired or evicted hold can never cause a double-booking. The map is hard-
|
|
// bounded (holdsMaxEntries) so it can't grow without limit under reservation spam.
|
|
type holds struct {
|
|
mu sync.Mutex
|
|
ttl time.Duration
|
|
byUID map[string]held
|
|
}
|
|
|
|
type held struct {
|
|
eventTypeID string
|
|
start time.Time
|
|
exp time.Time
|
|
}
|
|
|
|
func newHolds(ttl time.Duration) *holds {
|
|
return &holds{ttl: ttl, byUID: map[string]held{}}
|
|
}
|
|
|
|
// reserve records a hold on (eventTypeID, start) and returns its opaque uid. The map
|
|
// is hard-capped: when full it prunes expired holds and, if still full, evicts the
|
|
// hold nearest expiry, so it never exceeds holdsMaxEntries.
|
|
func (h *holds) reserve(eventTypeID string, start time.Time) string {
|
|
uid := security.RandomString(20)
|
|
now := time.Now()
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if len(h.byUID) >= holdsMaxEntries {
|
|
h.prune(now)
|
|
if len(h.byUID) >= holdsMaxEntries {
|
|
h.evictNearestExpiry()
|
|
}
|
|
}
|
|
h.byUID[uid] = held{eventTypeID: eventTypeID, start: start.UTC(), exp: now.Add(h.ttl)}
|
|
return uid
|
|
}
|
|
|
|
// evictNearestExpiry drops the single hold closest to expiring (the least useful to
|
|
// keep). Called only when the map is full of live holds under active spam.
|
|
func (h *holds) evictNearestExpiry() {
|
|
var victim string
|
|
var soonest time.Time
|
|
first := true
|
|
for uid, v := range h.byUID {
|
|
if first || v.exp.Before(soonest) {
|
|
soonest, victim, first = v.exp, uid, false
|
|
}
|
|
}
|
|
if !first {
|
|
delete(h.byUID, victim)
|
|
}
|
|
}
|
|
|
|
// release drops a hold by its reservation uid (Cal's useDeleteSelectedSlot).
|
|
func (h *holds) release(uid string) {
|
|
if uid == "" {
|
|
return
|
|
}
|
|
h.mu.Lock()
|
|
delete(h.byUID, uid)
|
|
h.mu.Unlock()
|
|
}
|
|
|
|
// releaseStart drops every hold on (eventTypeID, start) — the slot just got booked.
|
|
func (h *holds) releaseStart(eventTypeID string, start time.Time) {
|
|
start = start.UTC()
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
for uid, v := range h.byUID {
|
|
if v.eventTypeID == eventTypeID && v.start.Equal(start) {
|
|
delete(h.byUID, uid)
|
|
}
|
|
}
|
|
}
|
|
|
|
// activeStarts returns the currently-held (non-expired) slot starts for an event
|
|
// type, pruning expired entries as it goes.
|
|
func (h *holds) activeStarts(eventTypeID string) []time.Time {
|
|
now := time.Now()
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
var out []time.Time
|
|
for uid, v := range h.byUID {
|
|
if now.After(v.exp) {
|
|
delete(h.byUID, uid)
|
|
continue
|
|
}
|
|
if v.eventTypeID == eventTypeID {
|
|
out = append(out, v.start)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// prune drops expired holds so the map can't grow unbounded.
|
|
func (h *holds) prune(now time.Time) {
|
|
for uid, v := range h.byUID {
|
|
if now.After(v.exp) {
|
|
delete(h.byUID, uid)
|
|
}
|
|
}
|
|
}
|