Overview
CVE-2026-33668 describes a broken function-level authorization issue where account status checks (enabled/disabled or locked) were only applied to certain login paths (local login and JWT refresh) but not to other authentication mechanisms like API tokens, CalDAV basic auth, and OpenID Connect. This allowed disabled or locked users to continue accessing the API and syncing data via those unprotected paths, creating a real-world risk where account state could be subverted to perform unauthorized actions or data exfiltration. The CVE highlights CWE-285 (Improper Authorization) and CWE-863 (Incorrect Authorization) as the underlying failure modes, where access control decision points were incomplete or inconsistently enforced across authentication surfaces. Vikunja patched this in version 2.2.1, but the vulnerability illustrates a broader class of broken function-level authorization that can manifest in Go (Gin) services when different auth paths diverge in how or when they validate user status.
Affected Versions
0.18.0 through 2.2.0 (i.e., >=0.18.0 and <2.2.1)
Code Fix Example
Go (Gin) API Security Remediation
VULNERABLE_GO_FILE:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
type User struct { ID int64; Active bool }
func getUserFromToken(token string) (int64, error) {
// token validation and user extraction (stubbed)
return 1, nil
}
func main() {
r := gin.Default()
// Local login path with status check
r.POST("/login", func(c *gin.Context){
// ... verify credentials ...
user := User{ID: 1, Active: true}
if !user.Active {
c.JSON(http.StatusUnauthorized, gin.H{"error": "inactive"})
return
}
c.JSON(http.StatusOK, gin.H{"token": "jwt"})
})
// JWT refresh path with status check
r.POST("/auth/refresh", func(c *gin.Context){
// ... validate refresh token and load user ...
user := User{ID: 1, Active: true}
if !user.Active {
c.JSON(http.StatusUnauthorized, gin.H{"error": "inactive"})
return
}
c.JSON(http.StatusOK, gin.H{"token": "new-jwt"})
})
// API token path (vulnerable): validates token but does not check user status
r.GET("/api/v1/tasks", func(c *gin.Context){
token := c.GetHeader("X-API-Token")
if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
// token validated; user status not checked
userID, _ := getUserFromToken(token)
_ = userID
c.JSON(http.StatusOK, gin.H{"tasks": []string{}})
})
// OpenID Connect callback path (vulnerable): status not checked
r.GET("/oidc/callback", func(c *gin.Context){
c.JSON(http.StatusOK, gin.H{"ok": true})
})
r.Run()
})
FIXED_GO_FILE:
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
type User struct { ID int64; Active bool }
func getUserFromToken(token string) (int64, error) {
// token validation and user extraction (stubbed)
return 1, nil
}
func main() {
r := gin.Default()
// Local login path with status check (unchanged)
r.POST("/login", func(c *gin.Context){
// ... verify credentials ...
user := User{ID: 1, Active: true}
if !user.Active {
c.JSON(http.StatusUnauthorized, gin.H{"error": "inactive"})
return
}
c.JSON(http.StatusOK, gin.H{"token": "jwt"})
})
// JWT refresh path with status check (unchanged)
r.POST("/auth/refresh", func(c *gin.Context){
// ... validate refresh token and load user ...
user := User{ID: 1, Active: true}
if !user.Active {
c.JSON(http.StatusUnauthorized, gin.H{"error": "inactive"})
return
}
c.JSON(http.StatusOK, gin.H{"token": "new-jwt"})
})
// Centralized middleware to enforce active status on all authenticated paths
requireActive := func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
userID, _ := getUserFromToken(token)
// In real life, fetch user from DB by userID
user := User{ID: userID, Active: true}
if !user.Active {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "inactive"})
return
}
c.Set("user", user)
c.Next()
}
// Apply active-user enforcement to protected API routes
api := r.Group("/api/v1", requireActive)
api.GET("/tasks", func(c *gin.Context){
c.JSON(http.StatusOK, gin.H{"tasks": []string{}})
})
// OIDC callback path is protected by the same active-user check
r.GET("/oidc/callback", requireActive, func(c *gin.Context){
c.JSON(http.StatusOK, gin.H{"ok": true})
})
r.Run()
}