GitHub Advisoryadvisory · Go
GHSA-vvcv-vpph-h844 Vikunja: Link-share principal ID collision allows cross-account API token issuance and management
HIGH
Official page ↗
- Aliases
- CVE-2026-68581
- Ecosystem
- Go
- Packages
- code.vikunja.io/api
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N
- Weaknesses
- CWE-863
- Reviewed
- 2026-10-09
Affected packages
• Go code.vikunja.io/api: >= 0.22.0, < 2.4.0 → fixed in 2.4.0
Description
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 `` 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 == `, 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 `` 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 decision and mutation co…
Related
Alias · CVE (1)
- CVE-2026-68581 Vikunja versions 0.22.0 through 2.3.0 fail to validate the principal type in API token management. vulnerability
Weakness · CWE (1)
- CWE-863 Incorrect Authorization weakness
References
- https://github.com/go-vikunja/vikunja/security/advisories/GHSA-vvcv-vpph-h844 web
- https://nvd.nist.gov/vuln/detail/CVE-2026-68581 advisory
- https://github.com/go-vikunja/vikunja/commit/95b7e673fb5ee407498fa4b13e8b4c57847a4a0b web
- https://github.com/go-vikunja/vikunja/commit/e6b25bd57b537ef9a72b5acdadf446ca5ef77bfa web
- https://github.com/go-vikunja/vikunja package
- https://github.com/go-vikunja/vikunja/releases/tag/v2.4.0 web
- https://www.vulncheck.com/advisories/vikunja-through-authentication-bypass-via-principal-id-collision web