Dev.to Security 🔐 Cybersecurity 👁 0 📖 7 min read

CVE-2026-68582: How a Share Link Lets You Read Someone Else's Kanban Board (Vikunja BOLA)

TL;DR Target: Vikunja, a self-hosted, open-source to-do and kanban app (Go backend) Affected: 0.24.0 through 2.3.0 → fixed in 2.4.0 Class: BOLA / Broken Object Level Authorization (CWE-639, CWE-863) Endpoint: GET

CVE-2026-68582: How a Share Link Lets You Read Someone Else's Kanban Board (Vikunja BOLA)

TL;DR

  • Target: Vikunja, a self-hosted, open-source to-do and kanban app (Go backend)
  • Affected: 0.24.0 through 2.3.0 → fixed in 2.4.0
  • Class: BOLA / Broken Object Level Authorization (CWE-639, CWE-863)
  • Endpoint: GET /api/v1/projects/{project}/views/{view}/tasks
  • Precondition: any project's share link. No account needed
  • Leaks: bucket titles, bucket creator (created_by: username, name, id), and whether a given project/view ID exists
  • Does not leak: task contents of other projects

Background: share links and kanban views

A share link lets you show a project to someone without an account. Calling POST /api/v1/shares/{hash}/auth with the link's hash returns a share JWT that carries one fact: "this token is for project A" (ProjectID). Share links are meant to be handed to outsiders, so treat them as semi-public.

A view is one way of looking at a project: list, kanban, table. A kanban view owns buckets (the columns: To Do / Doing / Done), and every bucket records who created it (created_by).

Version 0.24.0 added per-view kanban (feat(views)!: return tasks in buckets by view), which introduced projects/{project}/views/{view}/tasks. That endpoint is where this bug lives.

The idea in one analogy

Think of a hotel key card.

The front desk reads the room number (A) off your key card and limits the cleaning list to room A. But the request form also asks for a floor plan (view), and the desk copies whatever you wrote. If you write down room B's floor plan, the cleaning list is still room A's, but the floor plan drags room B's layout and staff names along with it.

The server pinned the project from the token. It never checked that the view from the URL belongs to that project.

Source code walkthrough

The root cause is in TaskCollection.ReadAll (pkg/models/task_collection.go). The snippets below are excerpts from the GitHub security advisory (GHSA-rj9j-8772-4h6c); ... marks omitted code.

Step 1: the view is read straight from the URL

// pkg/models/task_collection.go  (ReadAll)
if tf.ProjectViewID != 0 {
    view, err = GetProjectViewByIDAndProject(s, tf.ProjectViewID, tf.ProjectID) // (URL params)
    ...
}

Both tf.ProjectViewID and tf.ProjectID are bound from the URL path (/projects/{project}/views/{view}), so the attacker controls both.

GetProjectViewByIDAndProject (pkg/models/project_view.go) does what its name says: it looks up a view with WHERE id = ? AND project_id = ?.

  • If the (project, view) pair exists → the view is returned
  • If not → ErrProjectViewDoesNotExist (HTTP 404)

There is no check that the caller may see this view. That is not a flaw in the function itself, since it is a plain lookup. The question is whether an authorization check follows it.

Step 2: the link-share branch never re-validates the view

shareAuth, is := a.(*LinkSharing)
if is {
    project, err := GetProjectSimpleByID(s, shareAuth.ProjectID)   // token's project
    ...
    return getTaskOrTasksInBuckets(s, a, []*Project{project}, view, opts, filteringForBucket, tf.forceFlatTasks)
    // `view` is the foreign, attacker-controlled view — never validated against shareAuth.ProjectID
}

Line by line:

  1. a.(*LinkSharing): a type assertion that checks whether the caller is a share-link token.
  2. GetProjectSimpleByID(s, shareAuth.ProjectID): loads the token's own project (A). This part is correct. Task scope is nailed to A.
  3. getTaskOrTasksInBuckets(..., []*Project{project}, view, ...): passes project A together with the view loaded in step 1, untouched.

The author likely reasoned that pinning the task scope to A makes the request safe. But view is a second, independent input, and nothing confirms it belongs to A. The comment in the excerpt says it best: the view is foreign and attacker-controlled, and never validated against shareAuth.ProjectID.

Step 3: the foreign view's buckets are queried as-is

Following getTaskOrTasksInBuckets into GetTasksInBucketsForView (pkg/models/kanban.go):

err = s.Where("project_view_id = ?", view.ID).OrderBy("position").Find(&buckets)
...
users, err := getUsersOrLinkSharesFromIDs(s, userIDs) // resolves each bucket's created_by
  • First line: loads every bucket belonging to view.ID. If the view is B's, you get B's buckets.
  • Last line: resolves each bucket's created_by ID into a full user object.

The Bucket struct serializes CreatedBy *user.User as json:"created_by", so the creator's username, name, and id go straight into the response.

Why the normal path was safe

A logged-in user takes a different route. getRelevantProjectsFromCollection calls project.CanRead on the URL's project and returns 403 if access is denied, before any bucket data exists.

