Vikunja: Link-share principal ID collision allows cross-account API token issuance and management
漏洞描述
Executive Summary Vikunja accepts both regular-user JSON Web Tokens (JWTs) and link-share JWTs through its generic authenticated API route group. A link-share JWT resolves to a `models.LinkSharing` principal whose `GetID()` method returns the numeric `link_shares.id`. The API-token management model then treats that generic numeric value as a `users.id` when it creates, lists, and deletes API tokens. Because `users.id` and `link_shares.id` are independent positive sequences, a link share whose ID equals another user's ID is treated as that user by `/api/v1/tokens`. An ordinary authenticated attacker can obtain a target's numeric user ID through authenticated user search and create link shares on an attacker-writable project until the link-share sequence reaches that value. The colliding link-share principal can then list the target's API-token metadata, issue a new API token owned by the target, and delete target-owned API tokens. The newly issued token operates with attacker-selected valid API scopes under the target user's existing permissions. I reviewed the vulnerable `v2.3.0` source, the introducing commit, the stable tag history, and the inspected main revision directly. I also reviewed the recorded local HTTP and native-router validation results. Dynamic testing used only a local `v2.3.0` Docker deployment with PostgreSQL and synthetic data; I did not test any public, hosted, or otherwise external instance. Historical tags were checked from source rather than exercised dynamically. | Field | Value | | --- | --- | | Product | Vikunja API | | Status | Validated; disclosure-ready | | Confirmed affected stable versions | `v0.22.0` through `v2.3.0`, inclusive | | Nearest confirmed unaffected stable version | `v0.21.0` | | Introducing commit | `e6b25bd57b537ef9a72b5acdadf446ca5ef77bfa` | | Latest inspected affected main revision | `95b7e673fb5ee407498fa4b13e8b4c57847a4a0b` | | Fixed version | Unknown; no verified fix commit or release was identified | | Severity | High, CVSS v3.1 score 8.1 | | Vector | `CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N` | | Weakness | Primary: CWE-863 (Incorrect Authorization); secondary: CWE-639 | The stable range is source-based. The issue first became reachable when commit `e6b25bd57b...` added the authenticated API-token CRUD routes while retaining the generic `web.Auth.GetID()` ownership model. `v0.21.0` does not contain the API-token model or these routes. All containing stable tags from `v0.22.0` through `v2.3.0` retain the vulnerable source-to-sink tuple. Downstream forks and vendor-specific builds were not assessed. ## Background ### Two authenticated principal types share one numeric interface Vikunja's authenticated route group is protected by a common token middleware. That middleware can establish a regular user, an API-token owner, or a link share as the request's `web.Auth` principal. The `/api/v1/tokens` handlers are registered on this generic group in `pkg/routes/routes.go`: ```go a.Use(SetupTokenMiddleware()) // API Tokens apiTokenProvider := &handler.WebHandler{ EmptyStruct: func() handler.CObject { return &models.APIToken{} }, } a.GET("/tokens", apiTokenProvider.ReadAllWeb) a.PUT("/tokens", apiTokenProvider.CreateWeb) a.DELETE("/tokens/:token", apiTokenProvider.DeleteWeb) ``` The security-sensitive detail is that authentication answers *which object is the principal*, while `web.Auth.GetID()` exposes only an untyped integer. A regular user's ID belongs to the `users` namespace. A link-share ID belongs to the separately allocated `link_shares` namespace. Equality between those integers does not imply that the principals represent the same actor. ### Link-share authentication preserves the share-row ID `pkg/modules/auth/auth.go` creates a link-share JWT with the share's numeric ID and later resolves that token to a `models.LinkSharing` object: ```go claims["type"] = AuthTypeLinkShare claims["id"] = share.ID claims["hash"] = share.Hash claims["project_id"] = share.ProjectID // ... if typ == AuthTypeLinkShare && config.ServiceEnableLinkSharing.GetBool() { s := db.NewSession() defer s.Close() return models.GetLinkShareFromClaims(s, claims) } ``` The `LinkSharing` model declares its own positive auto-incrementing key and returns it unchanged through the generic interface in `pkg/models/link_sharing.go`: ```go type LinkSharing struct { ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"share"` // ... } func (share *LinkSharing) GetID() int64 { return share.ID } ``` Link shares are expected to act within the shared project's permission boundary. API-token management is instead a user-account credential boundary: tokens are owned by users and can authorize future requests as those users. The normal invariant should therefore be that only a positively identified regular-user principal may enter API-token management, and its user ID must be used as the owner key. ### Attacker capabilities The validated attacker is an ordinary authenticated user, not an administrator. The attacker needs a project they own or can write to so that they can create ordinary link shares. Authenticated user search exposes the matching user's numeric `id` while blanking email addresses in `pkg/routes/api/v1/user_list.go`: ```go users, err := user.ListUsers(s, search, currentUser, nil) // ... for in := range users { users[in].Email = "" } return c.JSON(http.StatusOK, users) ``` That response makes the collision target directly discoverable in the tested default flow. An operator's registration policy or user-search restrictions may alter practical target selection, but they do not restore the missing principal-type check at the API-token boundary. ## Vulnerability Details ### Source-to-sink walkthrough We first authenticate an ordinary attacker account and learn a candidate `<TARGET_USER_ID>` through `GET /api/v1/users`. We then create link shares on an attacker-writable project. `LinkSharing.Create` resets the caller-supplied ID and lets the database allocate the next value: ```go func (share *LinkSharing) Create(s *xorm.Session, a web.Auth) (err error) { // permission and hash generation omitted share.SharedByID = a.GetID() // ... share.ID = 0 _, err = s.Insert(share) return } ``` The two database sequences advance independently. When a newly created share has `share.ID == <TARGET_USER_ID>`, authenticating that share gives us a valid, server-issued link-share JWT. `GetAuthFromClaims` resolves it back to the `LinkSharing` row, so the principal passed to generic handlers now returns `<TARGET_USER_ID>` from `GetID()` even though it is not a user. From here, the API-token model crosses the namespace boundary without checking the principal type. Token creation in `pkg/models/api_tokens.go` assigns the generic ID directly to the user-owned `owner_id` column: ```go func (t *APIToken) Create(s *xorm.Session, a web.Auth) (err error) { // token generation omitted t.OwnerID = a.GetID() if err := PermissionsAreValid(t.APIPermissions); err != nil { return err } _, err = s.Insert(t) return err } ``` There is no compensating permission check: `CanCreate` accepts every `web.Auth` implementation. ```go func (t *APIToken) CanCreate(_ *xorm.Session, _ web.Auth) (bool, error) { return true, nil } ``` If we carry the colliding value into listing, `ReadAll` uses the same generic integer as an owner filter: ```go var where builder.Cond = builder.Eq{"owner_id": a.GetID()} ``` The list response reveals target-owned token IDs, titles, permission sets, expiry, and creation metadata. It does **not** disclose the raw values of existing target tokens. The more consequential confidentiality path is the new credential returned once at creation: because its stored owner is the target, subsequent API-token authentication resolves the target user and enforces the scopes selected in the create request. Deletion is the third manifestation of the same missed invariant. Both the permission de