CVE-2026-76216
highpriority · track · 0OT risk · elevatedEPSS 0.004 · 30 %ileUSEPSS · scored 2026-10-27
Vikunja through 2.4.0 contains a principal-type confusion vulnerability where LinkSharing principals with id N are treated as user principals with users.id == N at three permission checks lacking type guards. Attackers with a link-share JWT can remove victims from teams, enumerate and delete victim bot users, or read team rosters by exploiting id collisions in the autoincrement space.
cvss
Technical severity, 0–10. One row per scoring source.
base score 0–10 · higher = more severe · hover a vector to decode⚠ sources disagree · medium–high · 5.2–7.7
weaknesses
The CWE weakness classes this flaw belongs to.
affected products (2)
Products and version ranges known to be affected.
advisories (2)
Vendor and ecosystem advisories describing this.
- GHSA-32r8-5843-4qw2Vikunja: Link-share principal-type confusion enables cross-account team removal, bot takeover, and roster disclosure2026-10-09ghsa · osvUS
## Summary Vikunja's `web.Auth` interface (`pkg/web/web.go`, single method `GetID() int64`) is satisfied by BOTH `*user.User` and `*models.LinkSharing`. A link-share's `GetID()` returns the **raw positive** `share.ID` (`pkg/models/link_sharing.go:83-85`), which lives in the same positive autoincrement ID space as `users.id`. The safe negated form `getUserID() = share.ID * -1` (`link_sharing.go:126-128`) exists but is NOT used at three permission sinks. As a result, a link-share principal with id `N` — which should have zero authority over teams or bot users — is treated as the *user* whose `users.id == N` at three permission checks that lack the `a.(*LinkSharing)` guard their sibling methods have. This is the same principal-type-confusion class as CVE-2026-68581 (GHSA-vvcv-vpph-h844), but at three code paths that advisory/fix never touched. ## Root Cause `web.Auth` is a one-method interface (`GetID() int64`). `*LinkSharing.GetID()` returns the raw positive share id. Three permission methods compare this raw id directly and omit the link-share type guard used elsewhere in the same files: 1. **`TeamMember.CanDelete`** (`pkg/models/team_members_permissions.go:31-40`): the self-removal fast path `if u.ID == a.GetID() { return true }` (:36) executes **before** `IsAdmin`. `IsAdmin` (:48-51) is the ONLY place that rejects link shares (`if _, is := a.(*LinkSharing); is { return false }`, :50) — and it is never reached when the fast path returns true. 2. **`BotUser.isOwner`** (`pkg/models/bot_users_permissions.go:47-56`): `return u.BotOwnerID == a.GetID()` (:55), used by `CanRead`/`CanUpdate`/`CanDelete` (:36-45). Unlike `CanCreate` (:27-30) which type-asserts `a.(*user.User)`, these three paths have no principal guard. 3. **`Team.CanRead`** (`pkg/models/teams_permissions.go:68-78`): matches membership on `And("user_id = ?", a.GetID())` (:76) with no link-share guard, unlike sibling `IsAdmin` (:45-49, guard at :47). Link-share JWTs reach these routes: `SetupTokenMiddleware` validates the signature only, `GetAuthFromClaims` returns `*models.LinkSharing`, and the `/user`/`/teams` route groups add no link-share rejection. Link sharing is enabled by default (`config.go` `ServiceEnableLinkSharing.setDefault(true)`). ## Impact A link-share principal (obtainable from any public share link, or self-registered via a share on the attacker's own project) whose id `N` collides with a victim's `users.id == N` can, without being that user or any user: - **Integrity (I:H):** remove the victim from any team they belong to (`DELETE /api/v1/teams/{T}/members/{username}`) → revokes all project permissions the victim inherited through that team. - **Availability/Integrity (A:H, I:H):** enumerate the victim's bot users (`GET /api/v1/user/bots` → `bot_owner_id = a.GetID()`), then disable/rename or **permanently delete** them (`DELETE /api/v1/user/bots/{id}` → `DeleteUser`), destroying data owned solely by those bots. - **Confidentiality (C:H):** read the roster + metadata (name, description, full member list) of any team the colliding user belongs to (`GET /api/v1/teams/{T}`), plus read bot-user records via `isOwner`-gated reads. ## Attack Chain ### Sink 1 — TeamMember.CanDelete (integrity) 1. **Entry:** `POST /api/v1/shares/{hash}/auth` → link-share JWT with `id = N`. **Guard:** JWT middleware — signature only. **Bypass proof:** `GetAuthFromClaims` returns `*models.LinkSharing`; no route-group link-share rejection on the `/teams` group. 2. **Action:** `DELETE /api/v1/teams/{T}/members/{usernameOfUserN}` with the link-share bearer, targeting a team `T` (≥2 members) that user `N` belongs to. **Guard:** `CanDelete` → `GetUserByUsername(tm.Username)` returns user N, then `u.ID == a.GetID()` (`team_members_permissions.go:36`). **Bypass proof:** `a.GetID()` returns `N` (raw positive `share.ID`, `link_sharing.go:84`) == user N's id → `true`. No `a.(*LinkSharing)` check on this branch (only `IsAdmin` at :50 has it, never reached). 3. **Sink:** `Delete` (`team_members.go`) removes user N from team T (last-member check passes when team has ≥2 members). **Impact:** victim loses all project access inherited through team T. ### Sink 2 — BotUser.isOwner (bot takeover / destruction) 1. **Entry:** link-share JWT id N (as above). **Guard:** signature-only; `/user` group adds no link-share reject. 2. **Enumerate:** `GET /api/v1/user/bots` → `ReadAll` runs `Where("bot_owner_id = ?", a.GetID())` = bots owned by user N. **Bypass proof:** `a.GetID()` = N; returns victim's bot ids self-contained (removes the id-guessing barrier). 3. **Sink:** `DELETE /api/v1/user/bots/{botId}` → `CanDelete` → `isOwner` → `u.BotOwnerID == a.GetID()` (`bot_users_permissions.go:55`) → true; `Delete` calls `DeleteUser`. **Bypass proof:** no `a.(*LinkSharing)` guard here (Create-only, :28). **Impact:** disable/rename/permanently delete victim's bot automation identities. ### Sink 3 — Team.CanRead (info disclosure) 1. **Entry:** link-share JWT id N. **Guard:** signature-only; no reject on `GET /teams/:team`. 2. **Sink:** `GET /api/v1/teams/{T}` → `CanRead` runs `Where("team_id=?", t.ID).And("user_id=?", a.GetID()).Get(tm)` (`teams_permissions.go:74-77`). **Bypass proof:** `a.GetID()` = N matches user N's `team_members` row → `can = true`; no `a.(*LinkSharing)` check (contrast `IsAdmin` at :47). **Impact:** read roster + metadata of a team the link share is not part of. ## Bypass Evidence - `link_sharing.go:83-85` `GetID()` returns raw positive `share.ID` (NOT the negated `getUserID()` at :126-128). - `team_members_permissions.go:36` raw `u.ID == a.GetID()` before `IsAdmin`; the LinkSharing guard sits at :50 on a branch never reached when the fast path returns true. - `bot_users_permissions.go:55` raw `u.BotOwnerID == a.GetID()`; the `a.(*user.User)` guard at :28 is Create-only and NOT replicated on `isOwner`. - `teams_permissions.go:76` raw `a.GetID()` in `CanRead`; sibling `IsAdmin` has the guard at :47, `CanRead` omits it. - All three sinks verified present on latest release tag `v2.4.0` (`git show v2.4.0:<file>`). No fix commits touch these files between v2.4.0 and HEAD (the only post-tag commit to link_sharing.go, `c580d51`, merely shadows an embedded Update method). ## Affected Versions `<= 2.4.0` (latest release; also present on HEAD of `main`). Requires default-enabled link sharing. ## Exploitability Constraint (reflected in AC:H) The attacker cannot freely choose the colliding id — `link_shares.id` is autoincrement. Exploitation is (a) *opportunistic* (a guest holding a share with id N attacks the user whose `users.id == N`) or (b) *targeted* (self-register and walk the autoincrement toward a chosen id; low-numbered shares collide with low-numbered/early/admin accounts). This is the identical constraint the accepted CVE-2026-68581 had; it affects target selection (AC), not reachability of the boundary crossing. ## Suggested Fix Add the link-share principal guard (`if _, is := a.(*LinkSharing); is { return false }`) — which `IsAdmin`/`CanCreate` already use — to all three sinks: the `TeamMember.CanDelete` self-removal fast path (before the `u.ID == a.GetID()` check), `BotUser.isOwner`, and `Team.CanRead`. Alternatively, resolve principals through `getUserID()` (negated id space) at every permission check so link-share ids can never collide with user ids. --- Reported by **zx (Jace)** — GitHub: @manus-use
- GO-2026-6711Vikunja: Link-share principal-type confusion enables cross-account team removal, bot takeover, and roster disclosure in code.vikunja.io/api2026-10-19vendor · osvUS
Vikunja: Link-share principal-type confusion enables cross-account team removal, bot takeover, and roster disclosure in code.vikunja.io/api
also known as (2)
Other names and identifiers for this entity.
- GHSA-32r8-5843-4qw2osv
- GO-2026-6711osv
sources
Which sources assert this record, and when.
references (2) nvd
External citations — patches, advisories, analysis.
Attackers weaponise vulnerabilities like this against internet-exposed services. See what is exposed on your perimeter with a free passive scan.
Free scan