diff --git a/internal/web/service/calendar_renew.go b/internal/web/service/calendar_renew.go index aa5b47786..e5856151a 100644 --- a/internal/web/service/calendar_renew.go +++ b/internal/web/service/calendar_renew.go @@ -93,6 +93,10 @@ func localCalendarDateStart(date time.Time, loc *time.Location) (time.Time, bool return start, localDate(start).Equal(date) } +// maxClientResetDays bounds an interval renewal at a century: a larger one, which a node can +// report, wraps int64 once turned into milliseconds. +const maxClientResetDays = 36500 + func nextClientRenewal(expiry int64, reset, day, weekday int, loc *time.Location) int64 { if day > 0 { return nextCalendarRenewal(time.UnixMilli(expiry), day, loc).UnixMilli() @@ -100,6 +104,9 @@ func nextClientRenewal(expiry int64, reset, day, weekday int, loc *time.Location if weekday > 0 { return nextWeeklyRenewal(time.UnixMilli(expiry), weekday, loc).UnixMilli() } + if reset > maxClientResetDays { + return expiry + } return expiry + int64(reset)*86400000 } diff --git a/internal/web/service/calendar_weekly_bound_test.go b/internal/web/service/calendar_weekly_bound_test.go index 00d992646..6c90524e4 100644 --- a/internal/web/service/calendar_weekly_bound_test.go +++ b/internal/web/service/calendar_weekly_bound_test.go @@ -76,3 +76,21 @@ func TestWeeklyRenewalSearchFailsClosed(t *testing.T) { }) } } + +// A node reports the renewal interval unchecked; one that wraps int64 when turned into +// milliseconds made the catch-up loop step through time for seconds per client. +func TestCatchUpClientRenewalRefusesAnOversizedInterval(t *testing.T) { + now := time.Now().UnixMilli() + for _, reset := range []int{106751991167, maxClientResetDays + 1} { + traffic := &xray.ClientTraffic{Email: "c", Reset: reset, ExpiryTime: now - 86400000} + start := time.Now() + expiry, renewals := catchUpClientRenewal(traffic, now, time.UTC) + if expiry != traffic.ExpiryTime || renewals != 0 || time.Since(start) > 100*time.Millisecond { + t.Fatalf("reset %d: expiry %d, renewals %d after %v; want no renewal", reset, expiry, renewals, time.Since(start)) + } + } + traffic := &xray.ClientTraffic{Email: "c", Reset: maxClientResetDays, ExpiryTime: now - 86400000} + if _, renewals := catchUpClientRenewal(traffic, now, time.UTC); renewals != 1 { + t.Fatalf("a %d-day interval renewed %d times, want once", maxClientResetDays, renewals) + } +} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 773f0d869..c1bac93dd 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -86,6 +86,9 @@ func validateClientRenewal(client model.Client) error { if err := validateClientResetDay(client.ResetDay); err != nil { return err } + if client.Reset > maxClientResetDays { + return common.NewError("client reset must not exceed", maxClientResetDays, "days") + } if client.ResetWeekday < 0 || client.ResetWeekday > 7 { return common.NewError("client resetWeekday must be between 0 and 7, got:", client.ResetWeekday) } diff --git a/internal/web/service/client_renewal_write_validation_test.go b/internal/web/service/client_renewal_write_validation_test.go index b76d9f137..ed950f500 100644 --- a/internal/web/service/client_renewal_write_validation_test.go +++ b/internal/web/service/client_renewal_write_validation_test.go @@ -20,6 +20,7 @@ func TestClientRenewalWriteValidation(t *testing.T) { {"weekly and interval", 7, 0, 3, "client weekly renewal cannot be combined with reset or resetDay"}, {"weekly and monthly", 0, 1, 3, "client weekly renewal cannot be combined with reset or resetDay"}, {"invalid weekday", 0, 0, 8, "client resetWeekday must be between 0 and 7, got: 8"}, + {"oversized interval", maxClientResetDays + 1, 0, 0, "client reset must not exceed 36500 days"}, } { t.Run(operation+"/"+schedule.name, func(t *testing.T) { setupConflictDB(t)