# Vikunja vulnerable to Improper Authorization and Authorization Bypass Through User-Controlled Key

Vikunja's ProjectView.Delete cascades task_buckets/task_positions deletes on URL-supplied view ID without verifying it belongs to the URL project, letting any user wipe any project's Kanban layout.

- Payout: Research
- Severity: high
- Program: Vikunja
- Found by: KadirArslan
- Disclosed: 2026-08-28
- CVE: CVE-2026-55065
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-gg93-x632-9ccv
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-gg93-x632-9ccv
- Tags: area:web domain:api domain:web impact:dos impact:integrity kind:research platform:ghsa sev:high tech:go technique:source-review vuln:access-control vuln:business-logic vuln:idor

**Target:** DELETE /api/v1/projects/:project/views/:view endpoint (Go/xorm model layer)
**Root cause:** ProjectView.Delete scopes only the project_views delete to (id, project_id); the cascading deletes on task_buckets and task_positions filter on the bare URL-supplied pv.ID. CanDelete only checks Admin on the URL :project and never confirms the :view belongs to it; xorm returns 0 affected rows silently on the scoped mismatch and execution continues.
**Steps:**
1. Authenticate as any user; create a throwaway project via `PUT /api/v1/projects` (attacker becomes Admin via OwnerID).
2. Obtain a victim view ID (auto-increment integers; enumerate sequentially or list via `GET /api/v1/projects/:project/views` if a member).
3. Send `DELETE /api/v1/projects/<own-project>/views/<victim_view_id>` with the attacker JWT.
4. Server returns HTTP 200 `{"message":"Successfully deleted."}`; the scoped delete matches 0 rows, then unscoped deletes wipe task_buckets and task_positions for the victim view.
5. Verify: victim view still exists but `GET /api/v1/projects/<victim>/views/<view>/buckets` shows zero tasks in buckets.
**How found:** Code review of ProjectView.Delete: noticed the first SQL statement couples (id, project_id) but the following two cascades filter only on pv.ID, and that CanDelete checks admin on the URL :project without validating view ownership. Compared against neighbouring safe paths (Bucket.canDoBucket, ProjectView.Update) which re-couple via GetProjectViewByIDAndProject, and noted the same load-by-URL-id-then-cascade shape caused prior advisories GHSA-jfmm-mjcp-8wq2 and GHSA-2vq4-854f-5c72.
**Exploitation:** No capital or special role needed: self-registration plus one owned project suffices. Attacker iterates view IDs 1..N calling DELETE against their own project path; each returns 200 regardless of validity, destroying Kanban bucket assignments and manual task ordering for every view in the instance. Tasks and views survive but layout is unrecoverable without DB backup.
**Escalation:** Trivially scaled to instance-wide destruction by looping over sequential auto-increment view IDs; on multi-tenant hosted deployments a single signup wipes all tenants' Kanban layouts.
**Insight:** When a delete/update handler runs multiple SQL statements, check whether only the first is scoped and later cascades use the raw URL ID; xorm's silent 0-affected-rows on a scoped mismatch means the function keeps going. Grep for sibling handlers in the same model that call GetXByIDAndProject before mutating — the one that doesn't is the bug.

## Proof of concept (copied exactly from the original report)

**Attack steps: DELETE victim view via own project**

```
# As the attacker, with valid JWT:
PUT /api/v1/projects                     -> create throwaway project (ID = P_A)
DELETE /api/v1/projects/P_A/views/V      -> V is ANY view ID in the instance
```

**Vulnerable Delete cascade code**

```
func (pv *ProjectView) Delete(s *xorm.Session, _ web.Auth) (err error) {
    _, err = s.
        Where("id = ? AND project_id = ?", pv.ID, pv.ProjectID).   // (a) scoped — OK
        Delete(&ProjectView{})
    if err != nil {
        return
    }

    _, err = s.Where("project_view_id = ?", pv.ID).Delete(&TaskBucket{})    // (b) UNSCOPED
    if err != nil {
        return
    }

    _, err = s.Where("project_view_id = ?", pv.ID).Delete(&TaskPosition{})  // (c) UNSCOPED
    return
}
```

