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.
93 lines
2.9 KiB
Go
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
|
|
}
|