GHSA-32r8-5843-4qw2: High severity go/code.vikunja.io/api vulnerability
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/linksharing.go:83-85), which lives in the same positive autoincrement ID space as users.id. The safe negated form getUserID() = share.ID -1 (linksharing.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/teammemberspermissions.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/botuserspermissions.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/teamspermissions.go:68-78): matches membership on And("userid = ?", 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 → botownerid = 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() (teammemberspermissions.go:36). Bypass proof: a.GetID() returns N (raw positive share.ID, linksharing.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 (teammembers.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("botownerid = ?", 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() (botuserspermissions.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("teamid=?", t.ID).And("userid=?", a.GetID()).Get(tm) (teamspermissions.go:74-77). Bypass proof: a.GetID() = N matches user N's teammembers 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 - linksharing.go:83-85 GetID() returns raw positive share.ID (NOT the negated getUserID() at :126-128). - teammemberspermissions.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. - botuserspermissions.go:55 raw u.BotOwnerID == a.GetID(); the a.(user.User) guard at :28 is Create-only and NOT replicated on isOwner. - teamspermissions.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 linksharing.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 — linkshares.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
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Compensating control
In Vikunja, reject LinkSharing principals in TeamMember.CanDelete before the self-removal check, in BotUser.isOwner, and in Team.CanRead by adding `if _, is := a.(*LinkSharing); is { return false }`. Alternatively, use `getUserID()` (the negated share-ID form) for every permission check so link-share IDs cannot collide with user IDs.