Files
zeekay 2ee214ec51 fix(calendar): bound public read/reserve DoS surface + decouple public handle
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.
2026-07-22 14:32:37 -07:00

93 lines
2.9 KiB
Go

package calendar
import (
"encoding/json"
"time"
"github.com/hanzoai/base/core"
)
// availWindow is one weekly availability window in the schedule's timezone.
type availWindow struct {
Weekday int `json:"weekday"` // 0=Sunday … 6=Saturday
StartMinute int `json:"startMinute"` // minutes past local midnight
EndMinute int `json:"endMinute"`
}
const (
// maxSlotsPerQuery caps how many open slots a single availability query
// generates, so a wide window cannot amplify one small request.
maxSlotsPerQuery = 5000
// maxSlotWindow caps the [startTime,endTime] span a query may request. The day
// loop is then bounded regardless of the attacker-controlled range.
maxSlotWindow = 62 * 24 * time.Hour
)
// interval is a busy span (an existing booking or a synced calendar event),
// already padded by the event type's buffers.
type interval struct{ start, end time.Time }
func (iv interval) overlaps(a, b time.Time) bool { return iv.start.Before(b) && a.Before(iv.end) }
// weeklyWindows reads a schedule's weekly availability robustly, regardless of
// how the JSON field is stored internally.
func weeklyWindows(schedule *core.Record) []availWindow {
var out []availWindow
raw, err := json.Marshal(schedule.Get("weekly"))
if err != nil {
return out
}
_ = json.Unmarshal(raw, &out)
return out
}
// computeSlots generates the open start times in [from,to] for an event of
// durationMin, from the weekly availability evaluated in loc, dropping slots that
// overlap a busy interval or fall inside the minimum-notice horizon. Pure — no DB.
// maxSlots caps the number generated (<=0 means no cap); with a positive cap the
// day loop stops early, so a wide [from,to] can never amplify one request into an
// unbounded amount of work.
func computeSlots(from, to, now time.Time, durationMin, minNoticeMin int, windows []availWindow, loc *time.Location, busy []interval, maxSlots int) []time.Time {
if durationMin <= 0 || len(windows) == 0 {
return nil
}
var slots []time.Time
dur := time.Duration(durationMin) * time.Minute
notBefore := now.Add(time.Duration(minNoticeMin) * time.Minute)
f := from.In(loc)
day := time.Date(f.Year(), f.Month(), f.Day(), 0, 0, 0, 0, loc)
end := to.In(loc)
for !day.After(end) {
wd := int(day.Weekday())
for _, w := range windows {
if w.Weekday != wd {
continue
}
for m := w.StartMinute; m+durationMin <= w.EndMinute; m += durationMin {
start := day.Add(time.Duration(m) * time.Minute)
slotEnd := start.Add(dur)
su := start.UTC()
if su.Before(from) || su.After(to) || su.Before(notBefore) {
continue
}
open := true
for _, iv := range busy {
if iv.overlaps(start, slotEnd) {
open = false
break
}
}
if open {
slots = append(slots, su)
if maxSlots > 0 && len(slots) >= maxSlots {
return slots
}
}
}
}
day = day.AddDate(0, 0, 1)
}
return slots
}