VYPR
High severityNVD Advisory· Published Mar 24, 2026· Updated Mar 26, 2026

Vikunja Allows Disabled/Locked User Accounts to Authenticate via API Tokens, CalDAV, and OpenID Connect

CVE-2026-33668

Description

Vikunja is an open-source self-hosted task management platform. Starting in version 0.18.0 and prior to version 2.2.1, when a user account is disabled or locked, the status check is only enforced on the local login and JWT token refresh paths. Three other authentication paths — API tokens, CalDAV basic auth, and OpenID Connect — do not verify user status, allowing disabled or locked users to continue accessing the API and syncing data. Version 2.2.1 patches the issue.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Vikunja fails to enforce account disable/lock status on API tokens, CalDAV, and OpenID Connect authentication paths, allowing disabled users continued access.

Root

Cause

The vulnerability stems from incomplete enforcement of user account status checks in Vikunja. The status check (for StatusDisabled or StatusAccountLocked) is only implemented in two places: the local/LDAP login handler and the JWT token refresh path. Three other authentication paths—API token authentication, CalDAV basic auth, and the OpenID Connect callback—retrieve the user from the database but never inspect the returned user's status, allowing disabled or locked accounts to authenticate successfully [3].

Exploitation

An attacker whose account has been disabled or locked by an administrator can continue to access Vikunja through any of the three unguarded authentication methods. API tokens, which can be long-lived (up to years), remain fully functional until they expire naturally. CalDAV clients with valid credentials or a CalDAV token can continue syncing calendars and tasks. For OpenID Connect, the user can obtain a fresh, fully valid JWT by re-authenticating through their identity provider, completely bypassing the account disable [3].

Impact

An administrator who disables a user account expects immediate lockout. In practice, the user retains full API access for the remaining lifetime of any issued API tokens, can continue reading and writing tasks/events via CalDAV, or can obtain a new JWT via OIDC. This undermines administrative account management and can lead to unauthorized data access or modification [2][3].

Mitigation

The issue is patched in Vikunja version 2.2.1, released alongside version 2.2.2 with additional fixes. Users are strongly encouraged to upgrade to the latest version to ensure that all authentication paths enforce user status checks [2][3].

AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
code.vikunja.io/apiGo
>= 0.18.0, < 2.2.12.2.1

Affected products

2
  • Vikunja/Vikunjallm-fuzzy
    Range: >= 0.18.0, < 2.2.1
  • go-vikunja/vikunjav5
    Range: >= 0.18.0, < 2.2.1

Patches

4
0b04768d830c

test(auth): add comprehensive disabled/locked user auth tests

https://github.com/go-vikunja/vikunjakolaenteMar 23, 2026via ghsa
5 files changed · +53 0
  • pkg/db/fixtures/api_tokens.yml+10 0 modified
    @@ -38,3 +38,13 @@
       owner_id: 17
       created: 2023-09-01 07:00:00
       # token in plaintext is tk_disabled_user_test_token_000000001234abcd
    +- id: 5
    +  title: 'locked user token'
    +  token_salt: xK9mPr2sNq
    +  token_hash: ee3fb2381e42ec87430519de0b59ce5fbe6ad7e0f0be40948ac28100167ed6f26fc6999f53958f589536d6291418c9419aef
    +  token_last_eight: 12345678
    +  permissions: '{"tasks":["read_all"]}'
    +  expires_at: 2099-01-01 00:00:00
    +  owner_id: 18
    +  created: 2023-09-01 07:00:00
    +  # token in plaintext is tk_locked_user_test_token_0000000012345678
    
  • pkg/db/fixtures/users.yml+9 0 modified
    @@ -136,3 +136,12 @@
       issuer: local
       updated: 2018-12-02 15:13:12
       created: 2018-12-01 15:13:12
    +# Locked user for security tests
    +- id: 18
    +  username: 'user18'
    +  password: '$2a$04$X4aRMEt0ytgPwMIgv36cI..7X9.nhY/.tYwxpqSi0ykRHx2CwQ0S6' # 12345678
    +  email: 'user18@example.com'
    +  status: 3
    +  issuer: local
    +  updated: 2018-12-02 15:13:12
    +  created: 2018-12-01 15:13:12
    
  • pkg/user/user_test.go+10 0 modified
    @@ -329,6 +329,16 @@ func TestCheckUserCredentials(t *testing.T) {
     		require.Error(t, err)
     		assert.True(t, IsErrAccountDisabled(err))
     	})
    +	t.Run("locked user", func(t *testing.T) {
    +		db.LoadAndAssertFixtures(t)
    +		s := db.NewSession()
    +		defer s.Close()
    +
    +		// user18 is locked (status=3), password is "12345678"
    +		_, err := CheckUserCredentials(s, &Login{Username: "user18", Password: "12345678"})
    +		require.Error(t, err)
    +		assert.True(t, IsErrAccountLocked(err))
    +	})
     }
     
     func TestUpdateUser(t *testing.T) {
    
  • pkg/webtests/api_tokens_test.go+15 0 modified
    @@ -112,6 +112,21 @@ func TestAPIToken(t *testing.T) {
     		assert.Equal(t, http.StatusUnauthorized, res.Code)
     		assert.Contains(t, res.Body.String(), `"code":11`)
     	})
    +	t.Run("locked user token rejected", func(t *testing.T) {
    +		e, err := setupTestEnv()
    +		require.NoError(t, err)
    +		req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil)
    +		res := httptest.NewRecorder()
    +		c := e.NewContext(req, res)
    +		h := routes.SetupTokenMiddleware()(func(c *echo.Context) error {
    +			return c.String(http.StatusOK, "test")
    +		})
    +
    +		req.Header.Set(echo.HeaderAuthorization, "Bearer tk_locked_user_test_token_0000000012345678") // Token 5 (locked user 18)
    +		require.NoError(t, h(c))
    +		assert.Equal(t, http.StatusUnauthorized, res.Code)
    +		assert.Contains(t, res.Body.String(), `"code":11`)
    +	})
     	t.Run("jwt", func(t *testing.T) {
     		e, err := setupTestEnv()
     		require.NoError(t, err)
    
  • pkg/webtests/caldav_test.go+9 0 modified
    @@ -770,4 +770,13 @@ func TestCaldavDisabledUserRejected(t *testing.T) {
     		require.NoError(t, err)
     		assert.False(t, result, "disabled user should not be able to authenticate via CalDAV")
     	})
    +	t.Run("locked user cannot authenticate via CalDAV", func(t *testing.T) {
    +		e, _ := setupTestEnv()
    +		c, _ := createRequest(e, http.MethodGet, "", nil, nil)
    +
    +		// user18 is locked (status=3), password is "12345678"
    +		result, err := caldav.BasicAuth(c, "user18", "12345678")
    +		require.NoError(t, err)
    +		assert.False(t, result, "locked user should not be able to authenticate via CalDAV")
    +	})
     }
    
