mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-24 22:25:15 +00:00
CanDoAPIRoute's non-CRUD fallback branch compared a path-derived
permission name to the token's permission strings without checking
the request method. A token with projects.background (registered for
GET /projects/:project/background) could therefore invoke DELETE on
the same path. The same method-confusion affected the whole
/projects/:project/views/:view/buckets[/:bucket] cluster, where a
token with projects.views_buckets (registered for GET) authorized
PUT, POST, and DELETE on any accessible view's buckets.
The matcher also leaked CRUD permissions between parent and nested
sub-resource groups. When the request targeted a nested CRUD resource
(e.g. projects_teams, projects_shares, projects_users, projects_views,
projects_webhooks, projects_views_tasks, tasks_assignees, tasks_labels,
tasks_comments, tasks_relations, tasks_attachments, teams_members),
the matcher fell back from the specific group to the parent's permission
list but then looked the permission name up inside the sub-resource's
RouteDetail map. The effect was that a token holding only projects.read_all
also authorized GET on every nested projects_* list endpoint, and the
same held for create/update/delete and for the tasks.* family.
Rewrite the matcher to iterate the token's own permissions and accept
only when the stored RouteDetail's (Path, Method) matches the request.
This removes all the path-derived group guessing and makes the stored
detail the single source of truth. Preserve the tasks.read_all quirk
(one permission, two list endpoints) as an explicit two-path allowlist
inside the loop.
Extract a GetAPITokenRoutes accessor so the new property-based webtest
can consume the same snapshot served by GET /api/v1/routes.
Add TestAPITokenMethodMatching in pkg/webtests: using the live echo
router and the live apiTokenRoutes map, it iterates every advertised
permission against every registered route and asserts the matcher
accepts iff the stored (Path, Method) matches. Any future collision
introduced by a new non-CRUD route on a shared path will be caught.
After this change, previously-dead permissions like
projects.background_delete, projects.views_buckets_{put,post,delete},
other.avatar, other.ws and caldav.access start working as their UI
labels imply. Tokens that relied on the over-broad background /
views_buckets grants, or on cross-cluster CRUD bleed-through, will
lose the extra access - that is the fix.
Refs: GHSA-v479-vf79-mg83
373 lines
11 KiB
Go
373 lines
11 KiB
Go
// 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 models
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"code.vikunja.io/api/pkg/log"
|
|
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
var apiTokenRoutes = map[string]APITokenRoute{}
|
|
|
|
func init() {
|
|
apiTokenRoutes = make(map[string]APITokenRoute)
|
|
apiTokenRoutes["caldav"] = APITokenRoute{
|
|
"access": &RouteDetail{
|
|
Path: "/dav/*",
|
|
Method: "ANY",
|
|
},
|
|
}
|
|
}
|
|
|
|
type APITokenRoute map[string]*RouteDetail
|
|
|
|
type RouteDetail struct {
|
|
Path string `json:"path"`
|
|
Method string `json:"method"`
|
|
}
|
|
|
|
func getRouteGroupName(path string) (finalName string, filteredParts []string) {
|
|
parts := strings.Split(strings.TrimPrefix(path, "/api/v1/"), "/")
|
|
filteredParts = []string{}
|
|
for _, part := range parts {
|
|
if strings.HasPrefix(part, ":") {
|
|
continue
|
|
}
|
|
|
|
filteredParts = append(filteredParts, part)
|
|
}
|
|
|
|
finalName = strings.Join(filteredParts, "_")
|
|
switch finalName {
|
|
case "projects_tasks":
|
|
fallthrough
|
|
case "tasks_all":
|
|
return "tasks", []string{"tasks"}
|
|
default:
|
|
return finalName, filteredParts
|
|
}
|
|
}
|
|
|
|
// getRouteDetail determines the API permission type from the route's HTTP method and path.
|
|
// In Echo v5, route.Name is auto-generated as METHOD:PATH, so we derive permissions from
|
|
// the HTTP method and path structure instead of the handler function name.
|
|
func getRouteDetail(route echo.RouteInfo) (method string, detail *RouteDetail) {
|
|
detail = &RouteDetail{
|
|
Path: route.Path,
|
|
Method: route.Method,
|
|
}
|
|
|
|
// Check if path ends with a parameter (e.g., /:id, /:task, /:project)
|
|
pathParts := strings.Split(route.Path, "/")
|
|
lastPart := ""
|
|
if len(pathParts) > 0 {
|
|
lastPart = pathParts[len(pathParts)-1]
|
|
}
|
|
endsWithParam := strings.HasPrefix(lastPart, ":")
|
|
|
|
switch route.Method {
|
|
case http.MethodGet:
|
|
if endsWithParam {
|
|
return "read_one", detail
|
|
}
|
|
return "read_all", detail
|
|
case http.MethodPut:
|
|
// PUT is used for creating resources in this codebase
|
|
return "create", detail
|
|
case http.MethodPost:
|
|
// POST is used for updating resources
|
|
return "update", detail
|
|
case http.MethodDelete:
|
|
return "delete", detail
|
|
}
|
|
|
|
return "", detail
|
|
}
|
|
|
|
func ensureAPITokenRoutesGroup(group string) {
|
|
if _, has := apiTokenRoutes[group]; !has {
|
|
apiTokenRoutes[group] = make(APITokenRoute)
|
|
}
|
|
}
|
|
|
|
// isStandardCRUDRoute checks if a route follows the standard CRUD pattern.
|
|
// In Echo v5, route.Name is auto-generated as METHOD:PATH, so we can no longer
|
|
// check for "(*WebHandler)" in the name. Instead, we identify CRUD routes by:
|
|
// 1. Path structure: simple /resource or /resource/:param patterns
|
|
// 2. HTTP method: GET, PUT, POST, DELETE matching CRUD semantics
|
|
//
|
|
// Standard CRUD routes have paths like:
|
|
// - /projects, /tasks, /teams, /labels, /notifications, /webhooks, /filters, etc.
|
|
// - /projects/:project, /tasks/:task, /teams/:team, etc.
|
|
//
|
|
// Non-CRUD routes have paths with additional segments or special paths like:
|
|
// - /user/settings/email, /projects/:project/background, /backgrounds/unsplash/search
|
|
func isStandardCRUDRoute(routeGroupName string, routeParts []string, _ string) bool {
|
|
// Standard CRUD resource groups that follow the WebHandler pattern
|
|
crudResources := map[string]bool{
|
|
"projects": true,
|
|
"tasks": true,
|
|
"teams": true,
|
|
"labels": true,
|
|
"filters": true,
|
|
"notifications": true,
|
|
"webhooks": true,
|
|
"reactions": true,
|
|
"shares": true,
|
|
"buckets": true,
|
|
"views": true,
|
|
"assignees": true,
|
|
"comments": true,
|
|
"relations": true,
|
|
"attachments": true,
|
|
"projects_views": true,
|
|
"projects_teams": true,
|
|
"projects_users": true,
|
|
"projects_shares": true,
|
|
"projects_webhooks": true,
|
|
"projects_buckets": true,
|
|
"tasks_attachments": true,
|
|
"tasks_assignees": true,
|
|
"tasks_labels": true,
|
|
"tasks_comments": true,
|
|
"tasks_relations": true,
|
|
"teams_members": true,
|
|
"projects_views_tasks": true,
|
|
}
|
|
|
|
// Check if this is a standard CRUD resource
|
|
if crudResources[routeGroupName] {
|
|
return true
|
|
}
|
|
|
|
// Check if this is a bulk variant of a known CRUD resource
|
|
if strings.HasSuffix(routeGroupName, "_bulk") {
|
|
parent := strings.TrimSuffix(routeGroupName, "_bulk")
|
|
if crudResources[parent] {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Also check the base resource for nested paths
|
|
if len(routeParts) > 0 && crudResources[routeParts[0]] {
|
|
// For single-segment paths, it's CRUD if it's a known resource
|
|
if len(routeParts) == 1 {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// CollectRoutesForAPITokenUsage gets called for every added APITokenRoute and builds a list of all routes we can use for the api tokens.
|
|
// The requiresJWT parameter indicates if this route is protected by JWT authentication.
|
|
func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) {
|
|
|
|
if route.Method == "echo_route_not_found" {
|
|
return
|
|
}
|
|
|
|
if !requiresJWT {
|
|
return
|
|
}
|
|
|
|
routeGroupName, routeParts := getRouteGroupName(route.Path)
|
|
|
|
if routeGroupName == "token_test" ||
|
|
routeGroupName == "subscriptions" ||
|
|
routeGroupName == "tokens" ||
|
|
routeGroupName == "*" ||
|
|
strings.HasPrefix(routeGroupName, "user_") {
|
|
return
|
|
}
|
|
|
|
// Check if this is a standard CRUD route using path-based heuristics
|
|
// In Echo v5, we can no longer rely on route.Name containing "(*WebHandler)"
|
|
isCRUD := isStandardCRUDRoute(routeGroupName, routeParts, route.Method)
|
|
|
|
// Special case for task attachments which use custom handlers
|
|
isAttachmentRoute := routeGroupName == "tasks_attachments"
|
|
|
|
if !isCRUD && !isAttachmentRoute {
|
|
routeDetail := &RouteDetail{
|
|
Path: route.Path,
|
|
Method: route.Method,
|
|
}
|
|
// We're trying to add routes to the routes of a matching "parent" - for
|
|
// example, projects_background should show up under "projects".
|
|
// To do this, we check if the route is a sub route of some other route
|
|
// and if that's the case, add it to its parent instead.
|
|
// Otherwise, we add it to the "other" key.
|
|
if len(routeParts) == 1 {
|
|
if routeGroupName == "notifications" && route.Method == http.MethodPost {
|
|
ensureAPITokenRoutesGroup("notifications")
|
|
|
|
apiTokenRoutes["notifications"]["mark_all_as_read"] = routeDetail
|
|
return
|
|
}
|
|
|
|
ensureAPITokenRoutesGroup("other")
|
|
|
|
_, exists := apiTokenRoutes["other"][routeGroupName]
|
|
if exists {
|
|
routeGroupName += "_" + strings.ToLower(route.Method)
|
|
}
|
|
apiTokenRoutes["other"][routeGroupName] = routeDetail
|
|
return
|
|
}
|
|
|
|
subkey := strings.Join(routeParts[1:], "_")
|
|
|
|
if _, has := apiTokenRoutes[routeParts[0]]; !has {
|
|
apiTokenRoutes[routeParts[0]] = make(APITokenRoute)
|
|
}
|
|
|
|
if _, has := apiTokenRoutes[routeParts[0]][subkey]; has {
|
|
subkey += "_" + strings.ToLower(route.Method)
|
|
}
|
|
|
|
apiTokenRoutes[routeParts[0]][subkey] = routeDetail
|
|
|
|
return
|
|
}
|
|
|
|
if strings.HasSuffix(routeGroupName, "_bulk") {
|
|
parent := strings.TrimSuffix(routeGroupName, "_bulk")
|
|
ensureAPITokenRoutesGroup(parent)
|
|
|
|
method, routeDetail := getRouteDetail(route)
|
|
apiTokenRoutes[parent][method+"_bulk"] = routeDetail
|
|
return
|
|
}
|
|
|
|
_, has := apiTokenRoutes[routeGroupName]
|
|
if !has {
|
|
apiTokenRoutes[routeGroupName] = make(APITokenRoute)
|
|
}
|
|
|
|
method, routeDetail := getRouteDetail(route)
|
|
if method != "" {
|
|
apiTokenRoutes[routeGroupName][method] = routeDetail
|
|
}
|
|
|
|
// Handle task attachments specially - they use custom handlers not WebHandler
|
|
if routeGroupName == "tasks_attachments" {
|
|
// PUT is upload (create), GET with :attachment param is download (read_one)
|
|
if route.Method == http.MethodPut {
|
|
apiTokenRoutes[routeGroupName]["create"] = &RouteDetail{
|
|
Path: route.Path,
|
|
Method: route.Method,
|
|
}
|
|
}
|
|
if route.Method == http.MethodGet && strings.HasSuffix(route.Path, ":attachment") {
|
|
apiTokenRoutes[routeGroupName]["read_one"] = &RouteDetail{
|
|
Path: route.Path,
|
|
Method: route.Method,
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// 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.
|
|
// @tags api
|
|
// @Produce json
|
|
// @Security JWTKeyAuth
|
|
// @Success 200 {array} models.APITokenRoute "The list of all routes."
|
|
// @Router /routes [get]
|
|
func GetAvailableAPIRoutesForToken(c *echo.Context) error {
|
|
return c.JSON(http.StatusOK, GetAPITokenRoutes())
|
|
}
|
|
|
|
// 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
|
|
|
|
for group, perms := range token.APIPermissions {
|
|
routes, has := apiTokenRoutes[group]
|
|
if !has {
|
|
continue
|
|
}
|
|
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 %s which is not covered by its permissions %v",
|
|
token.ID, method, path, token.APIPermissions)
|
|
|
|
return false
|
|
}
|
|
|
|
func PermissionsAreValid(permissions APIPermissions) (err error) {
|
|
|
|
for key, methods := range permissions {
|
|
routes, has := apiTokenRoutes[key]
|
|
if !has {
|
|
return &ErrInvalidAPITokenPermission{
|
|
Group: key,
|
|
}
|
|
}
|
|
|
|
for _, method := range methods {
|
|
if routes[method] == nil {
|
|
return &ErrInvalidAPITokenPermission{
|
|
Group: key,
|
|
Permission: method,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|