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:
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:
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:
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:
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:
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:
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.
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:
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 compare only numeric ownership:
func (t *APIToken) CanDelete(s *xorm.Session, a web.Auth) (bool, error) {
token, err := GetAPITokenByID(s, t.ID)
if err != nil {
return false, err
}
if token.OwnerID != a.GetID() {
return false, nil
}
*t = *token
return true, nil
}
func (t *APIToken) Delete(s *xorm.Session, a web.Auth) (err error) {
_, err = s.Where("id = ? AND owner_id = ?", t.ID, a.GetID()).
Delete(&APIToken{})
return err
}
Create, list, and delete are therefore one vulnerability family. They share the same entry point, principal-namespace collision, owner interpretation, and required remediation. Treating the list or delete behavior as separate reports would duplicate the root cause rather than describe independent bugs.
Expected and actual authorization behavior
| Request with a link-share JWT |
Expected |
Actual on v2.3.0 |
GET /api/v1/tokens |
401 or 403 |
200; colliding user's token metadata returned |
PUT /api/v1/tokens |
401 or 403; no row created |
201; token row created with the colliding user as owner |
DELETE /api/v1/tokens/<TOKEN_ID> |
401 or 403; row retained |
200; colliding user's token row deleted |
A positive control using a regular-user JWT returned 200, 201, and 200 for the same GET, PUT, and DELETE sequence. A fix can therefore reject the link-share principal without changing the intended user-facing API contract.
Exploitability Analysis
Strongest validated route: issue a target-owned scoped token
The strongest route is credential issuance rather than metadata listing. We choose a valid API permission set supported by the target version, submit it through the colliding link-share principal, and receive a one-time API token. The database owner field points to <TARGET_USER_ID>. When that token is later presented to an allowed route, normal API-token authentication loads the target user from that owner ID. In the local validation, this path read a private project belonging to the synthetic target.
The token can also be created with valid write-capable scopes from Vikunja's registered permission catalogue. The impact is bounded by both the selected token scopes and the target user's current permissions; this report does not claim access to every endpoint, administrator privileges, instance-wide compromise, or execution outside the application.
Reliability and reachability constraints
The selected target user ID must be greater than or equal to the next link-share sequence value. If the sequence has already passed a target's ID, creating or deleting additional shares cannot move it backward. The validated database behavior was monotonic: deletion did not reuse an ID, and the next share received the next higher value.
Work scales linearly with the gap between the next share ID and <TARGET_USER_ID>: one ordinary share creation is needed per sequence step. The local normal-API validation reached its chosen collision after four bounded share creations. No create-specific per-project share quota was found in the inspected path.
Rate limiting is disabled by default. When enabled with the source defaults, the limit is 100 requests per 60 seconds. That control can slow a large sequence walk, especially when combined with reverse-proxy limits, but it does not remove the authorization flaw; requests can be paced. Very large ID gaps, custom quotas, user-search restrictions, disabled link sharing, or deployment- specific controls can reduce practicality.
Additional manifestations and boundaries
Listing target-token metadata is useful for selecting a token ID for deletion and reveals credential-management state, but not existing raw token values. Deletion can revoke a target's integrations or alter their credential state. Both are integrity-relevant manifestations of the same collision used for issuance.
No service-level availability impact was established; token deletion is scored as integrity impact, so the vector uses A:N. The validated primitive does not establish browser-session takeover, password change, TOTP bypass, remote code execution, or an administrator boundary bypass. Adjacent user-account routes reviewed during validation either rejected link shares explicitly or depended on claims absent from issuer-generated link-share JWTs.
Severity and weakness mapping
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N scores 8.1 High.
AV:N: the vulnerable HTTP API is remotely reachable when the deployment exposes Vikunja normally.
AC:L: no race, secret target identifier, or non-default setting is needed in the validated default flow. The monotonic reachability condition and request count affect target selection and effort, but not an outcome beyond the attacker's control once a reachable target is chosen.
PR:L: an ordinary authenticated account with a writable project is enough.
UI:N: no target-user action is required.
S:U: the confused authorization remains within Vikunja's security authority.
C:H/I:H: a target-owned token can expose private target data and exercise selected valid write scopes under the target's permissions; the attacker can also create and delete target-owned credentials.
A:N: no service-level availability impact was validated.
The primary weakness is CWE-863 because a principal type is not authorized before a user-owned credential operation. CWE-639 is a useful secondary label for the numeric owner-key collision, but it describes the same failure rather than a second vulnerability.
Proof of Concept
Safety and prerequisites
Use a disposable local deployment with synthetic accounts and data. Do not run this sequence against a public or third-party instance. The requests below are deliberately discrete and redacted; no automation or runnable exploit is included.
Prerequisites:
- Link sharing is enabled.
- A synthetic ordinary attacker account can search users.
- The attacker owns or can write to
<ATTACKER_PROJECT_ID>.
<TARGET_USER_ID> is at or above the next link-share ID.
- The target owns
<TARGET_PRIVATE_PROJECT_ID> and the attacker account cannot read it before the test.
Minimal redacted request sequence
First, we discover the synthetic target's numeric ID using the ordinary attacker session:
GET /api/v1/users?s=<REDACTED_TARGET_USERNAME> HTTP/1.1
Host: <LOCAL_VIKUNJA_HOST>
Authorization: Bearer <REDACTED_ATTACKER_JWT>
The relevant redacted response field is:
Next, we create an ordinary link share on an attacker-writable project. Repeat this request as individual lab actions only while the returned ID is lower than <TARGET_USER_ID>; stop immediately if it is higher.
PUT /api/v1/projects/<ATTACKER_PROJECT_ID>/shares HTTP/1.1
Host: <LOCAL_VIKUNJA_HOST>
Authorization: Bearer <REDACTED_ATTACKER_JWT>
Content-Type: application/json
{"name":"collision-validation","permission":0}
At the vulnerable state, the server returns a share whose redacted values satisfy:
<CREATED_SHARE_ID> == <TARGET_USER_ID>
<REDACTED_SHARE_HASH> identifies that share
We authenticate the colliding share through the normal link-share endpoint:
POST /api/v1/shares/<REDACTED_SHARE_HASH>/auth HTTP/1.1
Host: <LOCAL_VIKUNJA_HOST>
Content-Type: application/json
{}
Store the returned credential only as <REDACTED_LINK_SHARE_JWT>. With that server-issued link-share JWT, the following request should be rejected but is accepted on v2.3.0:
GET /api/v1/tokens HTTP/1.1
Host: <LOCAL_VIKUNJA_HOST>
Authorization: Bearer <REDACTED_LINK_SHARE_JWT>
The response returns metadata for tokens owned by <TARGET_USER_ID>. It does not return their raw credential values.
We then request a new token with a narrow read scope sufficient for the private project check:
PUT /api/v1/tokens HTTP/1.1
Host: <LOCAL_VIKUNJA_HOST>
Authorization: Bearer <REDACTED_LINK_SHARE_JWT>
Content-Type: application/json
{
"title": "collision-validation",
"permissions": {"projects": ["read_one"]},
"expires_at": "<FUTURE_TIMESTAMP>"
}
Record the one-time returned value only as <REDACTED_CREATED_API_TOKEN> and the returned row ID only as <CREATED_TOKEN_ID>. Presenting the new token to the target's private project endpoint demonstrates the cross-account result:
GET /api/v1/projects/<TARGET_PRIVATE_PROJECT_ID> HTTP/1.1
Host: <LOCAL_VIKUNJA_HOST>
Authorization: Bearer <REDACTED_CREATED_API_TOKEN>
Expected secure behavior is rejection because the attacker account has no access to that project and a link share should never mint a user-owned API token. Actual vulnerable behavior is a successful response under the target user's project permissions.
Finally, the same colliding link-share JWT can delete a target-owned token:
DELETE /api/v1/tokens/<TARGET_TOKEN_ID> HTTP/1.1
Host: <LOCAL_VIKUNJA_HOST>
Authorization: Bearer <REDACTED_LINK_SHARE_JWT>
On a vulnerable target, this returns success and removes the matching target-owned row. A secure implementation should return 401 or 403 and retain the row.
Representative validated result
user_search_exposed_target_id=true
collision_is_exact=true
bounded_share_creations=4
created_token_owner_is_target=true
created_token_read_target_private_project=true
link_share_get_tokens_status=200
link_share_put_tokens_status=201
link_share_delete_token_status=200
regular_user_positive_control=passed
All values in this report are symbolic or redacted. The local validation removed the created API-token rows, link-share rows, and explicit test projects; cleanup counts were zero. Raw JWTs, API tokens, existing target-token values, passwords, cookies, and authorization values were not retained in the report.
Remediation
Restore a typed principal invariant
Every API-token management operation should positively require a regular-user principal before any numeric ID reaches an owner comparison, query, insertion, or deletion. A denylist that rejects only LinkSharing would be weaker because future web.Auth implementations could recreate the same namespace confusion. The safer invariant is: API-token ownership is derived only from a verified user principal and never from a generic GetID() value.
One possible shape is a shared resolver used by create, list, delete, and their permission methods. The following is illustrative rather than an applied patch; the concrete error should follow Vikunja's existing permission-error conventions:
func apiTokenUserID(a web.Auth) (int64, bool) {
u, ok := a.(*user.User)
if !ok || u.ID <= 0 {
return 0, false
}
return u.ID, true
}
Each API-token method should fail closed when the boolean is false, then use the returned userID for every owner_id operation. Applying the check only to creation would leave metadata disclosure and deletion exposed; applying it only in route registration would leave model-level callers dependent on a fragile external invariant. Defense in depth at both the route/service boundary and the user-owned model boundary is preferable.
Longer term, principal identity should include its namespace or type rather than relying on interchangeable integers. A typed principal discriminator, separate interfaces for user-owned operations, or a structured identity such as (principal_type, principal_id) would make this class of collision harder to introduce elsewhere.
Regression coverage
Add full-router tests using a real issuer-generated link-share JWT whose share ID equals a fixture user's ID. The secure assertions should be:
- link-share
GET /api/v1/tokens returns 401 or 403 and exposes no target token metadata;
- link-share
PUT /api/v1/tokens returns 401 or 403 and creates no row;
- link-share
DELETE /api/v1/tokens/<TOKEN_ID> returns 401 or 403 and retains the target row;
- regular-user JWT GET, PUT, and DELETE retain their existing successful behavior;
- a non-user
web.Auth test double with a colliding positive ID is rejected, preventing the fix from depending on one concrete link-share type.
The current vulnerable v2.3.0 router produced 200, 201, and 200 for the three negative cases, including the expected create/delete database side effects. Those tests should pass only after the typed-principal invariant is implemented. Any fix should also be verified against the inspected main revision, whose API-token model has evolved but still lacks a positive regular-user gate.
Disclosure and redaction notes
This report is prepared for private coordinated disclosure and has not been submitted or published. A review of the public official advisory index did not identify the same API-token principal-ID collision root cause, but that screen cannot rule out private, pending, or future duplicates. No fixed version is claimed.
The reproduction uses only symbolic placeholders and synthetic observations. No raw credential, real account identifier, internal host detail, or external target result is included. No product patch, automated exploit, public issue, or pull request accompanies this report.
Summary
Vikunja routes API-token management through a generic authenticated principal and derives user-owned token authorization from web.Auth.GetID(). A link-share principal returns its independent share-row ID through that same interface. When the numeric values collide, the application mistakes a link share for an unrelated user at the API-token owner boundary.
We can steer the reachable collision through ordinary authenticated user search and link-share creation when the target ID has not already been passed. From the colliding share, the validated behavior lists target-owned token metadata, issues a scoped API token owned by the target, uses that credential under the target's project permissions, and deletes target-owned tokens. The strongest demonstrated impact is cross-account confidentiality and integrity, not browser-session or server compromise.
The immediate fix is to require a positive regular-user principal throughout API-token management and to cover GET, PUT, and DELETE with negative link-share tests and positive user controls. A broader review of user-owned models that accept generic numeric principals would be useful variant analysis, but no additional vulnerability is claimed here.
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.LinkSharingprincipal whoseGetID()method returns the numericlink_shares.id. The API-token management model then treats that generic numeric value as ausers.idwhen it creates, lists, and deletes API tokens.Because
users.idandlink_shares.idare 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.0source, 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 localv2.3.0Docker 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.v0.22.0throughv2.3.0, inclusivev0.21.0e6b25bd57b537ef9a72b5acdadf446ca5ef77bfa95b7e673fb5ee407498fa4b13e8b4c57847a4a0bCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:NThe stable range is source-based. The issue first became reachable when commit
e6b25bd57b...added the authenticated API-token CRUD routes while retaining the genericweb.Auth.GetID()ownership model.v0.21.0does not contain the API-token model or these routes. All containing stable tags fromv0.22.0throughv2.3.0retain 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.Authprincipal. The/api/v1/tokenshandlers are registered on this generic group inpkg/routes/routes.go: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 theusersnamespace. A link-share ID belongs to the separately allocatedlink_sharesnamespace. 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.gocreates a link-share JWT with the share's numeric ID and later resolves that token to amodels.LinkSharingobject:The
LinkSharingmodel declares its own positive auto-incrementing key and returns it unchanged through the generic interface inpkg/models/link_sharing.go: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
idwhile blanking email addresses inpkg/routes/api/v1/user_list.go: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>throughGET /api/v1/users. We then create link shares on an attacker-writable project.LinkSharing.Createresets the caller-supplied ID and lets the database allocate the next value: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.GetAuthFromClaimsresolves it back to theLinkSharingrow, so the principal passed to generic handlers now returns<TARGET_USER_ID>fromGetID()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.goassigns the generic ID directly to the user-ownedowner_idcolumn:There is no compensating permission check:
CanCreateaccepts everyweb.Authimplementation.If we carry the colliding value into listing,
ReadAlluses the same generic integer as an owner filter: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 compare only numeric ownership:
Create, list, and delete are therefore one vulnerability family. They share the same entry point, principal-namespace collision, owner interpretation, and required remediation. Treating the list or delete behavior as separate reports would duplicate the root cause rather than describe independent bugs.
Expected and actual authorization behavior
v2.3.0GET /api/v1/tokens401or403200; colliding user's token metadata returnedPUT /api/v1/tokens401or403; no row created201; token row created with the colliding user as ownerDELETE /api/v1/tokens/<TOKEN_ID>401or403; row retained200; colliding user's token row deletedA positive control using a regular-user JWT returned
200,201, and200for the same GET, PUT, and DELETE sequence. A fix can therefore reject the link-share principal without changing the intended user-facing API contract.Exploitability Analysis
Strongest validated route: issue a target-owned scoped token
The strongest route is credential issuance rather than metadata listing. We choose a valid API permission set supported by the target version, submit it through the colliding link-share principal, and receive a one-time API token. The database owner field points to
<TARGET_USER_ID>. When that token is later presented to an allowed route, normal API-token authentication loads the target user from that owner ID. In the local validation, this path read a private project belonging to the synthetic target.The token can also be created with valid write-capable scopes from Vikunja's registered permission catalogue. The impact is bounded by both the selected token scopes and the target user's current permissions; this report does not claim access to every endpoint, administrator privileges, instance-wide compromise, or execution outside the application.
Reliability and reachability constraints
The selected target user ID must be greater than or equal to the next link-share sequence value. If the sequence has already passed a target's ID, creating or deleting additional shares cannot move it backward. The validated database behavior was monotonic: deletion did not reuse an ID, and the next share received the next higher value.
Work scales linearly with the gap between the next share ID and
<TARGET_USER_ID>: one ordinary share creation is needed per sequence step. The local normal-API validation reached its chosen collision after four bounded share creations. No create-specific per-project share quota was found in the inspected path.Rate limiting is disabled by default. When enabled with the source defaults, the limit is 100 requests per 60 seconds. That control can slow a large sequence walk, especially when combined with reverse-proxy limits, but it does not remove the authorization flaw; requests can be paced. Very large ID gaps, custom quotas, user-search restrictions, disabled link sharing, or deployment- specific controls can reduce practicality.
Additional manifestations and boundaries
Listing target-token metadata is useful for selecting a token ID for deletion and reveals credential-management state, but not existing raw token values. Deletion can revoke a target's integrations or alter their credential state. Both are integrity-relevant manifestations of the same collision used for issuance.
No service-level availability impact was established; token deletion is scored as integrity impact, so the vector uses
A:N. The validated primitive does not establish browser-session takeover, password change, TOTP bypass, remote code execution, or an administrator boundary bypass. Adjacent user-account routes reviewed during validation either rejected link shares explicitly or depended on claims absent from issuer-generated link-share JWTs.Severity and weakness mapping
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:Nscores 8.1 High.AV:N: the vulnerable HTTP API is remotely reachable when the deployment exposes Vikunja normally.AC:L: no race, secret target identifier, or non-default setting is needed in the validated default flow. The monotonic reachability condition and request count affect target selection and effort, but not an outcome beyond the attacker's control once a reachable target is chosen.PR:L: an ordinary authenticated account with a writable project is enough.UI:N: no target-user action is required.S:U: the confused authorization remains within Vikunja's security authority.C:H/I:H: a target-owned token can expose private target data and exercise selected valid write scopes under the target's permissions; the attacker can also create and delete target-owned credentials.A:N: no service-level availability impact was validated.The primary weakness is CWE-863 because a principal type is not authorized before a user-owned credential operation. CWE-639 is a useful secondary label for the numeric owner-key collision, but it describes the same failure rather than a second vulnerability.
Proof of Concept
Safety and prerequisites
Use a disposable local deployment with synthetic accounts and data. Do not run this sequence against a public or third-party instance. The requests below are deliberately discrete and redacted; no automation or runnable exploit is included.
Prerequisites:
<ATTACKER_PROJECT_ID>.<TARGET_USER_ID>is at or above the next link-share ID.<TARGET_PRIVATE_PROJECT_ID>and the attacker account cannot read it before the test.Minimal redacted request sequence
First, we discover the synthetic target's numeric ID using the ordinary attacker session:
The relevant redacted response field is:
Next, we create an ordinary link share on an attacker-writable project. Repeat this request as individual lab actions only while the returned ID is lower than
<TARGET_USER_ID>; stop immediately if it is higher.At the vulnerable state, the server returns a share whose redacted values satisfy:
We authenticate the colliding share through the normal link-share endpoint:
Store the returned credential only as
<REDACTED_LINK_SHARE_JWT>. With that server-issued link-share JWT, the following request should be rejected but is accepted onv2.3.0:The response returns metadata for tokens owned by
<TARGET_USER_ID>. It does not return their raw credential values.We then request a new token with a narrow read scope sufficient for the private project check:
Record the one-time returned value only as
<REDACTED_CREATED_API_TOKEN>and the returned row ID only as<CREATED_TOKEN_ID>. Presenting the new token to the target's private project endpoint demonstrates the cross-account result:Expected secure behavior is rejection because the attacker account has no access to that project and a link share should never mint a user-owned API token. Actual vulnerable behavior is a successful response under the target user's project permissions.
Finally, the same colliding link-share JWT can delete a target-owned token:
On a vulnerable target, this returns success and removes the matching target-owned row. A secure implementation should return
401or403and retain the row.Representative validated result
All values in this report are symbolic or redacted. The local validation removed the created API-token rows, link-share rows, and explicit test projects; cleanup counts were zero. Raw JWTs, API tokens, existing target-token values, passwords, cookies, and authorization values were not retained in the report.
Remediation
Restore a typed principal invariant
Every API-token management operation should positively require a regular-user principal before any numeric ID reaches an owner comparison, query, insertion, or deletion. A denylist that rejects only
LinkSharingwould be weaker because futureweb.Authimplementations could recreate the same namespace confusion. The safer invariant is: API-token ownership is derived only from a verified user principal and never from a genericGetID()value.One possible shape is a shared resolver used by create, list, delete, and their permission methods. The following is illustrative rather than an applied patch; the concrete error should follow Vikunja's existing permission-error conventions:
Each API-token method should fail closed when the boolean is false, then use the returned
userIDfor everyowner_idoperation. Applying the check only to creation would leave metadata disclosure and deletion exposed; applying it only in route registration would leave model-level callers dependent on a fragile external invariant. Defense in depth at both the route/service boundary and the user-owned model boundary is preferable.Longer term, principal identity should include its namespace or type rather than relying on interchangeable integers. A typed principal discriminator, separate interfaces for user-owned operations, or a structured identity such as
(principal_type, principal_id)would make this class of collision harder to introduce elsewhere.Regression coverage
Add full-router tests using a real issuer-generated link-share JWT whose share ID equals a fixture user's ID. The secure assertions should be:
GET /api/v1/tokensreturns401or403and exposes no target token metadata;PUT /api/v1/tokensreturns401or403and creates no row;DELETE /api/v1/tokens/<TOKEN_ID>returns401or403and retains the target row;web.Authtest double with a colliding positive ID is rejected, preventing the fix from depending on one concrete link-share type.The current vulnerable
v2.3.0router produced200,201, and200for the three negative cases, including the expected create/delete database side effects. Those tests should pass only after the typed-principal invariant is implemented. Any fix should also be verified against the inspected main revision, whose API-token model has evolved but still lacks a positive regular-user gate.Disclosure and redaction notes
This report is prepared for private coordinated disclosure and has not been submitted or published. A review of the public official advisory index did not identify the same API-token principal-ID collision root cause, but that screen cannot rule out private, pending, or future duplicates. No fixed version is claimed.
The reproduction uses only symbolic placeholders and synthetic observations. No raw credential, real account identifier, internal host detail, or external target result is included. No product patch, automated exploit, public issue, or pull request accompanies this report.
Summary
Vikunja routes API-token management through a generic authenticated principal and derives user-owned token authorization from
web.Auth.GetID(). A link-share principal returns its independent share-row ID through that same interface. When the numeric values collide, the application mistakes a link share for an unrelated user at the API-token owner boundary.We can steer the reachable collision through ordinary authenticated user search and link-share creation when the target ID has not already been passed. From the colliding share, the validated behavior lists target-owned token metadata, issues a scoped API token owned by the target, uses that credential under the target's project permissions, and deletes target-owned tokens. The strongest demonstrated impact is cross-account confidentiality and integrity, not browser-session or server compromise.
The immediate fix is to require a positive regular-user principal throughout API-token management and to cover GET, PUT, and DELETE with negative link-share tests and positive user controls. A broader review of user-owned models that accept generic numeric principals would be useful variant analysis, but no additional vulnerability is claimed here.