fd452b9cb645

fix(auth): skip profile updates for disabled LDAP users

https://github.com/go-vikunja/vikunjakolaenteMar 23, 2026via ghsa
1 file changed · +5 0
  • pkg/modules/auth/ldap/ldap.go+5 0 modified
    @@ -268,6 +268,11 @@ func getOrCreateLdapUser(s *xorm.Session, entry *ldap.Entry) (u *user.User, err
     		return nil, err
     	}
     
    +	// If the user exists but is disabled/locked, return early without updating profile
    +	if user.IsErrUserStatusError(err) {
    +		return u, nil
    +	}
    +
     	// If no user exists, create one with the preferred username if it is not already taken
     	if user.IsErrUserDoesNotExist(err) {
     		uu := &user.User{
    
033922309f49

fix(auth): reject disabled/locked users in CheckUserCredentials

https://github.com/go-vikunja/vikunjakolaenteMar 23, 2026via ghsa
2 files changed · +18 0
  • pkg/user/user.go+8 0 modified
    @@ -381,6 +381,14 @@ func CheckUserCredentials(s *xorm.Session, u *Login) (*User, error) {
     		return user, err
     	}
     
    +	// After successful password verification, check if the account is disabled or locked
    +	if user.Status == StatusDisabled {
    +		return nil, &ErrAccountDisabled{UserID: user.ID}
    +	}
    +	if user.Status == StatusAccountLocked {
    +		return nil, &ErrAccountLocked{UserID: user.ID}
    +	}
    +
     	return user, nil
     }
     
    
  • pkg/user/user_test.go+10 0 modified
    @@ -319,6 +319,16 @@ func TestCheckUserCredentials(t *testing.T) {
     		_, err := CheckUserCredentials(s, &Login{Username: "user1@example.com", Password: "12345678"})
     		require.NoError(t, err)
     	})
    +	t.Run("disabled user", func(t *testing.T) {
    +		db.LoadAndAssertFixtures(t)
    +		s := db.NewSession()
    +		defer s.Close()
    +
    +		// user17 is disabled (status=2), password is "12345678"
    +		_, err := CheckUserCredentials(s, &Login{Username: "user17", Password: "12345678"})
    +		require.Error(t, err)
    +		assert.True(t, IsErrAccountDisabled(err))
    +	})
     }
     
     func TestUpdateUser(t *testing.T) {
    
04704e0fde4b

fix(user): reject disabled/locked users in getUser by default

https://github.com/go-vikunja/vikunjakolaenteMar 23, 2026via ghsa
1 file changed · +9 1
  • pkg/user/user.go+9 1 modified
    @@ -314,7 +314,15 @@ func getUser(s *xorm.Session, user *User, withEmail bool) (userOut *User, err er
     		userOut.OverdueTasksRemindersTime = "9:00"
     	}
     
    -	return userOut, err
    +	if userOut.Status == StatusDisabled {
    +		return userOut, &ErrAccountDisabled{UserID: userOut.ID}
    +	}
    +
    +	if userOut.Status == StatusAccountLocked {
    +		return userOut, &ErrAccountLocked{UserID: userOut.ID}
    +	}
    +
    +	return userOut, nil
     }
     
     func getUserByUsernameOrEmail(s *xorm.Session, usernameOrEmail string) (u *User, err error) {
    

Vulnerability mechanics

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

References

8

News mentions

0

No linked articles in our index yet.