diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index c399e9470..c719b4267 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -1508,15 +1508,36 @@ func addOneMonthToDate(d time.Time) time.Time { return time.Date(d.Year(), d.Month()+1, d.Day(), d.Hour(), d.Minute(), d.Second(), d.Nanosecond(), config.GetTimeZone()) } +// addRepeatIntervalToTime advances t by whole multiples of duration until +// it is strictly after now. The previous O(n) loop made a one-second +// interval with an ancient due_date trivial DoS (GHSA-r4fg-73rc-hhh7); +// this computes the answer in constant time. func addRepeatIntervalToTime(now, t time.Time, duration time.Duration) time.Time { - for { - t = t.Add(duration) - if t.After(now) { - break - } + if duration <= 0 { + return t } - return t + // Preserve the original contract: always advance t by at least one + // interval, even when t is already at or after now. + if !t.Before(now) { + return t.Add(duration) + } + + // time.Time.Sub saturates at math.MaxInt64 nanoseconds (~292 years). + // Fall back to "one interval past now" for pathologically old t. + diff := now.Sub(t) + if diff == math.MaxInt64 { + return now.Add(duration) + } + + intervals := int64(diff/duration) + 1 + + // Guard against int64 overflow when multiplying intervals by duration. + if intervals > math.MaxInt64/int64(duration) { + return now.Add(duration) + } + + return t.Add(time.Duration(intervals) * duration) } func setTaskDatesDefault(oldTask, newTask *Task) { diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index f817a1a81..8bdf49001 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -988,6 +988,90 @@ func TestUpdateDone(t *testing.T) { }) } +func TestAddRepeatIntervalToTime(t *testing.T) { + now := time.Date(2026, 4, 9, 12, 0, 0, 0, time.UTC) + day := 24 * time.Hour + + tests := []struct { + name string + now time.Time + t time.Time + duration time.Duration + want time.Time + }{ + { + name: "one day interval, t one day before now", + now: now, + t: now.Add(-day), + duration: day, + want: now.Add(day), + }, + { + name: "one day interval, t exactly one week before now", + now: now, + t: now.Add(-7 * day), + duration: day, + want: now.Add(day), + }, + { + name: "t in the far past (PoC case) completes with sane result", + now: now, + t: time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC), + duration: time.Second, + want: now.Add(time.Second), + }, + { + name: "zero t saturates and falls back", + now: now, + t: time.Time{}, + duration: time.Hour, + want: now.Add(time.Hour), + }, + { + name: "t after now still advances by one interval", + now: now, + t: now.Add(time.Hour), + duration: day, + want: now.Add(time.Hour + day), + }, + { + name: "t equals now still advances", + now: now, + t: now, + duration: day, + want: now.Add(day), + }, + { + name: "zero duration returns t unchanged", + now: now, + t: now.Add(-day), + duration: 0, + want: now.Add(-day), + }, + { + name: "negative duration returns t unchanged", + now: now, + t: now.Add(-day), + duration: -time.Hour, + want: now.Add(-day), + }, + { + name: "tiny duration on ancient date does not overflow", + now: now, + t: time.Date(1800, 1, 1, 0, 0, 0, 0, time.UTC), + duration: 1, + want: now.Add(1), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := addRepeatIntervalToTime(tc.now, tc.t, tc.duration) + assert.Equal(t, tc.want, got) + }) + } +} + func TestTask_ReadOne(t *testing.T) { u := &user.User{ID: 1}