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:
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.
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.
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)
- 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.
- 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).
- 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)
- Entry: link-share JWT id N (as above). Guard: signature-only;
/user group adds no link-share reject.
- 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).
- 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)
- Entry: link-share JWT id N. Guard: signature-only; no reject on
GET /teams/:team.
- 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
Summary
Vikunja's
web.Authinterface (pkg/web/web.go, single methodGetID() int64) is satisfied by BOTH*user.Userand*models.LinkSharing. A link-share'sGetID()returns the raw positiveshare.ID(pkg/models/link_sharing.go:83-85), which lives in the same positive autoincrement ID space asusers.id. The safe negated formgetUserID() = 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 idN— which should have zero authority over teams or bot users — is treated as the user whoseusers.id == Nat three permission checks that lack thea.(*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.Authis 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:TeamMember.CanDelete(pkg/models/team_members_permissions.go:31-40): the self-removal fast pathif u.ID == a.GetID() { return true }(:36) executes beforeIsAdmin.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.BotUser.isOwner(pkg/models/bot_users_permissions.go:47-56):return u.BotOwnerID == a.GetID()(:55), used byCanRead/CanUpdate/CanDelete(:36-45). UnlikeCanCreate(:27-30) which type-assertsa.(*user.User), these three paths have no principal guard.Team.CanRead(pkg/models/teams_permissions.go:68-78): matches membership onAnd("user_id = ?", a.GetID())(:76) with no link-share guard, unlike siblingIsAdmin(:45-49, guard at :47).Link-share JWTs reach these routes:
SetupTokenMiddlewarevalidates the signature only,GetAuthFromClaimsreturns*models.LinkSharing, and the/user//teamsroute groups add no link-share rejection. Link sharing is enabled by default (config.goServiceEnableLinkSharing.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
Ncollides with a victim'susers.id == Ncan, without being that user or any user:DELETE /api/v1/teams/{T}/members/{username}) → revokes all project permissions the victim inherited through that team.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.GET /api/v1/teams/{T}), plus read bot-user records viaisOwner-gated reads.Attack Chain
Sink 1 — TeamMember.CanDelete (integrity)
POST /api/v1/shares/{hash}/auth→ link-share JWT withid = N. Guard: JWT middleware — signature only. Bypass proof:GetAuthFromClaimsreturns*models.LinkSharing; no route-group link-share rejection on the/teamsgroup.DELETE /api/v1/teams/{T}/members/{usernameOfUserN}with the link-share bearer, targeting a teamT(≥2 members) that userNbelongs to. Guard:CanDelete→GetUserByUsername(tm.Username)returns user N, thenu.ID == a.GetID()(team_members_permissions.go:36). Bypass proof:a.GetID()returnsN(raw positiveshare.ID,link_sharing.go:84) == user N's id →true. Noa.(*LinkSharing)check on this branch (onlyIsAdminat :50 has it, never reached).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)
/usergroup adds no link-share reject.GET /api/v1/user/bots→ReadAllrunsWhere("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).DELETE /api/v1/user/bots/{botId}→CanDelete→isOwner→u.BotOwnerID == a.GetID()(bot_users_permissions.go:55) → true;DeletecallsDeleteUser. Bypass proof: noa.(*LinkSharing)guard here (Create-only, :28). Impact: disable/rename/permanently delete victim's bot automation identities.Sink 3 — Team.CanRead (info disclosure)
GET /teams/:team.GET /api/v1/teams/{T}→CanReadrunsWhere("team_id=?", t.ID).And("user_id=?", a.GetID()).Get(tm)(teams_permissions.go:74-77). Bypass proof:a.GetID()= N matches user N'steam_membersrow →can = true; noa.(*LinkSharing)check (contrastIsAdminat :47). Impact: read roster + metadata of a team the link share is not part of.Bypass Evidence
link_sharing.go:83-85GetID()returns raw positiveshare.ID(NOT the negatedgetUserID()at :126-128).team_members_permissions.go:36rawu.ID == a.GetID()beforeIsAdmin; the LinkSharing guard sits at :50 on a branch never reached when the fast path returns true.bot_users_permissions.go:55rawu.BotOwnerID == a.GetID(); thea.(*user.User)guard at :28 is Create-only and NOT replicated onisOwner.teams_permissions.go:76rawa.GetID()inCanRead; siblingIsAdminhas the guard at :47,CanReadomits it.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 ofmain). Requires default-enabled link sharing.Exploitability Constraint (reflected in AC:H)
The attacker cannot freely choose the colliding id —
link_shares.idis autoincrement. Exploitation is (a) opportunistic (a guest holding a share with id N attacks the user whoseusers.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 }) — whichIsAdmin/CanCreatealready use — to all three sinks: theTeamMember.CanDeleteself-removal fast path (before theu.ID == a.GetID()check),BotUser.isOwner, andTeam.CanRead. Alternatively, resolve principals throughgetUserID()(negated id space) at every permission check so link-share ids can never collide with user ids.Reported by zx (Jace) — GitHub: @manus-use