Safeguard
Application Security•

A BOLA and BFLA Test Routine, Built From the Vikunja Advisory

Vikunja's CVE-2026-68582 loaded an object before checking access. Here is a repeatable two-user test routine that finds this class of bug in your own API before an advisory does.

Safeguard Security Team
7 min read

On July 19, 2026, the Vikunja project published GitHub advisory GHSA-rj9j-8772-4h6c, tracked as CVE-2026-68582. A holder of a link-share token could read kanban bucket data and user details belonging to other projects. The GitHub Advisory Database listed it on October 9, 2026, with a CVSS 4.0 score of 9.3.

It is a clean example of broken object level authorization (BOLA), and the lesson is about test routine more than about Vikunja. This post walks through the bug, then gives you a two-user test routine you can run against any API you own.

What's affected

  • Product: Vikunja, Go module code.vikunja.io/api in go-vikunja/vikunja.
  • Affected versions: 0.24.0 up to and including 2.3.0.
  • Fixed version: 2.4.0.
  • Weakness: CWE-639 (authorization bypass through a user-controlled key) and CWE-863 (incorrect authorization).

The vulnerable endpoint is GET /api/v1/projects/{project}/views/{view}/tasks. The handler loaded the requested view from the URL before it checked whether the caller was allowed to see it. For a link-share caller, the task query was pinned to the share's own project, but the view the attacker named was reused without being checked against that project.

The result: bucket titles and each bucket's created_by user object (username, name, ID) came back for a view in a different project. Vikunja creates a default project with a kanban view per user, so an attacker could walk view IDs and enumerate usernames and user IDs across the instance. The difference between a 404 and a 403 also worked as an existence oracle for project and view IDs, and ordinary authenticated users could use that part too.

The advisory says the flaw arrived in 0.24.0 with the per-view kanban feature. The normal path was safe because it called project.CanRead on the URL project. Only the link-share branch skipped the check.

How to check if you're exposed

If you run Vikunja, check the running version. Anything from 0.24.0 to 2.3.0 is affected.

docker image ls | grep -i vikunja
# or, for a binary install
./vikunja version

If you build on the Go module, look at your lockfile:

grep -n "code.vikunja.io/api" go.mod go.sum

For your own code, the better question is whether the same pattern exists in your handlers. Search for places where an object is loaded by an ID from the URL and the permission check happens afterward, or only on one branch.

# Rough starting point for a Go or Python service: loads by ID near the top of a handler
grep -rnE "GetByID|get_object_or_404|findById|find_by_id" --include=*.go --include=*.py --include=*.js --include=*.rb src | head -50

That list is long, and most hits are fine. The point is to read each one and ask: where is the check, and does every auth type (session, API key, share link, service token) pass through it?

What to do now

  1. Upgrade Vikunja to 2.4.0 or later. The advisory lists no workaround.
  2. Review which link shares exist on instances that ran 0.24.0 to 2.3.0, and who holds them. Remove any you do not recognize.
  3. Build the two-user test below into your API test suite for every route that takes an object ID.
  4. Make authorization a single function that every route calls, so a new auth type cannot skip it.
  5. Return the same status for "does not exist" and "not yours" where the existence of an ID is itself sensitive.

How this could have been avoided

  • Check access before loading anything. The fix pattern in the advisory sets the project from the share first, then validates the view against it.
  • Test every auth type, not just the default one. The normal path had the check. The link-share branch did not.
  • Add a negative test for each new endpoint that takes a nested ID: valid token, wrong parent.
  • Do not serialize full user objects into nested responses by default. A bucket only needed a title, and created_by leaked identity data.
  • Make 403 and 404 indistinguishable for IDs a caller has no right to know about.

The two-user test routine

BOLA means a caller reaches an object they do not own. BFLA (broken function level authorization) means a caller reaches a function their role should not reach. Both are found by the same habit: act as someone else.

Set up two tenants, A and B, each with an ordinary user. Add one admin and one low-privilege role. Seed each tenant with a few objects and record every ID.

Then run this matrix against every route that takes an ID or performs a privileged action:

TestRequest asTargetExpected
Horizontal readUser AObject owned by B403 or 404, no data
Horizontal writeUser AUpdate or delete B's object403 or 404, object unchanged
Nested ID swapUser AA's parent, B's child IDRejected
Parent swapUser AB's parent, A's child IDRejected
Alternate credentialShare link, API keyAny other tenant's IDRejected
Vertical (BFLA)Low roleAdmin route403
Method swapLow rolePUT or DELETE on a GET route403 or 405
UnauthenticatedNoneAny route401

The nested ID swap is the one Vikunja missed. A short script covers it:

import requests

BASE = "https://app.example.test/api/v1"
tok_a = {"Authorization": "Bearer TOKEN_A"}

# IDs created by tenant B during setup
b_project, b_view = 912, 4471
# IDs created by tenant A
a_project, a_view = 311, 1203

cases = [
    ("own project, own view", a_project, a_view, 200),
    ("own project, B view", a_project, b_view, (403, 404)),
    ("B project, own view", b_project, a_view, (403, 404)),
    ("B project, B view", b_project, b_view, (403, 404)),
]

for name, p, v, want in cases:
    r = requests.get(f"{BASE}/projects/{p}/views/{v}/tasks", headers=tok_a)
    ok = r.status_code in (want if isinstance(want, tuple) else (want,))
    print("PASS" if ok else "FAIL", name, r.status_code)

Run it once per credential type. A link-share token is a different credential, and in this advisory it was the only one that failed.

Why it matters

Authorization bugs do not show up in a scanner that only reads dependency manifests, and pattern-based SAST often cannot tell that a check is missing on one branch. They show up when someone sends the wrong ID with the right token.

The cheap defense is a test that does exactly that, run on every pull request. Once the matrix exists, adding a route means adding rows, and the second auth type is no longer an afterthought.

Try it yourself

Pick one route in your API that takes two IDs in the path. Write the four cases from the script above for it, run them with a second tenant's token, and record the status codes. If the 404 and 403 responses differ for IDs you do not own, note that as a finding too.

Click through it at your own pace, or open it full screen.

Quick check

Why did the normal Vikunja path escape this bug while the link-share path did not?

The normal path called project.CanRead on the project from the URL before building bucket data. The link-share branch pinned the task query to the share's project but reused the attacker-supplied view without validating it against that project.

What is the difference between BOLA and BFLA?

BOLA is reaching an object the caller does not own, such as another tenant's view. BFLA is reaching a function the caller's role should not reach, such as an admin route from a low-privilege account.

Why test with a share link or API key, not only a user session?

Different credential types often take different code branches. In this advisory the check existed on one branch and was missing on the other.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.