diff --git a/internal/api/handler/contact_detail.go b/internal/api/handler/contact_detail.go index 09f0ed3e..73beb7da 100644 --- a/internal/api/handler/contact_detail.go +++ b/internal/api/handler/contact_detail.go @@ -180,6 +180,10 @@ func (h *Handler) ListContactTimeline(c *gin.Context) { errx.Handle(c, xerr) return } + if !models.ContactTimelineSource(source).Valid() { + errx.Handle(c, errx.New(errx.BadRequest, "invalid cursor")) + return + } cursor = &models.ContactTimelineKey{At: at, Source: models.ContactTimelineSource(source), ID: id} } else if raw := c.Query("before"); raw != "" { t, err := time.Parse(time.RFC3339Nano, raw) diff --git a/internal/models/contact.go b/internal/models/contact.go index 1c514ef8..8ef9d7c7 100644 --- a/internal/models/contact.go +++ b/internal/models/contact.go @@ -440,6 +440,14 @@ const ( TimelineSourcePageHit ContactTimelineSource = 14 // website_page_hits ) +// Valid reports whether s names a source the timeline is merged from. A +// cursor carrying any other rank is malformed: zero is reserved for the +// legacy bare-timestamp bound and anything above the last source would +// re-admit the events at the cursor's instant. +func (s ContactTimelineSource) Valid() bool { + return s >= TimelineSourceProgressSent && s <= TimelineSourcePageHit +} + // ContactTimelineKey is one event's position in the merged feed. A page // resumes strictly after the key of the last event it returned, comparing // (At, Source, ID) as a tuple, which is what the opaque cursor carries. diff --git a/internal/models/contact_timeline_test.go b/internal/models/contact_timeline_test.go new file mode 100644 index 00000000..231d7182 --- /dev/null +++ b/internal/models/contact_timeline_test.go @@ -0,0 +1,16 @@ +package models + +import "testing" + +func TestContactTimelineSourceValid(t *testing.T) { + for s := TimelineSourceProgressSent; s <= TimelineSourcePageHit; s++ { + if !s.Valid() { + t.Fatalf("source %d is one the feed merges and must be valid", s) + } + } + for _, s := range []ContactTimelineSource{0, -1, TimelineSourcePageHit + 1, 99} { + if s.Valid() { + t.Fatalf("source %d names no table and must be rejected in a cursor", s) + } + } +}