Medium severity4.3NVD Advisory· Published Apr 10, 2026· Updated Apr 17, 2026
CVE-2026-40103
CVE-2026-40103
Description
Vikunja is an open-source self-hosted task management platform. Prior to 2.3.0, Vikunja's scoped API token enforcement for custom project background routes is method-confused. A token with only projects.background can successfully delete a project background, while a token with only projects.background_delete is rejected. This is a scoped-token authorization bypass. This vulnerability is fixed in 2.3.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
code.vikunja.io/apiGo | < 2.3.0 | 2.3.0 |
Affected products
1Patches
16a0f39b252a8fix(security): enforce HTTP method and path in scoped API token matcher
2 files changed · +127 −40
pkg/models/api_routes.go+33 −40 modified@@ -287,6 +287,12 @@ func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) { } +// GetAPITokenRoutes exposes the registered scoped-token routes so tests +// and the /api/v1/routes handler share a single source of truth. +func GetAPITokenRoutes() map[string]APITokenRoute { + return apiTokenRoutes +} + // GetAvailableAPIRoutesForToken returns a list of all API routes which are available for token usage. // @Summary Get a list of all token api routes // @Description Returns a list of all API routes which are available to use with an api token, not a user login. @@ -296,61 +302,48 @@ func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) { // @Success 200 {array} models.APITokenRoute "The list of all routes." // @Router /routes [get] func GetAvailableAPIRoutesForToken(c *echo.Context) error { - return c.JSON(http.StatusOK, apiTokenRoutes) + return c.JSON(http.StatusOK, GetAPITokenRoutes()) } -// CanDoAPIRoute checks if a token is allowed to use the current api route +// CanDoAPIRoute checks if a token is allowed to use the current api route. +// +// Each permission is authoritative: the request is allowed only if the +// stored (Path, Method) for that permission matches exactly. This closes +// GHSA-v479-vf79-mg83 and the wider method/sub-resource confusion it +// enabled. The one exception is the tasks.read_all quirk handled below. func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) { path := c.Path() if path == "" { // c.Path() is empty during testing, but returns the path which // the route used during registration which is what we need. path = c.Request().URL.Path } + method := c.Request().Method - routeGroupName, routeParts := getRouteGroupName(path) - - routeGroupName = strings.TrimSuffix(routeGroupName, "_bulk") - - if routeGroupName == "user" || - routeGroupName == "users" || - routeGroupName == "routes" { - routeGroupName = "other" - } - - group, hasGroup := token.APIPermissions[routeGroupName] - if !hasGroup { - group, hasGroup = token.APIPermissions[routeParts[0]] - if !hasGroup { - return false - } - } - - var route string - routes, has := apiTokenRoutes[routeGroupName] - if !has { - routes, has = apiTokenRoutes[routeParts[0]] + for group, perms := range token.APIPermissions { + routes, has := apiTokenRoutes[group] if !has { - return false - } - route = strings.Join(routeParts[1:], "_") - } - - // The tasks read_all route is available as /:project/tasks and /tasks/all - therefore we need this workaround here. - if routeGroupName == "tasks" && path == "/api/v1/projects/:project/tasks" && c.Request().Method == http.MethodGet { - route = "read_all" - } - - for _, p := range group { - if route == "" && routes[p] != nil && routes[p].Path == path && routes[p].Method == c.Request().Method { - return true + continue } - if route != "" && p == route { - return true + for _, p := range perms { + rd := routes[p] + if rd == nil { + continue + } + if rd.Method == method && rd.Path == path { + return true + } + // Two list endpoints share tasks.read_all but only one + // survives collection, so allow either explicitly. + if group == "tasks" && p == "read_all" && method == http.MethodGet && + (path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks") { + return true + } } } - log.Debugf("[auth] Token %d tried to use route %s which requires permission %s but has only %v", token.ID, path, route, token.APIPermissions) + log.Debugf("[auth] Token %d tried to use route %s %s which is not covered by its permissions %v", + token.ID, method, path, token.APIPermissions) return false }
pkg/webtests/api_token_method_matching_test.go+94 −0 added@@ -0,0 +1,94 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +package webtests + +import ( + "net/http/httptest" + "strings" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAPITokenMethodMatching is the standing guard for GHSA-v479-vf79-mg83: +// for every advertised permission it builds a single-permission token and +// asserts CanDoAPIRoute matches exactly the permission's stored (method, +// path) across every registered route. Any future contributor who adds a +// non-CRUD route on a shared path, or otherwise reintroduces method +// confusion, fails here. The tasks.read_all quirk is the only exception. +func TestAPITokenMethodMatching(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + type apiRoute struct{ Method, Path string } + var allRoutes []apiRoute + for _, r := range e.Router().Routes() { + if !strings.HasPrefix(r.Path, "/api/v1") { + continue + } + if r.Method == "echo_route_not_found" { + continue + } + allRoutes = append(allRoutes, apiRoute{Method: r.Method, Path: r.Path}) + } + require.NotEmpty(t, allRoutes, "echo router should have registered routes") + + advertised := models.GetAPITokenRoutes() + require.NotEmpty(t, advertised, "GetAPITokenRoutes should be populated by RegisterRoutes") + + // Spec the matcher must conform to. + expectedAuthorized := func(group, perm string, rd *models.RouteDetail, method, path string) bool { + if rd.Method == method && rd.Path == path { + return true + } + if group == "tasks" && perm == "read_all" && method == "GET" && + (path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks") { + return true + } + return false + } + + for group, perms := range advertised { + for perm, rd := range perms { + token := &models.APIToken{ + APIPermissions: models.APIPermissions{group: []string{perm}}, + } + + req := httptest.NewRequest(rd.Method, rd.Path, nil) + c := e.NewContext(req, httptest.NewRecorder()) + assert.Truef(t, models.CanDoAPIRoute(c, token), + "%s.%s must authorize its own stored route %s %s", + group, perm, rd.Method, rd.Path, + ) + + for _, r := range allRoutes { + want := expectedAuthorized(group, perm, rd, r.Method, r.Path) + req := httptest.NewRequest(r.Method, r.Path, nil) + c := e.NewContext(req, httptest.NewRecorder()) + got := models.CanDoAPIRoute(c, token) + assert.Equalf(t, want, got, + "token %s.%s (stored for %s %s) on request %s %s: got=%v want=%v", + group, perm, rd.Method, rd.Path, + r.Method, r.Path, got, want, + ) + } + } + } +}
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- github.com/go-vikunja/vikunja/commit/6a0f39b252a81fa4b19dc56dc889183acc9225aenvdPatchWEB
- github.com/go-vikunja/vikunja/security/advisories/GHSA-v479-vf79-mg83nvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-v479-vf79-mg83ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-40103ghsaADVISORY
- github.com/go-vikunja/vikunja/pull/2584nvdIssue TrackingWEB
- github.com/go-vikunja/vikunja/releases/tag/v2.3.0nvdRelease NotesWEB
News mentions
0No linked articles in our index yet.