Only the LinkSharing branch skips that gate. On top of that, ReadAllWeb (pkg/web/handler/read_all.go) performs no authorization of its own, so all authorization for this endpoint lives inside ReadAll. There is exactly one gate, and the link-share branch has a side door around it.

Why task contents don't leak

In step 2 the task query scope is []*Project{project}, i.e. project A. Internally getRawTasksForProjects filters on tasks.project_id IN opts.projectIDs. So B's kanban structure (bucket titles, creators) is visible, but the tasks that would sit inside those buckets are filtered out by A's scope. The advisory states this explicitly: victim task contents do not leak.

Attack flow

  1. Attacker takes any project A's share link and calls POST /api/v1/shares/{hash}/auth → gets a share JWT
  2. Calls GET /api/v1/projects/{B}/views/{V_B}/tasks with that JWT
  3. The server looks up (B, V_B) (no authorization check)
  4. The link-share branch pins tasks to A but keeps view V_B
  5. V_B's buckets and created_by user info come back with 200

Reproducing it

A public PoC repository (comradezephyr/CVE-2026-68582-PoC) exists, but it was empty when I checked. The commands below are the advisory's reproduction steps translated into curl. Run them only against a test instance you control.

HOST="http://localhost:3456/api/v1"

# 1) Get a share JWT from project A's share-link hash
TOKEN=$(curl -s -X POST "$HOST/shares/$SHARE_HASH/auth" \
  -H "Content-Type: application/json" -d '{}' | jq -r '.token')

# 2) Ask for a kanban view that belongs to an unrelated project B
curl -s "$HOST/projects/$B/views/$V_B/tasks" \
  -H "Authorization: Bearer $TOKEN" | jq '.[] | {title, created_by}'
  • A 200 returns B's buckets with title and created_by (username, name, id).
  • Iterating {B}/{V_B} over sequential integers turns the 200-vs-404 difference into an existence oracle.

The advisory also includes a local test. A LinkSharing{ProjectID: 1} (project 1 owned by user 1) called TaskCollection{ProjectID: 2, ProjectViewID: 8}.ReadAll and received project 2's buckets ("testbucket4 - other project", "testbucket40") plus created_by (project 2 is owned by user 3), with no error. A nonexistent view ID returned ErrProjectViewDoesNotExist.

Impact

A single share token, which is a semi-public artifact by design, allows:

  • ID enumeration: project and view IDs are sequential integers, so 404 vs 200 reveals what exists across the whole instance.
  • User enumeration: Vikunja auto-creates a default project with a kanban view for every user. Walking view IDs yields essentially every account's username, name, and user id.
  • Bucket title harvesting: the kanban structure of the entire instance is exposed.

This is not a direct task-content leak. It is PII exposure and reconnaissance for follow-up attacks. Note that a logged-in user also loads the view before checking CanRead, so they get the same ID-existence oracle from the difference between a nonexistent pair (404) and an inaccessible one (403).

Why the score differs by source

This CVE carries different scores depending on where you look:

  • GitHub/CNA (VulnCheck): CVSS v4.0 9.3 (Critical), with High confidentiality, integrity, and availability
  • The same CNA record's CVSS v3.1 is 6.5 (Medium) (PR:L, C:H/I:N/A:N)
  • The GitLab Advisory DB lists 9.8

The advisory's prose describes bucket structure and user identifiers leaking, which reads as a confidentiality-focused information disclosure. A v4 score of 9.3, especially with High integrity and availability, does not line up well with that text. That mismatch is why "Critical, but no task data leaks?" feels off.

The fix

The fix shipped in 2.4.0. The advisory's recommended direction is:

if shareAuth, is := a.(*LinkSharing); is {
    tf.ProjectID = shareAuth.ProjectID
}

placed before the view is loaded. Whatever project the URL names, a link-share caller's ProjectID is overwritten with the token's project.

  • GetProjectViewByIDAndProject(s, viewID, A) then runs WHERE id = V_B AND project_id = A. V_B belongs to B, so the lookup fails and the response is 404.
  • It matches how the task query was already pinned to shareAuth.ProjectID.

The advisory also recommends defense in depth: for every auth type, authorize the caller against the view's project before loading the view. That would close the 404-vs-403 existence oracle for regular users too.

What to do

  • Upgrade Vikunja to 2.4.0 or later. The 2.4.0 release bundles 10 security fixes, so upgrade promptly.
  • If you cannot upgrade right away, avoid distributing share links publicly. Link sharing is enabled by default, so this is a stopgap, not a fix.
  • If you have published share links in the past, assume usernames and bucket titles on your instance may have been harvested.

Takeaway: two inputs, two authorization checks

This bug is not a case of "no authorization at all." The developer clearly pinned the task scope to the token's project. What got missed was a second user-controlled input in the same request (the view).

  • When a request carries more than one object identifier (project, view), verify each one against the caller's scope separately.
  • When authorization is concentrated in one function that branches by auth type, check that every branch performs the same checks. Here the regular-user branch went through CanRead and the link-share branch did not.
  • Features added later can combine with old branches to create new bugs. The advisory notes that the link-share branch had existed for a long time and only became vulnerable once the 0.24.0 view feature arrived.
📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.