Vikunja Allows Disabled/Locked User Accounts to Authenticate via API Tokens, CalDAV, and OpenID Connect
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.
| Package | Affected versions | Patched versions |
|---|---|---|
code.vikunja.io/apiGo | >= 0.18.0, < 2.2.1 | 2.2.1 |
Affected products
2- go-vikunja/vikunjav5Range: >= 0.18.0, < 2.2.1
Patches
40b04768d830ctest(auth): add comprehensive disabled/locked user auth tests
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") + }) }
fd452b9cb645fix(auth): skip profile updates for disabled LDAP users
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{
033922309f49fix(auth): reject disabled/locked users in CheckUserCredentials
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) {
04704e0fde4bfix(user): reject disabled/locked users in getUser by default
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- github.com/advisories/GHSA-94xm-jj8x-3cr4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33668ghsaADVISORY
- github.com/go-vikunja/vikunja/commit/033922309f492996c928122fb49b691339199c35ghsax_refsource_MISCWEB
- github.com/go-vikunja/vikunja/commit/04704e0fde4b027039cf583110cee7afe136fc1bghsax_refsource_MISCWEB
- github.com/go-vikunja/vikunja/commit/0b04768d830c80e9fde1b0962db1499cc652da0eghsax_refsource_MISCWEB
- github.com/go-vikunja/vikunja/commit/fd452b9cb6457fd4f9936527a14c359818f1cca7ghsax_refsource_MISCWEB
- github.com/go-vikunja/vikunja/security/advisories/GHSA-94xm-jj8x-3cr4ghsax_refsource_CONFIRMWEB
- vikunja.io/changelog/vikunja-v2.2.2-was-releasedghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.