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

160 lines
6.6 KiB
Go

package calendar
import (
"time"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/security"
"github.com/hanzoai/base/tools/types"
"github.com/hanzoai/dbx"
)
// eventType loads an active event type by its PUBLIC handle and slug. Resolution is
// by (handle, slug) via bound params — never the internal IAM owner id — so the raw
// IAM subject is never a public URL key or an enumeration target. The lookup is
// handle- and slug-scoped, active-only, and rejects an empty handle, so it never
// resolves an unpublished type or enumerates another host's types.
func (p *plugin) eventType(handle, slug string) (*core.Record, error) {
return p.app.FindFirstRecordByFilter("eventType",
"handle={:handle} && slug={:slug} && active=true && handle!=''",
dbx.Params{"handle": handle, "slug": slug})
}
func (p *plugin) bookingByUID(uid string) (*core.Record, error) {
return p.app.FindFirstRecordByFilter("booking", "uid={:uid}", dbx.Params{"uid": uid})
}
// scheduleFor resolves the availability schedule for an event type — the one it
// references, else the host's default.
func (p *plugin) scheduleFor(app core.App, owner, id string) (*core.Record, error) {
if id != "" {
if rec, err := app.FindRecordById("availabilitySchedule", id); err == nil {
return rec, nil
}
}
return app.FindFirstRecordByFilter("availabilitySchedule",
"owner={:owner} && isDefault=true", dbx.Params{"owner": owner})
}
// openSlots computes the host's open start times for the event type in [from,to],
// evaluated in the schedule's timezone, minus existing bookings and synced calendar
// events (padded by buffers). It is the one availability computation both the slots
// listing and the write gate consult. Returns the slots and the schedule record.
func (p *plugin) openSlots(app core.App, owner string, et *core.Record, from, to, now time.Time) ([]time.Time, *core.Record) {
sched, err := p.scheduleFor(app, owner, et.GetString("availabilitySchedule"))
if err != nil {
return nil, nil
}
loc := loadLoc(sched.GetString("timezone"))
busy := p.busyIntervals(app, owner, from, to, et.GetInt("bufferBeforeMinutes"), et.GetInt("bufferAfterMinutes"))
// maxSlotsPerQuery caps generation so a wide window can't amplify one small
// request into a huge response (the handler also clamps the window span).
slots := computeSlots(from, to, now, et.GetInt("durationMinutes"), et.GetInt("minimumNoticeMinutes"), weeklyWindows(sched), loc, busy, maxSlotsPerQuery)
return slots, sched
}
// isOpenSlot reports whether start is a genuinely bookable slot for the event type
// against the given app (which may be a transaction): inside the host's
// availability, past the minimum-notice horizon, future-dated, grid-aligned and
// conflict-free (incl. buffers). It is the single source of truth reused by both
// the slots listing and the booking write, so the write path can never accept a
// time the listing wouldn't render. Advisory holds deliberately do NOT gate this —
// the booker whose hold it is must still be able to book their held slot.
func (p *plugin) isOpenSlot(app core.App, owner string, et *core.Record, start time.Time) bool {
from := start.Add(-24 * time.Hour)
to := start.Add(24 * time.Hour)
slots, _ := p.openSlots(app, owner, et, from, to, time.Now().UTC())
for _, s := range slots {
if s.Equal(start) {
return true
}
}
return false
}
// busyIntervals collects the host's non-cancelled bookings and synced calendar
// events that OVERLAP [from,to], padded by the event type's buffers. The overlap
// is filtered in SQL (startsAt < to && endsAt > from) so a host with many rows
// never blinds the conflict check the way a fixed row cap would.
func (p *plugin) busyIntervals(app core.App, owner string, from, to time.Time, bufBefore, bufAfter int) []interval {
var out []interval
fromDT, _ := types.ParseDateTime(from)
toDT, _ := types.ParseDateTime(to)
params := dbx.Params{"owner": owner, "from": fromDT, "to": toDT}
add := func(s, en time.Time) {
s = s.Add(-time.Duration(bufBefore) * time.Minute)
en = en.Add(time.Duration(bufAfter) * time.Minute)
out = append(out, interval{s, en})
}
if recs, err := app.FindRecordsByFilter("booking",
"owner={:owner} && status!='cancelled' && startsAt<{:to} && endsAt>{:from}",
"startsAt", 2000, 0, params); err == nil {
for _, r := range recs {
add(r.GetDateTime("startsAt").Time(), r.GetDateTime("endsAt").Time())
}
}
if recs, err := app.FindRecordsByFilter("calendarEvent",
"owner={:owner} && isCanceled=false && startsAt<{:to} && endsAt>{:from}",
"startsAt", 2000, 0, params); err == nil {
for _, r := range recs {
add(r.GetDateTime("startsAt").Time(), r.GetDateTime("endsAt").Time())
}
}
return out
}
// attendee carries the validated primary-attendee input from a booking request.
type attendee struct {
Name, Email, Timezone, Notes string
}
// book creates a booking for the event type at start, serializing the final
// availability re-check with the write in one transaction. The partial unique
// index on (owner, startsAt) is the backstop against a concurrent double-book — its
// violation surfaces here as an error. Returns the saved record or errSlotTaken.
//
// DEPLOY GATE: the in-transaction re-check + unique index guarantee exactly-one
// booking only under a single writer. On the default SQLite backend the `cal` App
// CR MUST pin replicas:1 with strategy:Recreate; scaling it out on SQLite would
// reopen the double-book TOCTOU. Multi-writer needs a shared-DB backend.
func (p *plugin) book(owner string, et *core.Record, start time.Time, a attendee) (*core.Record, error) {
col, err := p.app.FindCollectionByNameOrId("booking")
if err != nil {
return nil, err
}
end := start.Add(time.Duration(et.GetInt("durationMinutes")) * time.Minute)
rec := core.NewRecord(col)
rec.Set("owner", owner)
rec.Set("handle", et.GetString("handle")) // denormalized public handle; never surfaces the owner id
rec.Set("eventType", et.Id)
rec.Set("startsAt", start)
rec.Set("endsAt", end)
rec.Set("timezone", a.Timezone)
rec.Set("status", "accepted")
rec.Set("title", et.GetString("title"))
rec.Set("location", et.GetString("location"))
rec.Set("attendeeName", a.Name)
rec.Set("attendeeEmail", a.Email)
rec.Set("attendeeTimezone", a.Timezone)
rec.Set("attendeeNotes", a.Notes)
rec.Set("uid", security.RandomString(24))
err = p.app.RunInTransaction(func(txApp core.App) error {
if !p.isOpenSlot(txApp, owner, et, start) {
return errSlotTaken
}
return txApp.Save(rec)
})
if err != nil {
return nil, errSlotTaken
}
return rec, nil
}
func loadLoc(tz string) *time.Location {
if loc, err := time.LoadLocation(tz); err == nil {
return loc
}
return time.UTC
}