VYPR
Medium severity6.5NVD Advisory· Published Apr 10, 2026· Updated Apr 17, 2026

CVE-2026-35599

CVE-2026-35599

Description

Vikunja is an open-source self-hosted task management platform. Prior to 2.3.0, the addRepeatIntervalToTime function uses an O(n) loop that advances a date by the task's RepeatAfter duration until it exceeds the current time. By creating a repeating task with a 1-second interval and a due date far in the past, an attacker triggers billions of loop iterations, consuming CPU and holding a database connection for minutes per request. This vulnerability is fixed in 2.3.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
code.vikunja.io/apiGo
< 2.3.02.3.0

Affected products

1

Patches

1
6df0d6c8f54b

feat(tasks): cap repeat_after at 10 years to harden repeating-task handler

https://github.com/go-vikunja/vikunjakolaenteApr 9, 2026via ghsa
3 files changed · +107 0
  • pkg/models/error.go+28 0 modified
    @@ -553,6 +553,34 @@ func (err ErrTaskCannotBeEmpty) HTTPError() web.HTTPError {
     	return web.HTTPError{HTTPCode: http.StatusBadRequest, Code: ErrCodeTaskCannotBeEmpty, Message: "You must provide at least a task title."}
     }
     
    +// ErrInvalidTaskRepeatInterval represents an error where the provided
    +// task repeat interval is outside the allowed range.
    +type ErrInvalidTaskRepeatInterval struct {
    +	RepeatAfter int64
    +}
    +
    +// IsErrInvalidTaskRepeatInterval checks if an error is ErrInvalidTaskRepeatInterval.
    +func IsErrInvalidTaskRepeatInterval(err error) bool {
    +	_, ok := err.(ErrInvalidTaskRepeatInterval)
    +	return ok
    +}
    +
    +func (err ErrInvalidTaskRepeatInterval) Error() string {
    +	return fmt.Sprintf("Invalid task repeat interval. [RepeatAfter: %d]", err.RepeatAfter)
    +}
    +
    +// ErrCodeInvalidTaskRepeatInterval holds the unique world-error code of this error.
    +const ErrCodeInvalidTaskRepeatInterval = 4029
    +
    +// HTTPError holds the http error description.
    +func (err ErrInvalidTaskRepeatInterval) HTTPError() web.HTTPError {
    +	return web.HTTPError{
    +		HTTPCode: http.StatusBadRequest,
    +		Code:     ErrCodeInvalidTaskRepeatInterval,
    +		Message:  fmt.Sprintf("The task repeat interval must be between 0 and %d seconds (10 years).", MaxTaskRepeatAfterSeconds),
    +	}
    +}
    +
     // ErrTaskDoesNotExist represents a "ErrProjectDoesNotExist" kind of error. Used if the project does not exist.
     type ErrTaskDoesNotExist struct {
     	ID int64
    
  • pkg/models/tasks.go+20 0 modified
    @@ -47,6 +47,18 @@ const (
     	TaskRepeatModeFromCurrentDate
     )
     
    +// MaxTaskRepeatAfterSeconds caps repeat_after at ten years. Sized to
    +// stay far from int64 overflow when multiplied out in nanoseconds, and
    +// ten years is already well past any legitimate recurrence.
    +const MaxTaskRepeatAfterSeconds int64 = 10 * 365 * 24 * 3600
    +
    +func validateRepeatAfter(repeatAfter int64) error {
    +	if repeatAfter < 0 || repeatAfter > MaxTaskRepeatAfterSeconds {
    +		return ErrInvalidTaskRepeatInterval{RepeatAfter: repeatAfter}
    +	}
    +	return nil
    +}
    +
     // Task represents a task in a project
     type Task struct {
     	// The unique, numeric id of this task.
    @@ -873,6 +885,10 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool, setB
     		return ErrTaskCannotBeEmpty{}
     	}
     
    +	if err := validateRepeatAfter(t.RepeatAfter); err != nil {
    +		return err
    +	}
    +
     	// Check if the project exists
     	p, err := GetProjectSimpleByID(s, t.ProjectID)
     	if err != nil {
    @@ -1165,6 +1181,10 @@ func (t *Task) updateSingleTask(s *xorm.Session, a web.Auth, fields []string) (e
     		}
     	}
     
    +	if err := validateRepeatAfter(t.RepeatAfter); err != nil {
    +		return err
    +	}
    +
     	// If the task is being moved between projects, make sure to move the bucket + index as well
     	if t.ProjectID != 0 && ot.ProjectID != t.ProjectID {
     		t.Index, err = calculateNextTaskIndex(s, t.ProjectID)
    
  • pkg/models/tasks_test.go+59 0 modified
    @@ -988,6 +988,65 @@ func TestUpdateDone(t *testing.T) {
     	})
     }
     
    +func TestTask_RepeatAfterCap(t *testing.T) {
    +	const maxRepeat int64 = 10 * 365 * 24 * 3600
    +
    +	t.Run("create rejects repeat_after above cap", func(t *testing.T) {
    +		db.LoadAndAssertFixtures(t)
    +		s := db.NewSession()
    +		defer s.Close()
    +
    +		usr := &user.User{ID: 1, Username: "user1"}
    +		task := &Task{
    +			Title:       "nope",
    +			ProjectID:   1,
    +			RepeatAfter: maxRepeat + 1,
    +		}
    +		err := task.Create(s, usr)
    +		require.Error(t, err)
    +		assert.True(t, IsErrInvalidTaskRepeatInterval(err))
    +	})
    +
    +	t.Run("create accepts repeat_after at cap", func(t *testing.T) {
    +		db.LoadAndAssertFixtures(t)
    +		s := db.NewSession()
    +		defer s.Close()
    +
    +		usr := &user.User{ID: 1, Username: "user1"}
    +		task := &Task{
    +			Title:       "ok",
    +			ProjectID:   1,
    +			RepeatAfter: maxRepeat,
    +		}
    +		require.NoError(t, task.Create(s, usr))
    +		require.NoError(t, s.Commit())
    +	})
    +
    +	t.Run("update rejects repeat_after above cap", func(t *testing.T) {
    +		db.LoadAndAssertFixtures(t)
    +		s := db.NewSession()
    +		defer s.Close()
    +
    +		usr := &user.User{ID: 1, Username: "user1"}
    +		task := &Task{
    +			ID:          1,
    +			RepeatAfter: maxRepeat + 1,
    +		}
    +		err := task.Update(s, usr)
    +		require.Error(t, err)
    +		assert.True(t, IsErrInvalidTaskRepeatInterval(err))
    +	})
    +}
    +
    +func TestErrInvalidTaskRepeatInterval(t *testing.T) {
    +	err := ErrInvalidTaskRepeatInterval{RepeatAfter: 999999999999}
    +	assert.True(t, IsErrInvalidTaskRepeatInterval(err))
    +	assert.False(t, IsErrInvalidTaskRepeatInterval(ErrTaskCannotBeEmpty{}))
    +	httpErr := err.HTTPError()
    +	assert.Equal(t, 400, httpErr.HTTPCode)
    +	assert.Equal(t, ErrCodeInvalidTaskRepeatInterval, httpErr.Code)
    +}
    +
     func TestUpdateDone_DoSRegression_AncientDueDate(t *testing.T) {
     	// GHSA-r4fg-73rc-hhh7: ancient due_date + 1s interval used to spin
     	// for billions of iterations. The <1s assertion catches a regression
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.